@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/README.md CHANGED
@@ -1,93 +1,135 @@
1
1
  # @usequeek/app-sdk
2
2
 
3
- The SDK for building a Queek app on the public Merchant API and signed webhooks. Framework-agnostic Web-standard handlers for the install handoff and topic webhooks (`Request` in, `Response` out — use them from Next.js route handlers, Express, or any runtime), a typed Merchant API client (types generated from the live contract), GitHub-style app credentials (one asymmetric key per app, short-lived per-installation tokens minted on demand), resync recovery, and an encrypted installation store (SQLite for local/test, Postgres in production). Optional Hono wrappers live under `@usequeek/app-sdk/hono`.
3
+ The SDK for building an app that installs into Queek stores. It verifies the signed install handoff and topic webhooks, mints and caches installation tokens, calls the Merchant API through a typed client, and keeps installations in an encrypted store.
4
4
 
5
- Full guide: <https://docs.usequeek.com/docs/apps/sdk>.
5
+ Full guide: <https://docs.usequeek.com/docs/apps/sdk>. Merchant API reference: <https://api.usequeek.com/docs/merchant.json>.
6
6
 
7
- > Pre-1.0: the API can still change; pin the exact version (`npm i @usequeek/app-sdk@<version>`).
7
+ > Pre-1.0. The API can still change between minor versions. Pin the exact version and read [CHANGELOG.md](./CHANGELOG.md) before you upgrade.
8
8
 
9
- ```sh
10
- npm i @usequeek/app-sdk pg
11
- ```
12
-
13
- Requires Node `>=22.14`. `pg` is a regular dependency (the production store). `hono` is an optional peer — install it (`npm i hono`) only if you use the Hono wrappers.
14
-
15
- ## Quick start for AI agents
9
+ ## Requirements
16
10
 
17
- Register the app on the Queek Developer page, then use the CLI:
11
+ - Node.js 22.14 or later.
12
+ - `hono` (optional): only for the wrappers in `@usequeek/app-sdk/hono`.
13
+ - `react` 18 or 19 (optional): only for `@usequeek/app-sdk/react`.
18
14
 
