@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/README.md CHANGED
@@ -1,82 +1,135 @@
1
1
  # @usequeek/app-sdk
2
2
 
3
- > In development — not yet released. APIs may change before 1.0.
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
- 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`.
5
+ Full guide: <https://docs.usequeek.com/docs/apps/sdk>. Merchant API reference: <https://api.usequeek.com/docs/merchant.json>.
6
6
 
7
- ```sh
8
- npm i @usequeek/app-sdk pg
9
- ```
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.
10
8
 
11
- 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.
9
+ ## Requirements
12
10
 
13
- ## Which entry do I import?
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`.
14
14
 
15
- | Import | For | Pulls in |
16
- | --- | --- | --- |
17
- | `@usequeek/app-sdk` (main) | Server / universal code: handlers, client, stores, verifiers | `node:crypto`, `pg` — NOT browser-bundlable |
18
- | `@usequeek/app-sdk/server` | Session-token verifier (`verifySessionToken`) | Server-only (`jose`) — never import in a browser bundle |
19
- | `@usequeek/app-sdk/hono` | Thin Hono wrappers (`hono` optional peer) | Server-only |
20
- | `@usequeek/app-sdk/react` | React apps (`<QueekProvider>`, `useQueek()`; `react` optional peer) | Browser-safe (built on the same bridge modules as `/browser`) |
21
- | `@usequeek/app-sdk/browser` | Plain-browser code: `installAuthFetch` + bridge/theme helpers, no framework | Browser-safe ONLY — importing these helpers from the main entry drags in the whole barrel and breaks Vite/Rollup builds |
15
+ ## Install
22
16
 
23
- 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.
17
+ ```sh
18
+ npm i @usequeek/app-sdk
19
+ ```
24
20
 
25
- The shape follows [`@shopify/shopify-api`](https://github.com/Shopify/shopify-app-js/blob/main/packages/apps/shopify-api/README.md): the core "doesn't rely on any specific framework, so you can include it alongside your preferred stack" (runtime differences are covered by adapters such as `@shopify/shopify-api/adapters/node`), and framework integrations are separate packages in the [shopify-app-js monorepo](https://github.com/Shopify/shopify-app-js) (e.g. `@shopify/shopify-app-express` and `@shopify/shopify-app-remix` build on `@shopify/shopify-api`).
21
+ `pg` ships as a regular dependency because Postgres is the production store.
26
22
 
27
- ## Example (any framework)
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>.
28
24
 
29
- A minimal app with plain Web-standard handlers — no framework import:
25
+ ## Quick start
26
+
27
+ A complete server with plain Web-standard handlers. It has no framework import.
30
28
 
31
29
  ```ts
32
30
  import {
33
31
  createAppTokenProvider,
32
+ createInstallationClient,
34
33
  handleInstallRequest,
35
34
  handleWebhookRequest,
35
+ type InstallHandlerOptions,
36
36
  loadAppCredential,
37
37
  SqliteInstallationStore,
38
+ type WebhookHandlerOptions,
38
39
  } from "@usequeek/app-sdk";
39
40
 
40
41
  const store = new SqliteInstallationStore({
41
42
  path: "./data/installations.db",
42
- storeKey: process.env.APP_ENCRYPTION_KEY!,
43
+ storeKey: process.env.APP_ENCRYPTION_KEY ?? "",
43
44
  });
44
- const tokens = createAppTokenProvider({
45
- 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 ?? "",
46
51
  store,
47
- });
48
- const installOptions = { appSecret: process.env.QUEEK_APP_SECRET!, store };
49
- const webhookOptions = {
52
+ };
53
+
54
+ const webhookOptions: WebhookHandlerOptions = {
50
55
  store,
51
56
  handlers: {
52
- "orders/updated": async (envelope, context) => {
53
- 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);
54
65
  },
55
66
  },
56
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);
57
73
  ```
58
74
 
59
- 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`.
60
76
 
61
- ```ts
62
- // app/api/install/route.ts (and uninstall/route.ts, settings/route.ts likewise)
63
- import { installOptions } from "./options";
77
+ ## Which entry do I import?
64
78
 
65
- export async function POST(request: Request) {
66
- return handleInstallRequest(request, installOptions);
67
- }
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. |
68
86
 
69
- // app/api/webhooks/route.ts
70
- 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.
71
88
 
72
- export async function POST(request: Request) {
73
- return handleWebhookRequest(request, webhookOptions);
74
- }
75
- ```
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.
76
129
 
77
- ### Express (no new package)
130
+ ### Express
78
131
 
79
- 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:
80
133
 
81
134
  ```ts
82
135
  import express from "express";
@@ -84,8 +137,13 @@ import {
84
137
  type DeliveryResult,
85
138
  handleInstallDelivery,
86
139
  handleWebhookDelivery,
140
+ type InstallHandlerOptions,
141
+ type WebhookHandlerOptions,
87
142
  } from "@usequeek/app-sdk";
88
143
 
144
+ declare const installOptions: InstallHandlerOptions;
145
+ declare const webhookOptions: WebhookHandlerOptions;
146
+
89
147
  const app = express();
90
148
  app.use("/api", express.raw({ type: "application/json" }));
91
149
 
@@ -104,21 +162,21 @@ for (const route of ["install", "uninstall", "settings"]) {
104
162
  );
105
163
  });
