@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.
- package/LICENSE +1 -1
- package/README.md +340 -337
- package/dist/app-auth.d.ts +1 -2
- package/dist/app-auth.js +1 -2
- package/dist/auth-fetch.d.ts +12 -1
- package/dist/auth-fetch.js +13 -2
- package/dist/background.d.ts +9 -1
- package/dist/background.js +9 -1
- package/dist/browser.d.ts +1 -2
- package/dist/browser.js +1 -2
- package/dist/client.d.ts +130 -7
- package/dist/client.js +198 -41
- package/dist/collected.d.ts +0 -1
- package/dist/collected.js +0 -1
- package/dist/crypto.d.ts +11 -1
- package/dist/crypto.js +22 -7
- package/dist/delivery.d.ts +18 -1
- package/dist/delivery.js +26 -17
- package/dist/frame.d.ts +0 -1
- package/dist/frame.js +0 -1
- package/dist/handoff.d.ts +0 -1
- package/dist/handoff.js +0 -1
- package/dist/hono.d.ts +0 -1
- package/dist/hono.js +0 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/install-handlers.d.ts +22 -2
- package/dist/install-handlers.js +37 -7
- package/dist/logger.d.ts +7 -1
- package/dist/logger.js +7 -1
- package/dist/merchant-schema.d.ts +1184 -336
- package/dist/merchant-schema.js +0 -1
- package/dist/proxy.d.ts +2 -3
- package/dist/proxy.js +4 -5
- package/dist/react.d.ts +9 -1
- package/dist/react.js +10 -2
- package/dist/resync.d.ts +11 -1
- package/dist/resync.js +18 -16
- package/dist/scopes.d.ts +14 -1
- package/dist/scopes.js +18 -17
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/session.d.ts +20 -1
- package/dist/session.js +20 -1
- package/dist/signatures.d.ts +26 -1
- package/dist/signatures.js +29 -3
- package/dist/store.d.ts +6 -1
- package/dist/store.js +0 -1
- package/dist/theme.d.ts +0 -1
- package/dist/theme.js +0 -1
- package/dist/tokens.d.ts +9 -1
- package/dist/tokens.js +23 -47
- package/dist/webhooks.d.ts +28 -3
- package/dist/webhooks.js +25 -7
- package/package.json +18 -2
- package/dist/app-auth.d.ts.map +0 -1
- package/dist/app-auth.js.map +0 -1
- package/dist/auth-fetch.d.ts.map +0 -1
- package/dist/auth-fetch.js.map +0 -1
- package/dist/background.d.ts.map +0 -1
- package/dist/background.js.map +0 -1
- package/dist/browser.d.ts.map +0 -1
- package/dist/browser.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/collected.d.ts.map +0 -1
- package/dist/collected.js.map +0 -1
- package/dist/crypto.d.ts.map +0 -1
- package/dist/crypto.js.map +0 -1
- package/dist/delivery.d.ts.map +0 -1
- package/dist/delivery.js.map +0 -1
- package/dist/frame.d.ts.map +0 -1
- package/dist/frame.js.map +0 -1
- package/dist/handoff.d.ts.map +0 -1
- package/dist/handoff.js.map +0 -1
- package/dist/hono.d.ts.map +0 -1
- package/dist/hono.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/install-handlers.d.ts.map +0 -1
- package/dist/install-handlers.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/merchant-schema.d.ts.map +0 -1
- package/dist/merchant-schema.js.map +0 -1
- package/dist/proxy.d.ts.map +0 -1
- package/dist/proxy.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/resync.d.ts.map +0 -1
- package/dist/resync.js.map +0 -1
- package/dist/scopes.d.ts.map +0 -1
- package/dist/scopes.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js.map +0 -1
- package/dist/signatures.d.ts.map +0 -1
- package/dist/signatures.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/theme.d.ts.map +0 -1
- package/dist/theme.js.map +0 -1
- package/dist/tokens.d.ts.map +0 -1
- package/dist/tokens.js.map +0 -1
- package/dist/webhooks.d.ts.map +0 -1
- package/dist/webhooks.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,93 +1,135 @@
|
|
|
1
1
|
# @usequeek/app-sdk
|
|
2
2
|
|
|
3
|
-
The SDK for building
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
```sh
|
|
18
|
+
npm i @usequeek/app-sdk
|
|
19
|
+
```
|
|
27
20
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
25
|
+
## Quick start
|
|
39
26
|
|
|
40
|
-
A
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
const webhookOptions = {
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const webhookOptions: WebhookHandlerOptions = {
|
|
61
55
|
store,
|
|
62
56
|
handlers: {
|
|
63
|
-
"orders/updated": async (envelope,
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
130
|
+
### Express
|
|
89
131
|
|
|
90
|
-
|
|
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
|
|
171
|
+
### Fastify
|
|
127
172
|
|
|
128
|
-
|
|
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.
|
|
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,
|
|
179
|
-
console.log("order
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
+
### Errors
|
|
194
272
|
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
411
|
+
## Storefront proxy
|
|
289
412
|
|
|
290
|
-
Queek signs each storefront proxy
|
|
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
|
|
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
|
|
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)
|
|
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: () =>
|
|
442
|
+
signJwt: () => tokens.signJwt(),
|
|
454
443
|
appSlug: "my-app",
|
|
455
|
-
optionalScopes: ["merchant-orders-read"],
|
|
444
|
+
optionalScopes: ["merchant-orders-read"],
|
|
456
445
|
});
|
|
457
446
|
|
|
458
|
-
await scopes.queryScopes(); // { granted, optional }
|
|
459
|
-
scopes.requestScopes(["merchant-orders-read"]); // dashboard consent link
|
|
460
|
-
await scopes.revokeScopes(["merchant-orders-read"]); // 422 app_scope_required
|
|
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
|
-
`
|
|
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
|
-
##
|
|
454
|
+
## Local development
|
|
476
455
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
483
|
-
Content-Security-Policy: frame-ancestors https://dashboard.usequeek.com
|
|
484
|
-
```
|
|
460
|
+
## API surface
|
|
485
461
|
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
+
## Security
|
|
495
499
|
|
|
496
|
-
|
|
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
|
|
504
|
+
MIT. Copyright Queekx Technology LTD. See [LICENSE](./LICENSE).
|