19
- 1. Scaffold: `npm create @usequeek/app my-app` (same as `queek app init`).
20
- 2. Develop: `queek app dev` — tunnel + dev-store install; mints credentials into `.queek/.env.local` on first run.
21
- 3. Deploy: `queek app deploy` — pushes `queek.app.toml` as a new version (secret shown once, also written to `.queek/.env.local`). In CI, authenticate with a per-app App Automation Token from the Developer page (`QUEEK_APP_AUTOMATION_TOKEN`).
22
- 4. Import from the entry table below: `@usequeek/app-sdk` for server code, `@usequeek/app-sdk/browser` (or `/react`) for browser code — never the main entry in a browser bundle.
23
- 5. Serve the install/uninstall/settings handoff (`handleInstallRequest`) and topic webhooks (`handleWebhookRequest`), then call the Merchant API via `createInstallationClient`.
24
- 6. Capabilities: the live list of scopes, webhook topics and other platform capabilities is served at `https://api.usequeek.com/docs/capabilities.json`; bridge capabilities are in the `queek-bridge` skill of [queek-ai-toolkit](https://github.com/usequeek/queek-ai-toolkit) (`references/bridge-spec.md`). Confirm scope and topic names with `queek app deploy`'s validator rather than a baked-in list.
15
+ ## Install
25
16
 
26
- ## Which entry do I import?
17
+ ```sh
18
+ npm i @usequeek/app-sdk
19
+ ```
27
20
 
28
- | Import | For | Pulls in |
29
- | --- | --- | --- |
30
- | `@usequeek/app-sdk` (main) | Server / universal code: handlers, client, stores, verifiers | `node:crypto`, `pg` — NOT browser-bundlable |
31
- | `@usequeek/app-sdk/server` | Session-token verifier (`verifySessionToken`) | Server-only (`jose`) — never import in a browser bundle |
32
- | `@usequeek/app-sdk/hono` | Thin Hono wrappers (`hono` optional peer) | Server-only |
33
- | `@usequeek/app-sdk/react` | React apps (`<QueekProvider>`, `useQueek()`; `react` optional peer) | Browser-safe (built on the same bridge modules as `/browser`) |
34
- | `@usequeek/app-sdk/browser` | Plain-browser code: `installAuthFetch` + bridge/theme helpers, no framework | Browser-safe ONLY |
21
+ `pg` ships as a regular dependency because Postgres is the production store.
35
22
 
36
- Browser rule of thumb: if the code ships to the browser and does not need React, import it from `@usequeek/app-sdk/browser`. `installAuthFetch` is a browser-safe MODULE, but it is not browser-safe FROM THE MAIN ENTRY — the main barrel re-exports server modules (`app-auth` → `node:crypto`, `store` → `pg`), so a bundler resolving `installAuthFetch` from `@usequeek/app-sdk` still parses those Node-only files and fails (Vite/Rollup builds break).
23
+ To start from a working project, run `npm create @usequeek/app my-app` (the same as `queek app init`), then `queek app dev` for a tunnel, a dev-store install, and credentials written to `.queek/.env.local`. `queek app deploy` pushes `queek.app.toml`. For the live list of scopes and webhook topics, read <https://api.usequeek.com/docs/capabilities.json>.
37
24
 
38
- ## Example (any framework)
25
+ ## Quick start
39
26
 
40
- A minimal app with plain Web-standard handlers — no framework import:
27
+ A complete server with plain Web-standard handlers. It has no framework import.
41
28
 
42
29
  ```ts
43
30
  import {
44
31
  createAppTokenProvider,
32
+ createInstallationClient,
45
33
  handleInstallRequest,
46
34
  handleWebhookRequest,
35
+ type InstallHandlerOptions,
47
36
  loadAppCredential,
48
37
  SqliteInstallationStore,
38
+ type WebhookHandlerOptions,
49
39
  } from "@usequeek/app-sdk";
50
40
 
51
41
  const store = new SqliteInstallationStore({
52
42
  path: "./data/installations.db",
53
- storeKey: process.env.APP_ENCRYPTION_KEY!,
43
+ storeKey: process.env.APP_ENCRYPTION_KEY ?? "",
54
44
  });
55
- const tokens = createAppTokenProvider({
56
- credential: loadAppCredential({ appSlug: "hello" }), // APP_SLUG/APP_KEY_ID/APP_PRIVATE_KEY (base64 of the PEM, one line)
45
+
46
+ // Reads APP_SLUG, APP_KEY_ID and APP_PRIVATE_KEY from the environment.
47
+ const tokens = createAppTokenProvider({ credential: loadAppCredential(), store });
48
+
49
+ const installOptions: InstallHandlerOptions = {
50
+ appSecret: process.env.QUEEK_APP_SECRET ?? "",
57
51
  store,
58
- });
59
- const installOptions = { appSecret: process.env.QUEEK_APP_SECRET!, store };
60
- const webhookOptions = {
52
+ };
53
+
54
+ const webhookOptions: WebhookHandlerOptions = {
61
55
  store,
62
56
  handlers: {
63
- "orders/updated": async (envelope, context) => {
64
- console.log("order update:", context.installation.storePid, envelope.data);
57
+ "orders/updated": async (envelope, { installation }) => {
58
+ const client = createInstallationClient({
59
+ installationId: installation.installationId,
60
+ apiBase: installation.apiBase,
61
+ tokens,
62
+ });
63
+ const order = await client.request("GET", "/orders/ord_123");
64
+ console.log("order changed", installation.storePid, envelope.data, order);
65
65
  },
66
66
  },
67
67
  };
68
+
69
+ // One route file per handoff path. The handler routes on the trailing
70
+ // `install`, `uninstall` or `settings` segment of the URL.
71
+ export const installRoute = (request: Request) => handleInstallRequest(request, installOptions);
72
+ export const webhookRoute = (request: Request) => handleWebhookRequest(request, webhookOptions);
68
73
  ```
69
74
 
70
- Next.js App Router — one route file per handoff path (`handleInstallRequest` routes on the request URL's trailing `install` / `uninstall` / `settings` segment, so one shared options object serves all three):
75
+ In Next.js App Router, export `installRoute` as `POST` from `app/api/install/route.ts` (and again from `uninstall/route.ts` and `settings/route.ts`), and `webhookRoute` as `POST` from `app/api/webhooks/route.ts`.
71
76
 
72
- ```ts
73
- // app/api/install/route.ts (and uninstall/route.ts, settings/route.ts likewise)
74
- import { installOptions } from "./options";
77
+ ## Which entry do I import?
75
78
 
76
- export async function POST(request: Request) {
77
- return handleInstallRequest(request, installOptions);
78
- }
79
+ | Import | For | Notes |
80
+ | --- | --- | --- |
81
+ | `@usequeek/app-sdk` | Server code: handlers, clients, stores, verifiers | Uses `node:crypto` and `pg`. Not browser-bundlable. |
82
+ | `@usequeek/app-sdk/server` | `verifySessionToken` | Server only (uses `jose`). Needs the embed secret, which must never reach a browser. |
83
+ | `@usequeek/app-sdk/hono` | Hono wrappers | Server only. Needs the `hono` peer. |
84
+ | `@usequeek/app-sdk/react` | `<QueekProvider>` and `useQueek()` | Browser-safe. Needs the `react` peer. |
85
+ | `@usequeek/app-sdk/browser` | `installAuthFetch`, the bridge and theme helpers | Browser-safe. No framework. |
79
86
 
80
- // app/api/webhooks/route.ts
81
- import { webhookOptions } from "./options";
87
+ Import browser code that does not need React from `@usequeek/app-sdk/browser`. The main entry re-exports server modules, so a bundler that resolves `installAuthFetch` from it fails on `node:crypto` and `pg`. A test in this repository walks the imports of `/browser` and fails if a Node-only module ever appears in them.
82
88
 
83
- export async function POST(request: Request) {
84
- return handleWebhookRequest(request, webhookOptions);
85
- }
86
- ```
89
+ ## Receive deliveries
90
+
91
+ Queek sends two kinds of signed `POST`:
92
+
93
+ - **The install handoff** (`install`, `uninstall`, `settings`, plus the `app/resync` and `app/scopes_update` events on the same routes). It is signed with your app secret (`whsec_…`).
94
+ - **Topic webhooks** (for example `orders/updated`). Each installation has its own endpoint secret, delivered once inside the install payload.
95
+
96
+ Both use the Standard Webhooks headers `webhook-id`, `webhook-timestamp` and `webhook-signature`, over `{id}.{timestamp}.{raw body}`, with a 5-minute timestamp window. Every handler checks the signature, rejects stale timestamps, claims the `webhook-id` so each delivery runs at most once, and then calls your code.
97
+
98
+ The handlers come in three layers that behave identically:
99
+
100
+ 1. `handleInstallDelivery(input, options)` and `handleWebhookDelivery(input, options)` take raw body bytes plus headers and return `{ status, body }`. They use no request or response types, so any framework works.
101
+ 2. `handleInstallRequest(request, options)` and `handleWebhookRequest(request, options)` adapt a Web `Request` to a `Response`.
102
+ 3. `createInstallHandlers`, `createWebhookHandler` and `createProxyHandler` from `@usequeek/app-sdk/hono`.
103
+
104
+ ### Status codes
105
+
106
+ | Route | Status | Meaning |
107
+ | --- | --- | --- |
108
+ | Install handoff | 200 | Handled. |
109
+ | | 400 | The body is not the expected envelope or payload. |
110
+ | | 401 | Missing signature header, stale timestamp, or bad signature. |
111
+ | | 404 | Unknown route or unknown installation. |
112
+ | | 405 | Not a `POST`. |
113
+ | | 409 | This `webhook-id` was already claimed. |
114
+ | | 500 | Your callback or the store write threw. The claim is released, so Queek's retry can land. |
115
+ | Topic webhook | 200 | Handled, a repeat (`deduped: true`), or a topic with no handler (`unhandled: true`). |
116
+ | | 400 | The body is not a JSON envelope with a `topic`. |
117
+ | | 401 | Missing header, unknown installation, stale timestamp, or bad signature. |
118
+ | | 500 | Your handler threw. The claim is released, so Queek's retry can land. |
119
+
120
+ Answer 2xx only after your data is stored. Anything else makes Queek retry.
121
+
122
+ ### When your code throws
123
+
124
+ A throwing callback answers 500 and releases the claim. The SDK also reports the error: pass `onError(error, { stage, eventId, topic })` in the handler options to send it to your own monitoring. Without `onError`, the SDK writes the error name and message to stderr with secrets redacted. A throwing `onError` never changes the response.
125
+
126
+ ### Why the raw body matters
127
+
128
+ The signature covers the exact bytes Queek sent, not the parsed JSON. A body that was parsed and serialised again has different bytes and will not verify. Pass `Buffer` or `Uint8Array` straight from the framework. A string `rawBody` is used as given, so pass one only when it is already the exact UTF-8 text.
87
129
 
88
- ### Express (no new package)
130
+ ### Express
89
131
 
90
- Express hands you whatever request object it supports — pass its raw body bytes and headers straight to the layer-1 core (no `Request` construction needed). Keep the untouched bytes Queek signed with the built-in `express.raw` parser:
132
+ Use `express.raw`, never `express.json()`, on these routes:
91
133
 
92
134
  ```ts
93
135
  import express from "express";
@@ -95,8 +137,13 @@ import {
95
137
  type DeliveryResult,
96
138
  handleInstallDelivery,
97
139
  handleWebhookDelivery,
140
+ type InstallHandlerOptions,
141
+ type WebhookHandlerOptions,
98
142
  } from "@usequeek/app-sdk";
99
143
 
144
+ declare const installOptions: InstallHandlerOptions;
145
+ declare const webhookOptions: WebhookHandlerOptions;
146
+
100
147
  const app = express();
101
148
  app.use("/api", express.raw({ type: "application/json" }));
102
149
 
@@ -115,21 +162,21 @@ for (const route of ["install", "uninstall", "settings"]) {
115
162
  );
116
163
  });
117
164
  }
165
+
118
166
  app.post("/api/webhooks", async (req, res) => {
119
- reply(
120
- res,
121
- await handleWebhookDelivery({ rawBody: req.body as Buffer, headers: req.headers }, webhookOptions),
122
- );
167
+ reply(res, await handleWebhookDelivery({ rawBody: req.body as Buffer, headers: req.headers }, webhookOptions));
123
168
  });
124
169
  ```
125
170
 
126
- ### Fastify (no new package)
171
+ ### Fastify
127
172
 
128
- Same idea — keep the body raw with `addContentTypeParser`, then call the core directly:
173
+ Keep the body as a buffer with `addContentTypeParser`:
129
174
 
130
175
  ```ts
131
176
  import Fastify from "fastify";
132
- import { handleWebhookDelivery } from "@usequeek/app-sdk";
177
+ import { handleWebhookDelivery, type WebhookHandlerOptions } from "@usequeek/app-sdk";
178
+
179
+ declare const webhookOptions: WebhookHandlerOptions;
133
180
 
134
181
  const fastify = Fastify();
135
182
  fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
@@ -145,18 +192,8 @@ fastify.post("/api/webhooks", async (req, reply) => {
145
192
  });
146
193
  ```
147
194
 
148
- ### Why the raw body matters
149
-
150
- Signature verification covers the exact bytes Queek sent (`{id}.{timestamp}.{body}`, as defined by the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) spec), not the parsed JSON value — so the SDK takes the untouched body bytes at every layer. A parsed-then-restringified body has different bytes (spacing, key order) and will NOT verify. A string `rawBody` is used verbatim, so only pass a string when it is already the exact UTF-8 text — otherwise prefer `Buffer`/`Uint8Array` straight from `express.raw`, Fastify's `parseAs: "buffer"`, or `await request.arrayBuffer()`. In practice: Express needs `express.raw(...)` (never `express.json()`) on webhook routes, Fastify needs `addContentTypeParser` with `parseAs: "buffer"`, and in Next.js you read `await request.arrayBuffer()` (the SDK's layer 2 already does) rather than `await request.json()`.
151
-
152
- ### Body-size posture
153
-
154
- The SDK itself imposes no maximum body size (`arrayBuffer` / `TextDecoder` / `JSON.parse` are unbounded) — the cap belongs to the framework parser in front of it, which already rejects oversized bodies before the SDK ever sees them. Queek deliveries are small JSON payloads, so the framework defaults are plenty; if you set them explicitly, `express.raw({ type: "application/json", limit: "1mb" })` and Fastify's default `bodyLimit` of 1 MiB are the recommended ceilings. The SDK has no body-size option.
155
-
156
195
  ### Hono