106
164
  }
165
+
107
166
  app.post("/api/webhooks", async (req, res) => {
108
- reply(
109
- res,
110
- await handleWebhookDelivery({ rawBody: req.body as Buffer, headers: req.headers }, webhookOptions),
111
- );
167
+ reply(res, await handleWebhookDelivery({ rawBody: req.body as Buffer, headers: req.headers }, webhookOptions));
112
168
  });
113
169
  ```
114
170
 
115
- ### Fastify (no new package)
171
+ ### Fastify
116
172
 
117
- Same idea — keep the body raw with `addContentTypeParser`, then call the core directly:
173
+ Keep the body as a buffer with `addContentTypeParser`:
118
174
 
119
175
  ```ts
120
176
  import Fastify from "fastify";
121
- import { handleWebhookDelivery } from "@usequeek/app-sdk";
177
+ import { handleWebhookDelivery, type WebhookHandlerOptions } from "@usequeek/app-sdk";
178
+
179
+ declare const webhookOptions: WebhookHandlerOptions;
122
180
 
123
181
  const fastify = Fastify();
124
182
  fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
@@ -134,20 +192,8 @@ fastify.post("/api/webhooks", async (req, reply) => {
134
192
  });
135
193
  ```
136
194
 
137
- ### Why the raw body matters
138
-
139
- Signature verification covers the exact bytes Queek sent (`{id}.{timestamp}.{body}`), 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()`. This is Stripe's model (`stripe.webhooks.constructEvent(rawBody, sigHeader, secret)`): [their docs](https://docs.stripe.com/webhooks) put it bluntly — "Stripe requires the raw body of the request to perform signature verification… Any manipulation to the raw body of the request causes the verification to fail." 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()`.
140
-
141
- ### Body-size posture
142
-
143
- 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. No SDK option is needed unless Queek ever ships large deliveries.
144
-
145
- (If you already run Hono on Node, `@hono/node-server`'s `getRequestListener` bridges serving for you — but the adapters above need no extra dependency.)
146
-
147
195
  ### Hono
148
196
 
149
- Prefer Hono? The thin wrappers under `@usequeek/app-sdk/hono` (same options, same behaviour, same errors/status codes) mount the same core:
150
-
151
197
  ```ts
152
198
  import { SqliteInstallationStore } from "@usequeek/app-sdk";
153
199
  import { createInstallHandlers, createWebhookHandler } from "@usequeek/app-sdk/hono";
@@ -155,19 +201,18 @@ import { Hono } from "hono";
155
201
 
156
202
  const store = new SqliteInstallationStore({
157
203
  path: "./data/installations.db",
158
- storeKey: process.env.APP_ENCRYPTION_KEY!,
204
+ storeKey: process.env.APP_ENCRYPTION_KEY ?? "",
159
205
  });
160
206
 
161
207
  const app = new Hono();
162
- app.get("/health", (c) => c.json({ ok: true }));
163
- app.route("/", createInstallHandlers({ appSecret: process.env.QUEEK_APP_SECRET!, store }));
208
+ app.route("/", createInstallHandlers({ appSecret: process.env.QUEEK_APP_SECRET ?? "", store }));
164
209
  app.route(
165
210
  "/webhooks",
166
211
  createWebhookHandler({
167
212
  store,
168
213
  handlers: {
169
- "orders/updated": async (envelope, context) => {
170
- console.log("order update:", context.installation.storePid, envelope.data);
214
+ "orders/updated": async (envelope, { installation }) => {
215
+ console.log("order changed", installation.storePid, envelope.data);
171
216
  },
172
217
  },
173
218
  }),
@@ -176,261 +221,217 @@ app.route(
176
221
  export default app;
177
222
  ```
178
223
 
179
- Every Merchant API call goes through `createInstallationClient({ installationId, apiBase, tokens })`,
180
- 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.
181
227
 
182
- ## Credential lifecycle (S1: GitHub-style installation tokens)
228
+ ## Call the Merchant API
183
229
 
184
- One asymmetric credential per app — no per-installation secrets cross the handoff any more.
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";
185
234
 
186
- 1. **Generate a keypair** (once, offline) and keep the private key in the founder's password
187
- manager beside `APP_ENCRYPTION_KEY` (key + DB loss = working data lost — see below):
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
249
+
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
+ ```
270
+
271
+ ### Errors
272
+
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:
188
372
 
189
373
  ```sh
190
374
  openssl genrsa -out app-private.pem 2048
191
375
  openssl rsa -in app-private.pem -pubout -out app-public.pem
192
376
  ```
193
377
 
194
- 2. **Register the public key** (Queek side; the backend stores it under a `kid`):
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.
195
379
 
196
- ```sh
197
- php artisan app:register --public-key=./app-public.pem
198
- ```
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.
199
388
 
200
- Queek prints the app secret (`QUEEK_APP_SECRET`, verifies the handoff) and the key id
201
- (`APP_KEY_ID`, rides the JWT `kid` header). Configure the app with `APP_SLUG` (= `iss`),
202
- `APP_KEY_ID`, and `APP_PRIVATE_KEY` (base64 of the private key PEM, one line — the
203
- Developer page shows it; raw PEM also accepted — never logged, never shipped to clients).
204
-
205
- 3. **Acquire / cache / re-mint.** `acquireToken(installationId)` serves the cached token while
206
- its expiry is more than 5 minutes away; otherwise it signs an RS256 app JWT
207
- (`iss` = slug, `iat` = now − 60 s, `exp` = `iat` + 540 s, header `kid` — `node:crypto`
208
- only) and `POST`s `access_tokens`, then persists the token encrypted with its expiry and
209
- `kid` (one shared cache row per installation — restarts never burst). Concurrent callers
210
- in one process share one in-flight mint; two containers minting at once is harmless by
211
- design (Queek keeps coexisting tokens valid; a residual race self-heals via re-mint).
212
- Merchant refusal table (rev 7): any 401, or 403 `api_key_revoked` / `api_key_expired` /
213
- `invalid_client_key` → drop the token, re-mint once, retry once (a second refusal
214
- propagates); 403 `app_token_revoked` → drop ALL cached tokens and halt minting (kill
215
- switch / disabled app, no mint); every other 403 (scope, plan, mode) propagates to the
216
- caller without a mint.
217
-
218
- 4. **Failures follow the wire contract exactly** (`app-auth.ts` holds each code in one
219
- constant, confirmed against the backend build):
220
- - `401 invalid_client` — fatal for the app: loud log, minting stops, no retry loop.
221
- - `403 app_token_revoked` (kill switch / disabled app) — drops ALL cached tokens, stops
222
- minting, loud log. Minting resumes after the app is re-enabled + process restart
223
- (or `resumeMinting()`).
224
- - `404 app_installation_gone` — purges that installation locally.
225
- - `409 app_installation_pending` — the installation is not active yet: retry later
226
- with backoff, NEVER purge, NEVER halt. The row carries a persisted pending mark
227
- (store column, schema v2 — survives restarts, cleared when the installation is
228
- seen active or its mint succeeds).
229
- - `429 resync_cooldown` (per-installation resync only) — the ≤1/hour rotation
230
- cooldown: skipped + recorded, never retried. Any OTHER 429 is the per-app bucket
231
- (`too_many_requests`): `Retry-After` honored with jitter, bounded retries.
232
- - `5xx` — bounded exponential backoff.
233
-
234
- 5. **Resync after DB loss or a long outage.** `resyncFromQueek({ apiBase, tokens, store })`
235
- lists ACTIVE installations only (opaque keyset cursor — followed until `next_cursor`
236
- is null, never interpreted) → requests a resync per installation (409 pending →
237
- backoff + bounded retry, then skipped + recorded, never purged; 429 `resync_cooldown`
238
- → skipped + recorded, never retried; any other 429 → `Retry-After` + jitter, bounded
239
- retry; 404 `app_installation_gone` purges just that row and the run continues) → drops
240
- cached tokens → purges local rows absent from Queek's list, EXCEPT rows carrying the
241
- persisted pending mark — absence never purges a pending row, even across a restart.
242
- The fresh `webhook_secret` + non-secret settings arrive over the
243
- existing signed install channel — the install handler merges a redelivery for an
244
- existing installation idempotently (secret + settings refresh, `installedAt` and the
245
- cached token kept).
246
-
247
- 6. **Kill switch behaviour.** One backend operation revokes every token of an app across
248
- every store on the next request. The SDK side: cached tokens are dropped, minting halts
249
- with a loud log, and every call fails closed until the app is re-enabled and the
250
- process restarts (or resumes). Rotation without drama: add a `kid` (both verify) →
251
- switch the app to it → wait one token TTL → remove the old `kid`.
252
-
253
- ## Data rule + backups
254
-
255
- The app database holds INSTALLATIONS ONLY (refs, token cache, webhook secrets, settings).
256
- Business data lives in Queek. `resyncFromQueek` restores CONNECTIVITY with zero merchant
257
- action — but app WORKING data (inbound tokens, order links, form tokens, app-side-only
258
- settings) does NOT come back from resync: it needs the per-app `pg_dump` backup
259
- (RPO ≤ 24 h) plus the `APP_ENCRYPTION_KEY` backup. Events missed beyond Queek's webhook
260
- retries (~4 h) are gone; resync cannot backfill them. Full runbook: `docs/deploy.md` + O1.
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.
261
404
 
262
405
  ## Data deletion
263
406
 
264
- Treat the uninstall handoff (`app/uninstalled`) as the deletion trigger: the default
265
- `onUninstall` already deletes the installation row (secrets, token cache, settings) —
266
- keep that delete when you override it, and purge any app-side working data keyed by the
267
- 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.
268
408
 
269
- Unknown topics answer 200 without running a handler (forward compatibility, not consent):
270
- that 200 must not swallow a future privacy topic. If Queek ships mandatory privacy
271
- topics, register explicit handlers for them — an unhandled topic only reports
272
- `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.
273
410
 
274
- ## Storefront app-proxy (signed reads)
411
+ ## Storefront proxy
275
412
 
276
- Queek signs each storefront proxy fetch with the installation's `proxy_secret`
277
- (`AppProxyService::signQuery`); verify before answering the shopper:
413
+ Queek signs each storefront proxy read with the installation's `proxy_secret`. Verify before you answer:
278
414
 
279
415
  ```ts
280
- 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;
281
420
 
282
421
  export async function GET(request: Request) {
283
422
  return handleProxyRequest(
284
423
  request,
285
- { store, path: "/apps/booking/availability" }, // Queek-side canonical path, not the local route
286
- ({ installation, params }) =>
287
- 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) }),
288
426
  );
289
427
  }
290
428
  ```
291
429
 
292
- The canonical string is `path + "\n" + shop + "\n" + ts + "\n" + sorted(k=v&...)`
293
- (`sig` excluded), hex HMAC-SHA256 over the FULL `whsec_…` string — no base64 decode
294
- step. `kid` routes to the installation whose `proxy_secret` verifies (previous-secret
295
- grace: pass every active secret to `verifyProxyQuery`); timestamps skew at most 5
296
- minutes, each `jti` is single-use, and only `GET` is served (phase 1 is read-only).
297
- Hono: mount `createProxyHandler({ store, path, onVerified })` from
298
- `@usequeek/app-sdk/hono`.
299
-
300
- ## API surface
301
-
302
- - **app-auth** (`app-auth.ts`): `loadAppCredential` (`APP_SLUG`/`APP_KEY_ID`/`APP_PRIVATE_KEY`
303
- — base64 of the PEM, raw PEM, or `\n`-escaped one-line PEM; RSA validated at boot),
304
- `signAppJwt` (RS256, `iat` now − 60 s, `exp` window 540 s ≤ 600 s,
305
- `kid` header), the wire-contract error codes in one place (`INVALID_CLIENT_CODE`,
306
- `APP_TOKEN_REVOKED_CODE`, `APP_INSTALLATION_GONE_CODE`, `APP_INSTALLATION_PENDING_CODE`,
307
- `RESYNC_COOLDOWN_CODE`, `TOO_MANY_REQUESTS_CODE`), `AppMintHaltedError`.
308
- - **tokens** (`tokens.ts`): `createAppTokenProvider({ credential, store, … })` —
309
- `acquireToken` (cache → sign → mint → persist), single-flight per installation, the exact
310
- contract error mapping (409 pending → backoff + bounded retry, marked, never purged);
311
- `createInstallationClient({ installationId, apiBase, tokens })` — the `QueekClient`
312
- every app call uses (re-mint once + retry once on token refusals only).
313
- - **resync** (`resync.ts`): `resyncFromQueek({ apiBase, tokens, store })` — list active
314
- only (opaque keyset cursor) → resync each (409 pending → retry then skip + record;
315
- 429 `resync_cooldown` → skip + record; other 429 → backoff + retry) → drop tokens →
316
- purge absent except known-pending. Connectivity scope only.
317
- - **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.
318
- - **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.
319
- - **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`).
320
- - **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 with zero SDK publish. 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.
321
- - **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.
322
- - **proxy** (`proxy.ts`): `verifyProxyQuery` / `verifyProxyQueryDetailed` — app-proxy query verification byte-exact with the backend (`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`).
323
- - **store** (`store.ts`): `SqliteInstallationStore` (local/dev/test) and `PostgresInstallationStore`
324
- (`pg`, pool max 2, advisory-locked schema + `schema_version` row so two containers boot
325
- safely) — installations encrypted at rest (AES-GCM via `APP_ENCRYPTION_KEY`), plus the
326
- persisted 409-pending mark (`pending` column, schema v2, migrated in place) and the
327
- seen-webhook-id claim table behind dedupe. Pick with `createInstallationStore()`
328
- (`DATABASE_URL` set → Postgres, else SQLite — which production REFUSES with a clear
329
- message). Set once when the app is deployed; installs never change env: each install adds
330
- a row to the app's database, with that store's token + webhook secret encrypted using this key.
331
- - **background** (`background.ts`): `detach` plus `runInstallationCatchup` — jittered
332
- per-installation cron (uniform 0–600 s start jitter, per-install error isolation,
333
- concurrency ≤ pool size, honors 429 once per installation).
334
- - **logger** (`logger.ts`): redacts `sk_`/`pk_`/`whsec_`/`Bearer` values, bare RS256 JWTs, and
335
- PEM private-key blocks — the app JWT and private key can never reach logs.
336
- - **session** (`session.ts`, server-only via `@usequeek/app-sdk/server`):
337
- `verifySessionToken` — HS256 dashboard session tokens minted per installation
338
- (`embsec_…` secret, raw UTF-8 key bytes, 20 s clock tolerance, slug audience,
339
- issuer = the handoff `apiBase` verbatim (`installation.apiBase` — it equals the
340
- bare `app.url` the backend signs as `iss`), full installation binding). One token
341
- type, exactly Shopify's `id_token`: the dashboard puts the same token in the
342
- first-load URL param (`queek_token`, stripped on arrival) and answers bridge
343
- `ready` requests with it, so the app's exchange endpoint verifies first-load and
344
- refresh tokens with this one verifier — no purpose split, extra claims are
345
- ignored. The secret never enters a
346
- browser bundle: the main entry does not export the verifier. The install
347
- and resync handoffs deliver `embed_secret` + `app_id`; the store keeps
348
- them on the installation (`embedSecret` encrypted, `appId`), and
349
- `sessionTokenInstallationId` reads the token's `installation_id` as an
350
- unverified routing hint so a server can load that row before verifying.
351
- - **frame** (`frame.ts`, browser-safe, no secret): the embedded-app bridge
352
- v1 — typed unions both directions (`ready{capabilities,sdkVersion}`,
353
- `theme`, `title`/`title-action`, `toast`, `save-bar`/`save-bar-action`,
354
- `navigate`/`navigated`, `open`, `pick-resource`/`resource-picked`/
355
- `resource-pick-cancelled`), unknown types ignored, every outbound string
356
- length-capped client-side. `listenToDashboard` (accepts only the exact
357
- dashboard origin, plus an `expectSource` window check) and typed senders
358
- (`sendReady`, `sendToast`, `sendSaveBar`, `sendTitle`, `sendNavigated`,
359
- `sendOpen`, `sendPickResource`, …) to the exact target origin, never `"*"`.
360
- `parseOutboundMessage` runs the same per-type validation the dashboard
361
- runs — the dashboard re-validates everything before acting (open targets,
362
- title/toast/pick content), so a compromised frame cannot smuggle
363
- `javascript:` URLs or uncapped strings through the bridge. `open` targets
364
- are a dashboard-relative path or an absolute `https:` URL; backslashes,
365
- control characters, protocol-relative URLs and every other scheme are
366
- refused (by `clipOutbound` as well as `isAllowedOpenTarget`).
367
-
368
- **Bridge handshake.** The app announces `ready{capabilities,sdkVersion}`;
369
- the dashboard answers with its `theme` message, which also declares the
370
- DASHBOARD's capabilities: `{ type: "theme", mode, locale?, capabilities?:
371
- string[], bridge?: "1" }` (same caps as outbound: at most 32 entries of 64
372
- characters). `listenToDashboard` exposes both on the `BridgeTheme` passed to
373
- `onTheme`. A dashboard that omits `capabilities` is legacy (ready/token/
374
- resize only). `<QueekProvider>` stores the declared set: `pickResource`
375
- rejects at once when it lacks `pick-resource`, waits up to 1.5 s after
376
- `ready` when the set is still unknown, and rejects if no handshake arrives.
377
- Each pick carries a `requestId` (on `pick-resource`); a dashboard that
378
- declares `pick-resource` MUST echo it on `resource-picked` / `resource-pick-cancelled` — answers with a
379
- different or missing id are dropped as stale.
380
- - **auth** (`auth-fetch.ts`, no secret — browser-safe as a module, but import it
381
- from `@usequeek/app-sdk/browser` in browser bundles, never from the main
382
- entry): `installAuthFetch({ exchange })`
383
- — reads the dashboard token (`queek_token` query param) from the first
384
- load, strips it with `history.replaceState`, exchanges it once for the
385
- app's own session, attaches `Authorization: Bearer <session>` to
386
- same-origin fetch (cookies stay out), and re-establishes via the bridge
387
- `ready→token` flow + one retry on 401. Session recovery runs only when framed (a post target other than the
388
- window itself) and is not retried per request after a failed attempt — only
389
- after a later dashboard token arrives. Pass the same `capabilities`/
390
- `sdkVersion` as the provider so its refresh `ready` announces one consistent set. The app's
391
- token-exchange endpoint receives the first-load token and every 401-refresh
392
- token through the same `exchange` callback — one token type, so it verifies
393
- both with the single `verifySessionToken` verifier:
394
-
395
- ```ts
396
- import { verifySessionTokenDetailed } from "@usequeek/app-sdk/server";
397
-
398
- async function exchange(token: string): Promise<string> {
399
- const options = { secret: installation.embedSecret!, audience: "my-app", issuer: installation.apiBase, expected };
400
- const checked = await verifySessionTokenDetailed(token, options);
401
- if (!checked.ok) throw Object.assign(new Error("unauthorized"), { status: 401 });
402
- return mintAppSession(checked.claims);
403
- }
404
- ```
405
- - **theme** (`theme.ts`, browser-safe, no secret): `applyTheme(mode)` toggles
406
- `.dark` on `<html>` + `color-scheme` (shadcn's dark-mode mechanism);
407
- `themeBootstrapScript()` returns the inline `<head>` snippet that reads the
408
- unsigned `theme` URL param before first paint (no flash — a first-paint hint
409
- only, validated to `light`|`dark`, overwritten by the live bridge message);
410
- `installThemeListener` follows live bridge `theme{mode}` messages (only from
411
- `window.parent` by default; pass `expectSource` to override). The live mode is
412
- remembered in `sessionStorage`, and the bootstrap script falls back to it when
413
- the URL carries no `theme` param, so an in-frame reload never flashes light. Every live `theme` message also
414
- rewrites the `theme` URL param (other params kept) so a stale first-load param
415
- never beats the current mode on reload, and the first-load mode is remembered
416
- too (a legacy dashboard that never sends `theme` still reloads flash-free).
417
- - **react** (`react.ts`, via `@usequeek/app-sdk/react` — `react` is an
418
- optional peer, same pattern as `./hono`): `<QueekProvider>` owns one
419
- bridge subscription; `useQueek()` returns `{ toast, saveBar, title,
420
- navigate, pickResource, theme }` over the framework-free core.
421
- - **scopes** (`scopes.ts`): `createInstallationScopesClient({ installationId, store, signJwt, … })`
422
- — the optional-scopes session, wired to the same store + token provider as the
423
- installation client. `queryScopes` reads the cached grant (no network);
424
- `requestScopes` builds the dashboard consent link (pure — the SDK never
425
- renders consent); `revokeScopes` posts the app-authenticated revoke and
426
- refreshes the cache. The install/settings handlers accept the signed
427
- `app/scopes_update` handoff on both routes and refresh the cached grant
428
- (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 })`.
429
431
 
430
432
  ## Scopes
431
433
 
432
- Apps declare required scopes (granted at install) plus optional scopes
433
- (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).
434
435
 
435
436
  ```ts
436
437
  import { createInstallationScopesClient } from "@usequeek/app-sdk";
@@ -438,58 +439,66 @@ import { createInstallationScopesClient } from "@usequeek/app-sdk";
438
439
  const scopes = createInstallationScopesClient({
439
440
  installationId,
440
441
  store,
441
- signJwt: () => provider.signJwt(),
442
+ signJwt: () => tokens.signJwt(),
442
443
  appSlug: "my-app",
443
- optionalScopes: ["merchant-orders-read"], // the app's own manifest knowledge
444
+ optionalScopes: ["merchant-orders-read"],
444
445
  });
445
446
 
446
- await scopes.queryScopes(); // { granted, optional } — cached, no network
447
- scopes.requestScopes(["merchant-orders-read"]); // dashboard consent link (open via sendOpen)
448
- 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
449
450
  ```
450
451
 
451
- `queryScopes` splits the cached effective grant against the declared-optional
452
- list you pass (the app surface exposes the grant but no declared list, so the
453
- split needs your manifest knowledge). `requestScopes` returns
454
- `/apps?app={slug}&view=scopes&scopes={a},{b}` (absolute under
455
- `dashboardOrigin` when configured) — open it via `sendOpen` or a redirect: it
456
- opens the merchant's consent screen in the dashboard (shipped with dashboard
457
- item 8). The link carries the slug + the scope list only; the dashboard
458
- resolves the installation from the signed-in store. The grant reaches you as a
459
- signed `app/scopes_update` handoff, which the install/settings handlers apply
460
- like a resync (same verifier, same replay claim): cached grant refreshed,
461
- cached token dropped only when the grant moved.
462
-
463
- ## Embedded merchant page (S4 stage 2)
464
-
465
- The dashboard frames your app's granted merchant page in a
466
- `sandbox="allow-scripts allow-forms"` iframe and delivers the session token
467
- by postMessage. Serve the page with a `frame-ancestors` policy naming ONLY
468
- your dashboard origin, for example:
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.
469
453
 