157
196
 
158
- Prefer Hono? The thin wrappers under `@usequeek/app-sdk/hono` (same options, same behaviour, same errors/status codes) mount the same core. (If you already run Hono on Node, `@hono/node-server`'s `getRequestListener` bridges serving for you.)
159
-
160
197
  ```ts
161
198
  import { SqliteInstallationStore } from "@usequeek/app-sdk";
162
199
  import { createInstallHandlers, createWebhookHandler } from "@usequeek/app-sdk/hono";
@@ -164,19 +201,18 @@ import { Hono } from "hono";
164
201
 
165
202
  const store = new SqliteInstallationStore({
166
203
  path: "./data/installations.db",
167
- storeKey: process.env.APP_ENCRYPTION_KEY!,
204
+ storeKey: process.env.APP_ENCRYPTION_KEY ?? "",
168
205
  });
169
206
 
170
207
  const app = new Hono();
171
- app.get("/health", (c) => c.json({ ok: true }));
172
- app.route("/", createInstallHandlers({ appSecret: process.env.QUEEK_APP_SECRET!, store }));
208
+ app.route("/", createInstallHandlers({ appSecret: process.env.QUEEK_APP_SECRET ?? "", store }));
173
209
  app.route(
174
210
  "/webhooks",
175
211
  createWebhookHandler({
176
212
  store,
177
213
  handlers: {
178
- "orders/updated": async (envelope, context) => {
179
- console.log("order update:", context.installation.storePid, envelope.data);
214
+ "orders/updated": async (envelope, { installation }) => {
215
+ console.log("order changed", installation.storePid, envelope.data);
180
216
  },
181
217
  },
182
218
  }),
@@ -185,264 +221,217 @@ app.route(
185
221
  export default app;
186
222
  ```
187
223
 
188
- Every Merchant API call goes through `createInstallationClient({ installationId, apiBase, tokens })`,
189
- which resolves the installation's token via `acquireToken()` and sends it as `X-Client-Key`.
224
+ ### Body size
225
+
226
+ The SDK sets no body-size limit. Set it in the framework parser in front of it. Queek deliveries are small, so the framework defaults are enough. If you set a limit, 1 MiB is plenty.
227
+
228
+ ## Call the Merchant API
229
+
230
+ `createInstallationClient({ installationId, apiBase, tokens })` is the client apps use. On each call it gets the installation token from `acquireToken()` (usually a cache hit) and sends it as `X-Client-Key`. If Queek refuses the token, the client drops it, mints a new one, and retries once. A second refusal reaches you.
231
+
232
+ ```ts
233
+ import { createInstallationClient } from "@usequeek/app-sdk";
234
+
235
+ // installationId and apiBase come from the install record; tokens is the provider from the quick start.
236
+ const client = createInstallationClient({ installationId, apiBase, tokens });
237
+
238
+ const storeProfile = await client.getStore();
239
+ const accepted = await client.request("POST", "/orders/ord_123/status", {
240
+ body: { status: "accept" }, // needs the merchant-orders-status-update scope
241
+ });
242
+ ```
243
+
244
+ `createQueekClient({ apiBase, apiKey })` is the same client for one fixed key.
245
+
246
+ `apiBase` is validated when the client is created. It must be `https`, carry no credentials, and name a host on the allowlist (`api.usequeek.com` by default), so a tampered handoff can never send your key to another host. To allow a test host, pass `allowedApiHosts` or set `QUEEK_API_HOSTS`. `QUEEK_DEV_API_HOSTS` is for local tunnels and is refused when `NODE_ENV=production`.
247
+
248
+ ### Timeouts, aborts and retries
190
249
 
191
- ## Credential lifecycle (GitHub-style installation tokens)
250
+ - Every attempt times out after 30 seconds. Change it with `timeoutMs` on the client or on one call; `0` turns the client timeout off.
251
+ - Pass `signal` to cancel a call. A cancelled call fails with `request_aborted` and is never retried.
252
+ - `request()` never retries. `requestWithRetry()` retries 429, 502, 503, 504, timeouts and network errors, up to 3 attempts, with exponential backoff and jitter. It honours `Retry-After` up to `maxRetryAfterMs` (30 seconds) and throws at once when Queek asks for longer.
253
+ - Writes (`POST`, `PUT`, `PATCH`, `DELETE`) carry an `Idempotency-Key`: yours, or a random UUID. All attempts of one write send the same key, so Queek replays the first result instead of running the write twice.
254
+ - Pass `requestId` to correlate your trace with Queek's logs. Without it, the client generates one.
255
+
256
+ Encode path segments yourself (`encodeURIComponent`). Query values are encoded for you.
257
+
258
+ ### Pagination
259
+
260
+ List endpoints return `{ data, has_more, next_cursor }` and take `limit` and `starting_after`. `paginate` walks every page:
261
+
262
+ ```ts
263
+ import { paginate } from "@usequeek/app-sdk";
264
+
265
+ // client is the installation client created above.
266
+ for await (const order of paginate<{ p_id: string }>(client, "/orders", { limit: 50 })) {
267
+ console.log(order.p_id);
268
+ }
269
+ ```
192
270
 
193
- One asymmetric credential per app — no per-installation secrets cross the handoff.
271
+ ### Errors
194
272
 
195
- 1. **Keypair — two paths.** (a) Offline: generate locally, then upload the PUBLIC key on
196
- the Developer page (Keys) — Queek never receives the private key:
273
+ Every failed call throws `QueekApiError`. Switch on `code`, never on `message`.
274
+
275
+ | Property | Meaning |
276
+ | --- | --- |
277
+ | `status` | HTTP status, or `0` when no answer arrived. |
278
+ | `code` | Stable machine code. |
279
+ | `message` | What happened and what to do. For people, not for `switch`. |
280
+ | `requestId` | Matches the `X-Request-Id` response header. Quote it to Queek support. |
281
+ | `field`, `errors` | Validation detail for `validation_failed`. |
282
+ | `docUrl` | The entry for this code in the error reference. |
283
+ | `retryAfterMs` | On a 429, how long Queek asked you to wait. |
284
+ | `cause` | The original error, for failures that never got an answer. |
285
+ | `isRetryable` | True for the failures `requestWithRetry()` retries. |
286
+
287
+ Codes from Queek are listed in the [Merchant API reference](https://api.usequeek.com/docs/merchant.json) (`validation_failed`, `insufficient_scope`, `too_many_requests`, and more). The SDK adds these:
288
+
289
+ | Code | Meaning |
290
+ | --- | --- |
291
+ | `network_error` | No connection: DNS, refused or reset connection, TLS. Retryable. |
292
+ | `request_timeout` | No answer within `timeoutMs`. Retryable. |
293
+ | `request_aborted` | Your `AbortSignal` fired. Not retried. |
294
+ | `invalid_response` | An HTTP success whose body was not JSON, or a page that promised more rows and gave no cursor. |
295
+ | `invalid_api_base` | Thrown as `InvalidApiBaseError` when the client is created. |
296
+
297
+ ```ts
298
+ import { QueekApiError } from "@usequeek/app-sdk";
299
+
300
+ try {
301
+ await client.request("POST", "/orders/ord_123/status", { body });
302
+ } catch (error) {
303
+ if (error instanceof QueekApiError && error.code === "validation_failed") {
304
+ console.error(error.field, error.errors, error.requestId);
305
+ } else {
306
+ throw error;
307
+ }
308
+ }
309
+ ```
310
+
311
+ ### Types
312
+
313
+ The default types come from the Merchant API spec bundled in this package. They do not update by themselves when the API gains fields. For current types, run `queek app codegen` in your app and pass the result: `createInstallationClient<AppPaths>(…)`. The API only adds fields within `v1`, so an older SDK keeps working with new fields untyped.
314
+
315
+ ## Verify a signature yourself
316
+
317
+ ```ts
318
+ import { type InstallationRecord, verifyQueekSignatureDetailed } from "@usequeek/app-sdk";
319
+
320
+ export async function verifyDelivery(request: Request, installation: InstallationRecord): Promise<Response | null> {
321
+ const checked = verifyQueekSignatureDetailed({
322
+ id: request.headers.get("webhook-id") ?? "",
323
+ timestamp: request.headers.get("webhook-timestamp") ?? "",
324
+ signatureHeader: request.headers.get("webhook-signature") ?? "",
325
+ body: await request.text(), // the raw text, before any JSON.parse
326
+ secret: installation.webhookSecret ?? "",
327
+ });
328
+ return checked.ok ? null : new Response(checked.reason, { status: 401 });
329
+ }
330
+ ```
331
+
332
+ The MAC key is the base64-decoded bytes of the `whsec_…` secret. The compare is constant-time and checks every signature in the header, so a secret rotation that sends two signatures verifies. The timestamp must be a plain integer within 5 minutes of now. A valid signature on a stale delivery is still rejected, which bounds replay. The `webhook-id` claim in the handlers closes the rest of the window.
333
+
334
+ ## Embedded merchant page
335
+
336
+ The dashboard frames your merchant page and passes a session token. Verify it on your server before you trust the call:
337
+
338
+ ```ts
339
+ import { verifySessionTokenDetailed } from "@usequeek/app-sdk/server";
340
+
341
+ const checked = await verifySessionTokenDetailed(token, {
342
+ secret: installation.embedSecret ?? "",
343
+ audience: "my-app", // your app slug
344
+ issuer: installation.apiBase,
345
+ expected: {
346
+ installationId: installation.installationId,
347
+ vendorId: installation.vendorId,
348
+ appSlug: "my-app",
349
+ appId: installation.appId ?? "",
350
+ },
351
+ });
352
+ if (!checked.ok) throw new Error(`session token rejected: ${checked.reason}`);
353
+ ```
354
+
355
+ Tokens are HS256 with a 20-second clock tolerance. The verifier pins the algorithm, then checks signature, audience, issuer, expiry and the installation binding. `sessionTokenInstallationId(token)` reads the installation id without verifying anything, only so you can load the right secret. Never trust it on its own.
356
+
357
+ In the browser, `installAuthFetch({ exchange, dashboardOrigin })` from `@usequeek/app-sdk/browser` reads the first-load token from `?queek_token=`, removes it from the URL, exchanges it once through your `exchange` endpoint, attaches `Authorization: Bearer <session>` to same-origin requests, and refreshes through the bridge after a 401. Cookies are not used. In React, wrap the app in `<QueekProvider>` and call `useQueek()` for `toast`, `saveBar`, `title`, `navigate`, `pickResource` and `theme`.
358
+
359
+ Serve the page with a `frame-ancestors` policy that names only your dashboard origin:
360
+
361
+ ```http
362
+ Content-Security-Policy: frame-ancestors https://dashboard.usequeek.com
363
+ ```
364
+
365
+ Replace the host with the dashboard origin you registered. Only your server can set this header, so the dashboard cannot enforce it for you.
366
+
367
+ ## Credentials and tokens
368
+
369
+ An app holds one asymmetric key. Queek hands no store-callable credential over in the install handoff; the app mints short-lived installation tokens on demand.
370
+
371
+ 1. **Create a key pair.** Either generate it yourself and upload the public key on the Developer page, so Queek never sees the private key:
197
372
 
198
373
  ```sh
199
374
  openssl genrsa -out app-private.pem 2048
200
375
  openssl rsa -in app-private.pem -pubout -out app-public.pem
201
376
  ```
202
377
 
203
- (b) Faster: `queek app dev` server-generates the pair and stores the kid + private key
204
- (base64, one line) in `.queek/.env.local` (gitignored, 0600) — the private key is
205
- returned once and never shown again; if lost, generate a new key. Either way, keep the
206
- private key in your secrets manager beside `APP_ENCRYPTION_KEY` (key + DB loss = working
207
- data lost — see below).
208
-
209
- 2. **Register the app** on the Queek Developer page, then configure five values — each from
210
- exactly one place: `APP_SLUG` is the slug in `queek.app.toml` (= JWT `iss`); `APP_KEY_ID`
211
- is the kid from Keys (returned by generate/upload, or written by `queek app dev`);
212
- `APP_PRIVATE_KEY` is the matching private key (your offline file, or the once-shown
213
- server-generated one; raw PEM also accepted — never logged, never shipped to clients);
214
- `QUEEK_APP_SECRET` verifies the handoff (shown once on first registration — `queek app
215
- deploy` appends it to `.queek/.env.local`; `queek app dev` fetches it there; or Reveal
216
- it on the dashboard) and is never rotated by dev; `APP_ENCRYPTION_KEY` is 32 local
217
- random bytes, base64 or hex (`queek app dev` mints one into `.queek/.env.local`).
218
-
219
- 3. **Acquire / cache / re-mint.** `acquireToken(installationId)` serves the cached token while
220
- its expiry is more than 5 minutes away; otherwise it signs an RS256 app JWT
221
- (`iss` = slug, `iat` = now − 60 s, `exp` = `iat` + 540 s, header `kid` — `node:crypto`
222
- only) and `POST`s `access_tokens`, then persists the token encrypted with its expiry and
223
- `kid` (one shared cache row per installation — restarts never burst). Concurrent callers
224
- in one process share one in-flight mint; two containers minting at once is harmless by
225
- design (Queek keeps coexisting tokens valid; a residual race self-heals via re-mint).
226
- Token refusals: any 401, or 403 `api_key_revoked` / `api_key_expired` /
227
- `invalid_client_key` → drop the token, re-mint once, retry once (a second refusal
228
- propagates); 403 `app_token_revoked` → drop ALL cached tokens and halt minting (kill
229
- switch / disabled app, no mint); every other 403 (scope, plan, mode) propagates to the
230
- caller without a mint.
231
-
232
- 4. **Failures are handled by status and code** (`app-auth.ts` holds each code in one
233
- constant):
234
- - `401 invalid_client` — fatal for the app: loud log, minting stops, no retry loop.
235
- - `403 app_token_revoked` (kill switch / disabled app) — drops ALL cached tokens, stops
236
- minting, loud log. Minting resumes after the app is re-enabled + process restart
237
- (or `resumeMinting()`).
238
- - `404 app_installation_gone` — purges that installation locally.
239
- - `409 app_installation_pending` — the installation is not active yet: retry later
240
- with backoff, NEVER purge, NEVER halt. The row carries a persisted pending mark
241
- (store column, schema v2 — survives restarts, cleared when the installation is
242
- seen active or its mint succeeds).
243
- - `429 resync_cooldown` (per-installation resync only) — the ≤1/hour rotation
244
- cooldown: skipped + recorded, never retried. Any OTHER 429 is the per-app bucket
245
- (`too_many_requests`): `Retry-After` honored with jitter, bounded retries.
246
- - `5xx` — bounded exponential backoff.
247
-
248
- 5. **Resync after DB loss or a long outage.** `resyncFromQueek({ apiBase, tokens, store })`
249
- lists ACTIVE installations only (opaque keyset cursor — followed until `next_cursor`
250
- is null, never interpreted) → requests a resync per installation (409 pending →
251
- backoff + bounded retry, then skipped + recorded, never purged; 429 `resync_cooldown`
252
- → skipped + recorded, never retried; any other 429 → `Retry-After` + jitter, bounded
253
- retry; 404 `app_installation_gone` purges just that row and the run continues) → drops
254
- cached tokens → purges local rows absent from Queek's list, EXCEPT rows carrying the
255
- persisted pending mark — absence never purges a pending row, even across a restart.
256
- The fresh `webhook_secret` + non-secret settings arrive over the
257
- existing signed install channel — the install handler merges a redelivery for an
258
- existing installation idempotently (secret + settings refresh, `installedAt` and the
259
- cached token kept).
260
-
261
- 6. **Kill switch behaviour.** One operation on Queek's side (the kill switch) revokes every
262
- token of an app across every store on the next request. The SDK side is the
263
- `app_token_revoked` row of item 4: every call fails closed until the app is re-enabled
264
- and the process restarts (or resumes). Key rotation: add a `kid` (both verify) →
265
- switch the app to it → wait one token TTL → remove the old `kid`.
266
-
267
- ## Data rule + backups
268
-
269
- The app database holds INSTALLATIONS ONLY (refs, token cache, webhook secrets, settings).
270
- Business data lives in Queek. `resyncFromQueek` restores CONNECTIVITY with zero merchant
271
- action — but app WORKING data (inbound tokens, order links, form tokens, app-side-only
272
- settings) does NOT come back from resync: it needs the per-app `pg_dump` backup
273
- (RPO ≤ 24 h) plus the `APP_ENCRYPTION_KEY` backup. Events missed beyond Queek's webhook
274
- retries (~4 h) are gone; resync cannot backfill them. Deploy reference: `queek app deploy --help`.
378
+ Or run `queek app dev`, which generates a pair and stores the key id and private key (base64, one line) in `.queek/.env.local`. The server shows the private key once. If you lose it, generate a new key.
379
+
380
+ 2. **Set five values.**
381
+ - `APP_SLUG`: the slug in `queek.app.toml`. It is the JWT `iss`.
382
+ - `APP_KEY_ID`: the key id from the Developer page.
383
+ - `APP_PRIVATE_KEY`: the matching private key. Base64 of the PEM, raw PEM, and `\n`-escaped one-line PEM are accepted. It is never logged.
384
+ - `QUEEK_APP_SECRET`: verifies the install handoff. It is shown once at registration.
385
+ - `APP_ENCRYPTION_KEY`: 32 random bytes as base64 or hex. Prefer random bytes over a typed passphrase.
386
+
387
+ 3. **Acquire, cache, re-mint.** `acquireToken(installationId)` returns the cached token while more than 5 minutes remain. Otherwise it signs an RS256 app JWT (`iss` = slug, `iat` = now minus 60 s, `exp` = `iat` + 540 s, header `kid`), posts it to mint a token, and stores the token encrypted. Concurrent callers in one process share one mint.
388
+
389
+ 4. **Failures are handled by status and code.**
390
+ - `401 invalid_client`: fatal for the app. Minting stops and the SDK logs loudly. Fix `APP_PRIVATE_KEY` or `APP_KEY_ID`, then restart or call `resumeMinting()`.
391
+ - `403 app_token_revoked`: Queek disabled the app. The SDK drops every cached token and stops minting until the app is re-enabled and the process restarts.
392
+ - `404 app_installation_gone`: the installation was removed. The SDK purges that row.
393
+ - `409 app_installation_pending`: the installation is not active yet. The SDK retries later with backoff and never purges it.
394
+ - `429`: `Retry-After` is honoured with jitter. A `resync_cooldown` (one resync per installation per hour) is skipped, not retried.
395
+ - `5xx`, timeouts and network errors: bounded exponential backoff.
396
+
397
+ 5. **Rotate a key.** Add a new key id (both verify), switch the app to it, wait one token lifetime, then remove the old key id.
398
+
399
+ ## Resync and backups
400
+
401
+ The app database holds installations only: references, the token cache, webhook secrets and settings. Business data lives in Queek.
402
+
403
+ After a database loss or a long outage, `resyncFromQueek({ apiBase, tokens, store })` lists the app's active installations and asks Queek to redeliver each one's secrets and settings over the signed install channel. It purges local rows Queek no longer lists, except rows marked pending. Resync restores connectivity. It does not restore your own working data or webhook events that Queek stopped retrying. Back up the app database (`pg_dump`) and `APP_ENCRYPTION_KEY`. Without the key, stored secrets cannot be recovered.
275
404
 
276
405
  ## Data deletion
277
406
 
278
- Treat the uninstall handoff (`app/uninstalled`) as the deletion trigger: the default
279
- `onUninstall` already deletes the installation row (secrets, token cache, settings) —
280
- keep that delete when you override it, and purge any app-side working data keyed by the
281
- installation there too.
407
+ Treat the `app/uninstalled` handoff as your deletion trigger. The default `onUninstall` deletes the installation row, including secrets, token cache and settings. Keep that delete if you override it, and remove any app data keyed by the installation.
282
408
 
283
- Unknown topics answer 200 without running a handler (forward compatibility, not consent):
284
- that 200 must not swallow a future privacy topic. If Queek ships mandatory privacy
285
- topics, register explicit handlers for them — an unhandled topic only reports
286
- `unhandled: true`, it deletes nothing.
409
+ A topic with no handler answers 200 and reports `unhandled: true`. It deletes nothing. If Queek adds mandatory privacy topics, register explicit handlers for them.
287
410
 
288
- ## Storefront app-proxy (signed reads)
411
+ ## Storefront proxy
289
412
 
290
- Queek signs each storefront proxy fetch with the installation's `proxy_secret`;
291
- verify before answering the shopper:
413
+ Queek signs each storefront proxy read with the installation's `proxy_secret`. Verify before you answer:
292
414
 
293
415
  ```ts
294
- import { handleProxyRequest } from "@usequeek/app-sdk";
416
+ import { handleProxyRequest, type InstallationStore } from "@usequeek/app-sdk";
417
+
418
+ declare const store: InstallationStore;
419
+ declare function slotsFor(date: string | undefined, storePid: string | null): unknown;
295
420
 
296
421
  export async function GET(request: Request) {
297
422
  return handleProxyRequest(
298
423
  request,
299
- { store, path: "/apps/booking/availability" }, // Queek-side canonical path, not the local route
300
- ({ installation, params }) =>
301
- Response.json({ slots: slotsFor(params.date, installation.storePid) }),
424
+ { store, path: "/apps/booking/availability" }, // the path Queek signed, not your local route
425
+ ({ installation, params }) => Response.json({ slots: slotsFor(params.date, installation.storePid) }),
302
426
  );
303
427
  }
304
428
  ```
305
429
 
306
- The canonical string is `path + "\n" + shop + "\n" + ts + "\n" + sorted(k=v&...)`
307
- (`sig` excluded), hex HMAC-SHA256 over the FULL `whsec_…` string — no base64 decode
308
- step. `kid` routes to the installation whose `proxy_secret` verifies (previous-secret
309
- grace: pass every active secret to `verifyProxyQuery`); timestamps skew at most 5
310
- minutes, each `jti` is single-use, and only `GET` is served (proxy reads are read-only).
311
- Hono: mount `createProxyHandler({ store, path, onVerified })` from
312
- `@usequeek/app-sdk/hono`.
313
-
314
- ## API surface
315
-
316
- - **app-auth** (`app-auth.ts`): `loadAppCredential` (`APP_SLUG`/`APP_KEY_ID`/`APP_PRIVATE_KEY`
317
- — base64 of the PEM, raw PEM, or `\n`-escaped one-line PEM; RSA validated at boot),
318
- `signAppJwt` (RS256, `iat` now − 60 s, `exp` window 540 s ≤ 600 s,
319
- `kid` header), the error codes in one place (`INVALID_CLIENT_CODE`,
320
- `APP_TOKEN_REVOKED_CODE`, `APP_INSTALLATION_GONE_CODE`, `APP_INSTALLATION_PENDING_CODE`,
321
- `RESYNC_COOLDOWN_CODE`, `TOO_MANY_REQUESTS_CODE`), `AppMintHaltedError`.
322
- - **tokens** (`tokens.ts`): `createAppTokenProvider({ credential, store, … })` —
323
- `acquireToken` (cache → sign → mint → persist), single-flight per installation, with the
324
- error handling in Credential lifecycle items 3–4;
325
- `createInstallationClient({ installationId, apiBase, tokens })` — the `QueekClient`
326
- every app call uses (re-mint once + retry once on token refusals only).
327
- - **resync** (`resync.ts`): `resyncFromQueek({ apiBase, tokens, store })` — the flow in
328
- Credential lifecycle item 5. Connectivity scope only.
329
- - **verify** (`signatures.ts`): `verifyQueekSignature` — Standard Webhooks verification (`webhook-id`, `webhook-timestamp`, `webhook-signature` over `{id}.{timestamp}.{body}`, keyed by the decoded `whsec_…` bytes), with timestamp-skew enforcement.
330
- - **delivery core** (`delivery.ts`): `CoreDelivery` (`rawBody` + `headers`) / `InstallDelivery` (+ `method`/`path`) / `DeliveryResult` (`{ status, body }`) / `CoreHeaders`, plus `readHeader` (case-insensitive, array-tolerant), `decodeBody`, and `toResponse`. Zero request/response types.
331
- - **install handlers** (`install-handlers.ts`): layer 1 `handleInstallDelivery(input, { appSecret, store, onInstall?, onUninstall?, onSettings? })` serves the signed install/uninstall/settings handoff from raw bytes + headers (routes on the path's trailing segment); layer 2 `handleInstallRequest(request, …)` adapts `Request` → `Response` onto it. Defaults persist the installation (encrypted) in the store; a redelivered install for an existing installation merges idempotently (`saveResyncedInstallation`). The Hono wrapper `createInstallHandlers` lives under `@usequeek/app-sdk/hono` (`hono.ts`).
332
- - **client** (`client.ts`): `createQueekClient({ apiBase, apiKey })` — the low-level typed fetch client over the Merchant API (`X-Client-Key`), with `Idempotency-Key` on writes, typed `QueekApiError`s, and 429 retry helpers. Both it and `createInstallationClient({ installationId, apiBase, tokens })` are generic (`<AppPaths>`, default: the bundled `merchant-schema.ts` compat shim): pass the app's own codegen output for current types without waiting for an SDK release. The default does NOT auto-update (re-run codegen in the app; sunset signal at 1.0). `openapi/merchant.json` stays in-repo as `gen:merchant`'s reference input and is not shipped in the published package. Prefer `createInstallationClient` in apps.
333
- - **webhooks** (`webhooks.ts`): layer 1 `handleWebhookDelivery(input, { store, handlers })` verifies each delivery against the installation's endpoint secret, dedupes on `webhook-id`, and dispatches `topic → handler` at most once; layer 2 `handleWebhookRequest(request, …)` adapts `Request` → `Response` onto it. The Hono wrapper `createWebhookHandler` lives under `@usequeek/app-sdk/hono` (`hono.ts`). Unknown topics answer 200 `unhandled` — see Data deletion before relying on that.
334
- - **proxy** (`proxy.ts`): `verifyProxyQuery` / `verifyProxyQueryDetailed` — app-proxy query verification byte-exact with Queek's signer (`path\nshop\nts\nsorted(k=v&...)`, hex HMAC-SHA256 over the FULL `whsec_…` string, 5-minute skew floored at 60 s, `timingSafeEqual`, previous-secret grace over the secrets list); layer 1 `verifyProxyDelivery(input, { store, … })` resolves the installation from the store by `kid` and claims single-use `jti`; layer 2 `handleProxyRequest(request, { store, path, … }, onVerified)` serves GET only. The Hono wrapper `createProxyHandler` lives under `@usequeek/app-sdk/hono` (`hono.ts`).
335
- - **store** (`store.ts`): `SqliteInstallationStore` (local/dev/test) and `PostgresInstallationStore`
336
- (`pg`, pool max 2, advisory-locked schema + `schema_version` row so two containers boot
337
- safely) — installations encrypted at rest (AES-GCM via `APP_ENCRYPTION_KEY`), plus the
338
- persisted 409-pending mark (`pending` column, schema v2, migrated in place) and the
339
- seen-webhook-id claim table behind dedupe. Pick with `createInstallationStore()`
340
- (`DATABASE_URL` set → Postgres, else SQLite — which production REFUSES with a clear
341
- message). `APP_ENCRYPTION_KEY` is set once when the app is deployed; installs never change env:
342
- each install adds a row to the app's database, with that store's token + webhook secret
343
- encrypted using this key.
344
- - **background** (`background.ts`): `detach` plus `runInstallationCatchup` — jittered
345
- per-installation cron (uniform 0–600 s start jitter, per-install error isolation,
346
- concurrency ≤ pool size, honors 429 once per installation).
347
- - **logger** (`logger.ts`): redacts `sk_`/`pk_`/`whsec_`/`Bearer` values, bare RS256 JWTs, and
348
- PEM private-key blocks — the app JWT and private key can never reach logs.
349
- - **session** (`session.ts`, server-only via `@usequeek/app-sdk/server`):
350
- `verifySessionToken` — HS256 dashboard session tokens minted per installation
351
- (`embsec_…` secret, raw UTF-8 key bytes, 20 s clock tolerance, slug audience,
352
- issuer = the handoff `apiBase` verbatim (`installation.apiBase` — it equals the
353
- bare `app.url` Queek signs as `iss`), full installation binding). There is one token
354
- type: the dashboard puts the same token in the first-load URL param
355
- (`queek_token`, stripped on arrival) and answers bridge `ready` requests with it,
356
- so first-load and refresh tokens are verified by this one verifier — no purpose
357
- split, extra claims are ignored. The secret never enters a
358
- browser bundle: the main entry does not export the verifier. The install
359
- and resync handoffs deliver `embed_secret` + `app_id`; the store keeps
360
- them on the installation (`embedSecret` encrypted, `appId`), and
361
- `sessionTokenInstallationId` reads the token's `installation_id` as an
362
- unverified routing hint so a server can load that row before verifying.
363
- - **frame** (`frame.ts`, browser-safe, no secret): the embedded-app bridge
364
- v1 — typed unions both directions (`ready{capabilities,sdkVersion}`,
365
- `theme`, `title`/`title-action`, `toast`, `save-bar`/`save-bar-action`,
366
- `navigate`/`navigated`, `open`, `pick-resource`/`resource-picked`/
367
- `resource-pick-cancelled`), unknown types ignored, every outbound string
368
- length-capped client-side. `listenToDashboard` (accepts only the exact
369
- dashboard origin, plus an `expectSource` window check) and typed senders
370
- (`sendReady`, `sendToast`, `sendSaveBar`, `sendTitle`, `sendNavigated`,
371
- `sendOpen`, `sendPickResource`, …) to the exact target origin, never `"*"`.
372
- `parseOutboundMessage` runs the same per-type validation the dashboard
373
- runs — the dashboard re-validates everything before acting (open targets,
374
- title/toast/pick content), so a compromised frame cannot smuggle
375
- `javascript:` URLs or uncapped strings through the bridge. `open` targets
376
- are a dashboard-relative path or an absolute `https:` URL; backslashes,
377
- control characters, protocol-relative URLs and every other scheme are
378
- refused (by `clipOutbound` as well as `isAllowedOpenTarget`).
379
-
380
- **Bridge handshake.** The app announces `ready{capabilities,sdkVersion}`;
381
- the dashboard answers with its `theme` message, which also declares the
382
- DASHBOARD's capabilities: `{ type: "theme", mode, locale?, capabilities?:
383
- string[], bridge?: "1" }` (same caps as outbound: at most 32 entries of 64
384
- characters). `listenToDashboard` exposes both on the `BridgeTheme` passed to
385
- `onTheme`. A dashboard that omits `capabilities` is legacy (ready/token/
386
- resize only). `<QueekProvider>` stores the declared set: `pickResource`
387
- rejects at once when it lacks `pick-resource`, waits up to 1.5 s after
388
- `ready` when the set is still unknown, and rejects if no handshake arrives.
389
- Each pick carries a `requestId` (on `pick-resource`); a dashboard that
390
- declares `pick-resource` MUST echo it on `resource-picked` / `resource-pick-cancelled` — answers with a
391
- different or missing id are dropped as stale.
392
- - **auth** (`auth-fetch.ts`, no secret — browser-safe as a module, but import it
393
- from `@usequeek/app-sdk/browser` in browser bundles, never from the main
394
- entry): `installAuthFetch({ exchange })`
395
- — reads the dashboard token (`queek_token` query param) from the first
396
- load, strips it with `history.replaceState`, exchanges it once for the
397
- app's own session, attaches `Authorization: Bearer <session>` to
398
- same-origin fetch (cookies stay out), and re-establishes via the bridge
399
- `ready→token` flow + one retry on 401. Session recovery runs only when framed (a post target other than the
400
- window itself) and is not retried per request after a failed attempt — only
401
- after a later dashboard token arrives. Pass the same `capabilities`/
402
- `sdkVersion` as the provider so its refresh `ready` announces one consistent set. The app's
403
- token-exchange endpoint receives the first-load token and every 401-refresh
404
- token through the same `exchange` callback, so it verifies both with the
405
- single `verifySessionToken` verifier:
406
-
407
- ```ts
408
- import { verifySessionTokenDetailed } from "@usequeek/app-sdk/server";
409
-
410
- async function exchange(token: string): Promise<string> {
411
- const options = { secret: installation.embedSecret!, audience: "my-app", issuer: installation.apiBase, expected };
412
- const checked = await verifySessionTokenDetailed(token, options);
413
- if (!checked.ok) throw Object.assign(new Error("unauthorized"), { status: 401 });
414
- return mintAppSession(checked.claims);
415
- }
416
- ```
417
- - **theme** (`theme.ts`, browser-safe, no secret): `applyTheme(mode)` toggles
418
- `.dark` on `<html>` + `color-scheme` (shadcn's dark-mode mechanism);
419
- `themeBootstrapScript()` returns the inline `<head>` snippet that reads the
420
- unsigned `theme` URL param before first paint (no flash — a first-paint hint
421
- only, validated to `light`|`dark`, overwritten by the live bridge message);
422
- `installThemeListener` follows live bridge `theme{mode}` messages (only from
423
- `window.parent` by default; pass `expectSource` to override). The live mode is
424
- remembered in `sessionStorage`, and the bootstrap script falls back to it when
425
- the URL carries no `theme` param, so an in-frame reload never flashes light. Every live `theme` message also
426
- rewrites the `theme` URL param (other params kept) so a stale first-load param
427
- never beats the current mode on reload, and the first-load mode is remembered
428
- too (a legacy dashboard that never sends `theme` still reloads flash-free).
429
- - **react** (`react.ts`, via `@usequeek/app-sdk/react` — `react` is an
430
- optional peer, same pattern as `./hono`): `<QueekProvider>` owns one
431
- bridge subscription; `useQueek()` returns `{ toast, saveBar, title,
432
- navigate, pickResource, theme }` over the framework-free core.
433
- - **scopes** (`scopes.ts`): `createInstallationScopesClient({ installationId, store, signJwt, … })`
434
- — the optional-scopes session, wired to the same store + token provider as the
435
- installation client. `queryScopes` reads the cached grant (no network);
436
- `requestScopes` builds the dashboard consent link (pure — the SDK never
437
- renders consent); `revokeScopes` posts the app-authenticated revoke and
438
- refreshes the cache. The install/settings handlers accept the signed
439
- `app/scopes_update` handoff on both routes and refresh the cached grant
440
- (dropping the cached token when it moved).
430
+ The canonical string is `path`, `shop`, `ts` and the sorted, percent-encoded query pairs (without `sig`), joined with newlines. The signature is a hex HMAC-SHA256 keyed with the full `whsec_…` string, with no base64 decode. `kid` selects the installation, timestamps may skew by 5 minutes, each `jti` is single-use, and only `GET` is served. With Hono, mount `createProxyHandler({ store, path, onVerified })`.
441
431
 
442
432
  ## Scopes
443
433
 
444
- Apps declare required scopes (granted at install) plus optional scopes
445
- (requested later, revocable). The per-installation session covers the app side:
434
+ Apps declare required scopes (granted at install) and optional scopes (requested later, revocable).
446
435
 
447
436
  ```ts
448
437
  import { createInstallationScopesClient } from "@usequeek/app-sdk";
@@ -450,52 +439,66 @@ import { createInstallationScopesClient } from "@usequeek/app-sdk";
450
439
  const scopes = createInstallationScopesClient({
451
440
  installationId,
452
441
  store,
453
- signJwt: () => provider.signJwt(),
442
+ signJwt: () => tokens.signJwt(),
454
443
  appSlug: "my-app",
455
- optionalScopes: ["merchant-orders-read"], // the app's own manifest knowledge
444
+ optionalScopes: ["merchant-orders-read"],
456
445
  });
457
446
 
458
- await scopes.queryScopes(); // { granted, optional } — cached, no network
459
- scopes.requestScopes(["merchant-orders-read"]); // dashboard consent link (open via sendOpen)
460
- await scopes.revokeScopes(["merchant-orders-read"]); // 422 app_scope_required → AppScopeRequiredError
447
+ await scopes.queryScopes(); // { granted, optional }, from the cache, no network
448
+ scopes.requestScopes(["merchant-orders-read"]); // dashboard consent link; open it with sendOpen
449
+ await scopes.revokeScopes(["merchant-orders-read"]); // 422 app_scope_required becomes AppScopeRequiredError
461
450
  ```
462
451
 
463
- `queryScopes` splits the cached effective grant against the declared-optional
464
- list you pass (the app surface exposes the grant but no declared list, so the
465
- split needs your manifest knowledge). `requestScopes` returns
466
- `/apps?app={slug}&view=scopes&scopes={a},{b}` (absolute under
467
- `dashboardOrigin` when configured) — open it via `sendOpen` or a redirect: it
468
- opens the merchant's consent screen in the dashboard (requires a dashboard that
469
- supports the consent screen). The link carries the slug + the scope list only; the dashboard
470
- resolves the installation from the signed-in store. The grant reaches you as a
471
- signed `app/scopes_update` handoff, which the install/settings handlers apply
472
- like a resync (same verifier, same replay claim): cached grant refreshed,
473
- cached token dropped only when the grant moved.
452
+ The grant arrives as a signed `app/scopes_update` handoff. The install and settings handlers apply it, and drop the cached token only when the grant changed.
474
453
 
475
- ## Embedded merchant page
454
+ ## Local development
476
455
 
477
- The dashboard frames your app's granted merchant page in a
478
- `sandbox="allow-scripts allow-forms"` iframe and delivers the session token
479
- by postMessage. Serve the page with a `frame-ancestors` policy naming ONLY
480
- your dashboard origin, for example:
456
+ - `QUEEK_DEV_API_HOSTS` (comma-separated hostnames) points the client at a local tunnel. It is refused when `NODE_ENV=production`.
457
+ - Without `DATABASE_URL`, `createInstallationStore()` uses SQLite. In production it refuses SQLite and requires `DATABASE_URL`.
458
+ - Set `LOG_LEVEL` to `debug`, `info`, `warn` or `error` to change the logger threshold.
481
459
 
482
- ```http
483
- Content-Security-Policy: frame-ancestors https://dashboard.usequeek.com
484
- ```
460
+ ## API surface
485
461
 
486
- Replace the host with the dashboard origin you registered. `frame-ancestors`
487
- is a header only your server can set, so the dashboard cannot enforce it for you:
488
- without it any site may frame the page, and the token handshake (origin-bound) is
489
- your only remaining gate. Verify every token server-side before trusting calls that carry one
490
- (see the `auth` entry under API surface).
462
+ Every export has JSDoc in its type definitions. By module:
491
463
 
492
- ## Local development
464
+ | Module | What it gives you |
465
+ | --- | --- |
466
+ | `app-auth` | `loadAppCredential`, `signAppJwt`, the error code constants, `AppMintHaltedError` |
467
+ | `tokens` | `createAppTokenProvider`, `createInstallationClient` |
468
+ | `client` | `createQueekClient`, `paginate`, `QueekApiError`, `resolveApiBase` |
469
+ | `install-handlers` | `handleInstallDelivery`, `handleInstallRequest`, `saveResyncedInstallation` |
470
+ | `webhooks` | `handleWebhookDelivery`, `handleWebhookRequest` |
471
+ | `signatures` | `verifyQueekSignature`, `verifyQueekSignatureDetailed`, `signQueekPayload` |
472
+ | `proxy` | `verifyProxyQuery`, `verifyProxyDelivery`, `handleProxyRequest` |
473
+ | `store` | `SqliteInstallationStore`, `PostgresInstallationStore`, `createInstallationStore` |
474
+ | `resync`, `background` | `resyncFromQueek`, `runInstallationCatchup`, `detach` |
475
+ | `scopes` | `createInstallationScopesClient`, `AppScopeRequiredError` |
476
+ | `session` (`/server`) | `verifySessionToken`, `verifySessionTokenDetailed` |
477
+ | `frame`, `theme`, `auth-fetch` (`/browser`) | The embedded-page bridge, `installAuthFetch`, theme helpers |
478
+ | `react` (`/react`) | `QueekProvider`, `useQueek` |
479
+ | `logger` | `createLogger`, with secret redaction for keys, `whsec_` secrets, bearer tokens, JWTs and PEM blocks |
480
+
481
+ Installations are encrypted at rest with AES-256-GCM using `APP_ENCRYPTION_KEY`. The Postgres store takes an advisory lock while it creates its schema, so two containers can start together.
482
+
483
+ ## Versioning
484
+
485
+ The package follows semantic versioning once it reaches 1.0. Before that, a minor version can contain breaking changes, and each one is listed in [CHANGELOG.md](./CHANGELOG.md). The Merchant API is versioned separately: `v1` only adds fields and values, and a breaking change ships as `v2` beside it.
486
+
487
+ ## Contributing
488
+
489
+ ```sh
490
+ npm ci
491
+ npm run build && npm run typecheck && npm run typecheck:tests
492
+ npm run lint
493
+ npm test
494
+ ```
495
+
496
+ `npm test` also runs a public-text guard that fails on internal references and secret-shaped strings. Postgres tests run when `DATABASE_URL` points at a database and skip otherwise. `src/merchant-schema.ts` is generated from `openapi/merchant.json` by `npm run gen:merchant`: change the spec, never the generated file. Open an issue before a large change.
493
497
 
494
- Point the client at a local Queek API with `QUEEK_DEV_API_HOSTS` (comma-separated hosts). It is refused when `NODE_ENV=production`, so it can never weaken a live app. See `devApiHostsFromEnv` in `client.ts`.
498
+ ## Security
495
499
 
496
- Postgres locally: any Postgres works — point `DATABASE_URL` at it and the pg suite runs
497
- (CI always runs it via a service container); without `DATABASE_URL` those tests skip cleanly.
500
+ Report a vulnerability privately through this repository's security advisories, not in a public issue. Include the SDK version and a minimal reproduction. Never include a real secret.
498
501
 
499
502
  ## License
500
503
 
501
- MIT — see [LICENSE](./LICENSE).
504
+ MIT. Copyright Queekx Technology LTD. See [LICENSE](./LICENSE).