470
- ```http
471
- Content-Security-Policy: frame-ancestors https://merchant.queek.com
472
- ```
454
+ ## Local development
473
455
 
474
- Replace the host with the dashboard origin you registered. `frame-ancestors`
475
- is a docs-only control the dashboard cannot enforce for you: without it any
476
- site may frame the page, and the token handshake (origin-bound) is your only
477
- remaining gate. Verify every token server-side before trusting calls that carry one:
478
- the token-exchange endpoint receives the first-load token and every 401-refresh
479
- token through the same `installAuthFetch({ exchange })` callback — one token type,
480
- verified with the single `verifySessionToken` verifier (see the `auth` entry above).
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
- ## Local development
460
+ ## API surface
461
+
462
+ Every export has JSDoc in its type definitions. By module:
483
463
 
484
- Point the client at a local backend 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`.
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
+ ```
485
495
 
486
- Postgres locally: any Postgres works — point `DATABASE_URL` at it and the pg suite runs
487
- (CI always runs it via a service container); without `DATABASE_URL` those tests skip cleanly.
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.
488
497
 
489
- ## Registration
498
+ ## Security
490
499
 
491
- App registration is by the Queek team today: you ship Queek your manifest URL and receive the app secret + store key. There is no public self-serve registration yet.
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.
492
501
 
493
502
  ## License
494
503
 
495
- MIT — see [LICENSE](./LICENSE).
504
+ MIT. Copyright Queekx Technology LTD. See [LICENSE](./LICENSE).