@usequeek/app-sdk 0.4.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 (77) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -0
  4. package/dist/app-auth.d.ts +139 -0
  5. package/dist/app-auth.d.ts.map +1 -0
  6. package/dist/app-auth.js +205 -0
  7. package/dist/app-auth.js.map +1 -0
  8. package/dist/background.d.ts +68 -0
  9. package/dist/background.d.ts.map +1 -0
  10. package/dist/background.js +78 -0
  11. package/dist/background.js.map +1 -0
  12. package/dist/client.d.ts +148 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +269 -0
  15. package/dist/client.js.map +1 -0
  16. package/dist/collected.d.ts +103 -0
  17. package/dist/collected.d.ts.map +1 -0
  18. package/dist/collected.js +59 -0
  19. package/dist/collected.js.map +1 -0
  20. package/dist/crypto.d.ts +6 -0
  21. package/dist/crypto.d.ts.map +1 -0
  22. package/dist/crypto.js +76 -0
  23. package/dist/crypto.js.map +1 -0
  24. package/dist/frame.d.ts +58 -0
  25. package/dist/frame.d.ts.map +1 -0
  26. package/dist/frame.js +56 -0
  27. package/dist/frame.js.map +1 -0
  28. package/dist/handoff.d.ts +113 -0
  29. package/dist/handoff.d.ts.map +1 -0
  30. package/dist/handoff.js +29 -0
  31. package/dist/handoff.js.map +1 -0
  32. package/dist/index.d.ts +25 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +25 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/install-handlers.d.ts +62 -0
  37. package/dist/install-handlers.d.ts.map +1 -0
  38. package/dist/install-handlers.js +238 -0
  39. package/dist/install-handlers.js.map +1 -0
  40. package/dist/logger.d.ts +27 -0
  41. package/dist/logger.d.ts.map +1 -0
  42. package/dist/logger.js +75 -0
  43. package/dist/logger.js.map +1 -0
  44. package/dist/merchant-schema.d.ts +13963 -0
  45. package/dist/merchant-schema.d.ts.map +1 -0
  46. package/dist/merchant-schema.js +11 -0
  47. package/dist/merchant-schema.js.map +1 -0
  48. package/dist/resync.d.ts +80 -0
  49. package/dist/resync.d.ts.map +1 -0
  50. package/dist/resync.js +203 -0
  51. package/dist/resync.js.map +1 -0
  52. package/dist/server.d.ts +9 -0
  53. package/dist/server.d.ts.map +1 -0
  54. package/dist/server.js +8 -0
  55. package/dist/server.js.map +1 -0
  56. package/dist/session.d.ts +66 -0
  57. package/dist/session.d.ts.map +1 -0
  58. package/dist/session.js +155 -0
  59. package/dist/session.js.map +1 -0
  60. package/dist/signatures.d.ts +55 -0
  61. package/dist/signatures.d.ts.map +1 -0
  62. package/dist/signatures.js +74 -0
  63. package/dist/signatures.js.map +1 -0
  64. package/dist/store.d.ts +278 -0
  65. package/dist/store.d.ts.map +1 -0
  66. package/dist/store.js +610 -0
  67. package/dist/store.js.map +1 -0
  68. package/dist/tokens.d.ts +162 -0
  69. package/dist/tokens.d.ts.map +1 -0
  70. package/dist/tokens.js +411 -0
  71. package/dist/tokens.js.map +1 -0
  72. package/dist/webhooks.d.ts +60 -0
  73. package/dist/webhooks.d.ts.map +1 -0
  74. package/dist/webhooks.js +125 -0
  75. package/dist/webhooks.js.map +1 -0
  76. package/openapi/merchant.json +21452 -0
  77. package/package.json +63 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,120 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@usequeek/app-sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+
5
+ ## [0.2.0] - 2026-09-25
6
+
7
+ GitHub-style app credentials (slice S1 of `app-credentials-and-databases`, built against the
8
+ `/api/v1/apps` wire contract in parallel with the backend B1/B2 — the backend is not deployed yet).
9
+
10
+ ### Changed
11
+
12
+ - **BREAKING:** the install handoff no longer carries a store-callable `api_key`, and the store
13
+ no longer has an `api_key_enc` column. Installations cache one minted token instead
14
+ (`token_enc` + `token_expires_at` + `token_kid`); opening a 0.1.x SQLite database migrates it
15
+ (legacy key ciphertext is discarded — minted tokens replace it).
16
+ - `InstallationRecord` carries the cached token (`token`/`tokenExpiresAt`/`tokenKid`, memory-only
17
+ plaintext) plus the persisted 409-pending mark (`pending`); `InstallationStore` gains
18
+ `listInstallations()`, `clearCachedToken()`, `clearAllCachedTokens()`,
19
+ `markInstallationPending()` / `clearInstallationPending()` / `isKnownPending()`.
20
+ Opening a v1 database migrates it (schema v2: `pending` column, backfilled false;
21
+ Postgres `schema_version` guard converges to exactly one row at version 2).
22
+ - The default install handler merges a redelivered install envelope for an existing installation
23
+ idempotently (resync path): secret + settings refresh, `installedAt` and the cached token kept.
24
+ - The logger additionally redacts bare RS256 JWTs and PEM private-key blocks.
25
+ - `.env.example` files document `APP_KEY_ID` + `APP_PRIVATE_KEY`.
26
+
27
+ ### Added
28
+
29
+ - `app-auth.ts`: `loadAppCredential` (`APP_SLUG`/`APP_KEY_ID`/`APP_PRIVATE_KEY`, RSA PEM validated
30
+ at boot), `signAppJwt` (RS256 via `node:crypto` only, `iat` now − 60 s, `exp` window 540 s,
31
+ `kid` header), and the wire-contract error codes in exactly one place (`INVALID_CLIENT_CODE`,
32
+ `APP_TOKEN_REVOKED_CODE` (confirmed: `ApiError::APP_TOKEN_REVOKED`, also returned when the app is
33
+ disabled), `APP_INSTALLATION_GONE_CODE`, `APP_INSTALLATION_PENDING_CODE`, `RESYNC_COOLDOWN_CODE`,
34
+ `TOO_MANY_REQUESTS_CODE`).
35
+ - `tokens.ts`: `createAppTokenProvider` (`acquireToken` with 5-minute validity skew, in-process
36
+ single-flight per installation, exact contract error mapping: `invalid_client` halts, kill-switch
37
+ 403 drops all tokens and halts, `app_installation_gone` purges, 429 + jitter, bounded 5xx
38
+ backoff) and `createInstallationClient` (every call through `acquireToken`; merchant re-mint
39
+ only on token refusals — any 401 or 403 `api_key_revoked`/`api_key_expired`/`invalid_client_key`
40
+ → drop, re-mint once, retry once; merchant 403 `app_token_revoked` → drop all + halt; every
41
+ other 403 propagates without a mint).
42
+
43
+ ### Fixed
44
+
45
+ - Merchant 403s that are NOT token refusals (e.g. `insufficient_scope`, plan, mode) no longer
46
+ burn a mint per call: they propagate to the caller untouched (rev 7 review). `AppTokens` gains
47
+ `revokeAppAccess()` for the merchant-observed kill switch.
48
+ - Mint/resync 409 `app_installation_pending` (rev 8 review): retry later with backoff
49
+ (bounded), NEVER purge, NEVER halt. The installation carries a persisted pending mark
50
+ (B2 review r2: `pending` column on the installation row, schema v2 — survives restarts;
51
+ `markInstallationPending` / `isKnownPending` / `clearInstallationPending` on both the
52
+ stores and `AppTokenProvider`, which delegates); a 409 with any other code propagates
53
+ untouched.
54
+ - Resync 404 `app_installation_gone` purges just that installation and the run continues
55
+ (backend re-checks status on resync per B2 review r2 — an uninstall landing between
56
+ authorize and resync 404s here); the row's deletion drops its pending mark with it.
57
+ - Resync 429s are distinguished by code (rev 8): 429 `resync_cooldown` is the ≤1/hour
58
+ rotation cooldown (skipped + recorded in `cooldownSkipped`, never retried); any other 429
59
+ is the per-app bucket (`too_many_requests`: `Retry-After` + jitter, bounded retries,
60
+ then the run aborts — never recorded as a cooldown skip). `ResyncResult` gains
61
+ `pendingSkipped` for installations still pending after the retry budget.
62
+ - Resync purge-absent keeps locally pending rows (rev 8): `GET installations` lists active
63
+ installations only, so absence never purges an installation the app knows (via a 409 on
64
+ mint or resync) is pending. The list cursor is treated as opaque keyset throughout —
65
+ followed until `next_cursor` is null, never interpreted.
66
+ - `resync.ts`: `resyncFromQueek` (active-only list over an opaque keyset cursor →
67
+ per-installation resync with pending-retry/skip + cooldown-skip vs throttle-retry →
68
+ drop cached tokens → purge absent except known-pending; connectivity scope only).
69
+ - `store.ts`: `PostgresInstallationStore` (`pg`, pool max 2, advisory-locked schema +
70
+ `schema_version` row), `createInstallationStore` (`DATABASE_URL` → Postgres, else SQLite —
71
+ refused in production), AES-GCM envelope unchanged.
72
+ - `background.ts`: `runInstallationCatchup` (uniform 0–600 s start jitter, per-install error
73
+ isolation, concurrency ≤ pool size, honors 429 once per installation).
74
+ - CI runs the Postgres suite via a `postgres:16` service container (`DATABASE_URL` always set);
75
+ without `DATABASE_URL` those tests skip cleanly.
76
+ - Vitest suites against a local fake Queek server (JWT-verifying mint, opaque-cursor list, resync,
77
+ merchant refusals): cache-hit/expiry/single-flight/two-container minting, every contract error,
78
+ wipe → resync → connectivity, cooldown skip vs throttle retry, pending mint/resync retry + skip,
79
+ purge-absent (including pending-kept), resync-404 purges one installation without aborting,
80
+ persisted pending marks (v1→v2 migration + restart simulation on both stores),
81
+ production-refuses-SQLite.
82
+
83
+ ## [0.3.0] - 2026-09-25
84
+
85
+ One shared Postgres pool per app process (the apps' own tables now live beside the SDK's tables in the same per-app database).
86
+
87
+ ### Added
88
+
89
+ - `store.ts`: `createPostgresPool(connectionString, max)` — builds one `pg` Pool with the idle-client error guard for a process to share between its installation store and its app store. `PostgresInstallationStore` and `createInstallationStore` accept an optional shared `pool` (a provided pool wins over `DATABASE_URL`; `close()` never ends a pool the store does not own). `PostgresStoreOptions.connectionString` is now optional when `pool` is given.
90
+
91
+ ## [0.4.0] - 2026-09-28
92
+
93
+ Standalone release: `@usequeek/app-sdk` ships from its own repo (`usequeek/app-sdk`).
94
+
95
+ ### Added
96
+
97
+ - Session verifier as a server-only export (`@usequeek/app-sdk/server`): `verifySessionToken` — HS256 dashboard session tokens minted per installation (`embsec_…` secret, 20 s clock tolerance, slug audience + api_base issuer, full installation binding). The secret never enters a browser bundle: the main entry does not export the verifier. `sessionTokenInstallationId` reads the token's `installation_id` as an unverified routing hint.
98
+ - Frame bridge (`frame.ts`, browser-safe, no secret): `listenToDashboard` (accepts only the exact dashboard origin) and `sendReady` (exact target origin, never `"*"`).
99
+ - Resync handoff accepted on both `/install` and `/settings` (`type: app/resync`): the install-shaped data plus the resync-only `secret_rotated` flag merges via `saveResyncedInstallation` (secrets/settings/scopes refresh, `installedAt` and the cached token kept).
100
+ - Install handoff carries `app_id` + `embed_secret`: the app's own id (exactly as the session token signs it) and the installation's `embsec_…` embed secret, handed over on install/resync and kept on the installation record (`embedSecret` encrypted, `appId`).
101
+
102
+ ### Changed
103
+
104
+ - Merchant snapshot refreshed from production (line-properties PATCH, `storefront.domains`, `app_id`, no list `meta.currency`): regenerated types from the live Merchant API contract (decision 2026-09-24-one-public-product-object) — the product list + retrieve now return the one public `ProductResource`, the same object as `products/*` webhooks (`id` = int p_id, `uid` = UUID, plus `url`, `storefront_price`, `storefront_compare_at_price`, `primary_image_url`, `variants[]` with integer `id` = variant p_id, `images[]`; no cost/wholesale/discount internals).
105
+ - Installation-bound app writes for the S3a contract (queek_backend@c1fa1c31; snapshot intentionally ahead of production — see `openapi/merchant.drift.json`, re-check after the S3a deploy): `setSetupNotice` (PUT app/setup, full-sheet write), `sendAlert` (POST app/alerts, severity/title/message plus optional dedupe key), `collectedDefinitions.list/create/update` (the calling installation's own collected types), and `createRecord` (POST records with a fresh Idempotency-Key per submit; `values` is a `{field_key: value}` object per the backend's FormsAppCollectedTest).
106
+
107
+ ## [0.1.0] - 2026-09-24
108
+
109
+ First release: the kit every Queek app is built from.
110
+
111
+ ### Added
112
+
113
+ - Standard Webhooks signature verification (`verifyQueekSignature`) with timestamp-skew enforcement.
114
+ - Hono install/uninstall/settings handlers (`createInstallHandlers`) with encrypted persistence defaults.
115
+ - Typed Merchant API client (`createQueekClient`): `X-Client-Key` auth, `Idempotency-Key` on writes, typed errors, 429 retry helpers. Types generated from the committed `openapi/merchant.json` snapshot.
116
+ - Topic webhook dispatch (`createWebhookHandler`) with per-installation secrets and `webhook-id` dedupe.
117
+ - Encrypted SQLite installation store (`SqliteInstallationStore`, AES-GCM).
118
+ - Detached background-task helper (`detach`) with delivery-result tracking.
119
+ - Redacting JSON logger (`createLogger`).
120
+ - Dev-only `QUEEK_DEV_API_HOSTS` API-host override (refused when `NODE_ENV=production`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Queek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # @usequeek/app-sdk
2
+
3
+ The SDK for building a Queek app on the public Merchant API and signed webhooks. Hono route helpers for the install handoff and topic webhooks, 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).
4
+
5
+ ```sh
6
+ npm i @usequeek/app-sdk hono pg
7
+ ```
8
+
9
+ Requires Node `>=22.14`. `hono` is a peer dependency so your app never carries two copies. `pg` is a regular dependency (the production store).
10
+
11
+ ## Example
12
+
13
+ A minimal app: health check, install/uninstall/settings handlers, and one webhook topic:
14
+
15
+ ```ts
16
+ import {
17
+ createAppTokenProvider,
18
+ createInstallHandlers,
19
+ createWebhookHandler,
20
+ loadAppCredential,
21
+ SqliteInstallationStore,
22
+ } from "@usequeek/app-sdk";
23
+ import { Hono } from "hono";
24
+
25
+ const store = new SqliteInstallationStore({
26
+ path: "./data/installations.db",
27
+ storeKey: process.env.APP_ENCRYPTION_KEY!,
28
+ });
29
+ const tokens = createAppTokenProvider({
30
+ credential: loadAppCredential({ appSlug: "hello" }), // APP_SLUG/APP_KEY_ID/APP_PRIVATE_KEY
31
+ store,
32
+ });
33
+
34
+ const app = new Hono();
35
+ app.get("/health", (c) => c.json({ ok: true }));
36
+ app.route("/", createInstallHandlers({ appSecret: process.env.QUEEK_APP_SECRET!, store }));
37
+ app.route(
38
+ "/webhooks",
39
+ createWebhookHandler({
40
+ store,
41
+ handlers: {
42
+ "orders/updated": async (envelope, context) => {
43
+ console.log("order update:", context.installation.storePid, envelope.data);
44
+ },
45
+ },
46
+ }),
47
+ );
48
+
49
+ export default app;
50
+ ```
51
+
52
+ Every Merchant API call goes through `createInstallationClient({ installationId, apiBase, tokens })`,
53
+ which resolves the installation's token via `acquireToken()` and sends it as `X-Client-Key`.
54
+
55
+ ## Credential lifecycle (S1: GitHub-style installation tokens)
56
+
57
+ One asymmetric credential per app — no per-installation secrets cross the handoff any more.
58
+
59
+ 1. **Generate a keypair** (once, offline) and keep the private key in the founder's password
60
+ manager beside `APP_ENCRYPTION_KEY` (key + DB loss = working data lost — see below):
61
+
62
+ ```sh
63
+ openssl genrsa -out app-private.pem 2048
64
+ openssl rsa -in app-private.pem -pubout -out app-public.pem
65
+ ```
66
+
67
+ 2. **Register the public key** (Queek side; the backend stores it under a `kid`):
68
+
69
+ ```sh
70
+ php artisan app:register --public-key=./app-public.pem
71
+ ```
72
+
73
+ Queek prints the app secret (`QUEEK_APP_SECRET`, verifies the handoff) and the key id
74
+ (`APP_KEY_ID`, rides the JWT `kid` header). Configure the app with `APP_SLUG` (= `iss`),
75
+ `APP_KEY_ID`, and `APP_PRIVATE_KEY` (the PEM — never logged, never shipped to clients).
76
+
77
+ 3. **Acquire / cache / re-mint.** `acquireToken(installationId)` serves the cached token while
78
+ its expiry is more than 5 minutes away; otherwise it signs an RS256 app JWT
79
+ (`iss` = slug, `iat` = now − 60 s, `exp` = `iat` + 540 s, header `kid` — `node:crypto`
80
+ only) and `POST`s `access_tokens`, then persists the token encrypted with its expiry and
81
+ `kid` (one shared cache row per installation — restarts never burst). Concurrent callers
82
+ in one process share one in-flight mint; two containers minting at once is harmless by
83
+ design (Queek keeps coexisting tokens valid; a residual race self-heals via re-mint).
84
+ Merchant refusal table (rev 7): any 401, or 403 `api_key_revoked` / `api_key_expired` /
85
+ `invalid_client_key` → drop the token, re-mint once, retry once (a second refusal
86
+ propagates); 403 `app_token_revoked` → drop ALL cached tokens and halt minting (kill
87
+ switch / disabled app, no mint); every other 403 (scope, plan, mode) propagates to the
88
+ caller without a mint.
89
+
90
+ 4. **Failures follow the wire contract exactly** (`app-auth.ts` holds each code in one
91
+ constant, confirmed against the backend build):
92
+ - `401 invalid_client` — fatal for the app: loud log, minting stops, no retry loop.
93
+ - `403 app_token_revoked` (kill switch / disabled app) — drops ALL cached tokens, stops
94
+ minting, loud log. Minting resumes after the app is re-enabled + process restart
95
+ (or `resumeMinting()`).
96
+ - `404 app_installation_gone` — purges that installation locally.
97
+ - `409 app_installation_pending` — the installation is not active yet: retry later
98
+ with backoff, NEVER purge, NEVER halt. The row carries a persisted pending mark
99
+ (store column, schema v2 — survives restarts, cleared when the installation is
100
+ seen active or its mint succeeds).
101
+ - `429 resync_cooldown` (per-installation resync only) — the ≤1/hour rotation
102
+ cooldown: skipped + recorded, never retried. Any OTHER 429 is the per-app bucket
103
+ (`too_many_requests`): `Retry-After` honored with jitter, bounded retries.
104
+ - `5xx` — bounded exponential backoff.
105
+
106
+ 5. **Resync after DB loss or a long outage.** `resyncFromQueek({ apiBase, tokens, store })`
107
+ lists ACTIVE installations only (opaque keyset cursor — followed until `next_cursor`
108
+ is null, never interpreted) → requests a resync per installation (409 pending →
109
+ backoff + bounded retry, then skipped + recorded, never purged; 429 `resync_cooldown`
110
+ → skipped + recorded, never retried; any other 429 → `Retry-After` + jitter, bounded
111
+ retry; 404 `app_installation_gone` purges just that row and the run continues) → drops
112
+ cached tokens → purges local rows absent from Queek's list, EXCEPT rows carrying the
113
+ persisted pending mark — absence never purges a pending row, even across a restart.
114
+ The fresh `webhook_secret` + non-secret settings arrive over the
115
+ existing signed install channel — the install handler merges a redelivery for an
116
+ existing installation idempotently (secret + settings refresh, `installedAt` and the
117
+ cached token kept).
118
+
119
+ 6. **Kill switch behaviour.** One backend operation revokes every token of an app across
120
+ every store on the next request. The SDK side: cached tokens are dropped, minting halts
121
+ with a loud log, and every call fails closed until the app is re-enabled and the
122
+ process restarts (or resumes). Rotation without drama: add a `kid` (both verify) →
123
+ switch the app to it → wait one token TTL → remove the old `kid`.
124
+
125
+ ## Data rule + backups
126
+
127
+ The app database holds INSTALLATIONS ONLY (refs, token cache, webhook secrets, settings).
128
+ Business data lives in Queek. `resyncFromQueek` restores CONNECTIVITY with zero merchant
129
+ action — but app WORKING data (inbound tokens, order links, form tokens, app-side-only
130
+ settings) does NOT come back from resync: it needs the per-app `pg_dump` backup
131
+ (RPO ≤ 24 h) plus the `APP_ENCRYPTION_KEY` backup. Events missed beyond Queek's webhook
132
+ retries (~4 h) are gone; resync cannot backfill them. Full runbook: `docs/deploy.md` + O1.
133
+
134
+ ## API surface
135
+
136
+ - **app-auth** (`app-auth.ts`): `loadAppCredential` (`APP_SLUG`/`APP_KEY_ID`/`APP_PRIVATE_KEY`,
137
+ PEM validated at boot), `signAppJwt` (RS256, `iat` now − 60 s, `exp` window 540 s ≤ 600 s,
138
+ `kid` header), the wire-contract error codes in one place (`INVALID_CLIENT_CODE`,
139
+ `APP_TOKEN_REVOKED_CODE`, `APP_INSTALLATION_GONE_CODE`, `APP_INSTALLATION_PENDING_CODE`,
140
+ `RESYNC_COOLDOWN_CODE`, `TOO_MANY_REQUESTS_CODE`), `AppMintHaltedError`.
141
+ - **tokens** (`tokens.ts`): `createAppTokenProvider({ credential, store, … })` —
142
+ `acquireToken` (cache → sign → mint → persist), single-flight per installation, the exact
143
+ contract error mapping (409 pending → backoff + bounded retry, marked, never purged);
144
+ `createInstallationClient({ installationId, apiBase, tokens })` — the `QueekClient`
145
+ every app call uses (re-mint once + retry once on token refusals only).
146
+ - **resync** (`resync.ts`): `resyncFromQueek({ apiBase, tokens, store })` — list active
147
+ only (opaque keyset cursor) → resync each (409 pending → retry then skip + record;
148
+ 429 `resync_cooldown` → skip + record; other 429 → backoff + retry) → drop tokens →
149
+ purge absent except known-pending. Connectivity scope only.
150
+ - **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.
151
+ - **install handlers** (`install-handlers.ts`): `createInstallHandlers({ appSecret, store, onInstall?, onUninstall?, onSettings? })` — serves the signed install/uninstall/settings handoff. Defaults persist the installation (encrypted) in the store; a redelivered install for an existing installation merges idempotently (`saveResyncedInstallation`).
152
+ - **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. Types come from `openapi/merchant.json`, the committed snapshot of the live contract. Prefer `createInstallationClient` in apps.
153
+ - **webhooks** (`webhooks.ts`): `createWebhookHandler({ store, handlers })` — verifies each delivery against the installation's endpoint secret, dedupes on `webhook-id`, and dispatches `topic → handler` at most once.
154
+ - **store** (`store.ts`): `SqliteInstallationStore` (local/dev/test) and `PostgresInstallationStore`
155
+ (`pg`, pool max 2, advisory-locked schema + `schema_version` row so two containers boot
156
+ safely) — installations encrypted at rest (AES-GCM via `APP_ENCRYPTION_KEY`), plus the
157
+ persisted 409-pending mark (`pending` column, schema v2, migrated in place) and the
158
+ seen-webhook-id claim table behind dedupe. Pick with `createInstallationStore()`
159
+ (`DATABASE_URL` set → Postgres, else SQLite — which production REFUSES with a clear
160
+ message). Set once when the app is deployed; installs never change env: each install adds
161
+ a row to the app's database, with that store's token + webhook secret encrypted using this key.
162
+ - **background** (`background.ts`): `detach` plus `runInstallationCatchup` — jittered
163
+ per-installation cron (uniform 0–600 s start jitter, per-install error isolation,
164
+ concurrency ≤ pool size, honors 429 once per installation).
165
+ - **logger** (`logger.ts`): redacts `sk_`/`pk_`/`whsec_`/`Bearer` values, bare RS256 JWTs, and
166
+ PEM private-key blocks — the app JWT and private key can never reach logs.
167
+ - **session** (`session.ts`, server-only via `@usequeek/app-sdk/server`):
168
+ `verifySessionToken` — HS256 dashboard session tokens minted per installation
169
+ (`embsec_…` secret, raw UTF-8 key bytes, 20 s clock tolerance, slug audience
170
+ + api_base issuer, full installation binding). The secret never enters a
171
+ browser bundle: the main entry does not export the verifier. The install
172
+ and resync handoffs deliver `embed_secret` + `app_id`; the store keeps
173
+ them on the installation (`embedSecret` encrypted, `appId`), and
174
+ `sessionTokenInstallationId` reads the token's `installation_id` as an
175
+ unverified routing hint so a server can load that row before verifying.
176
+ - **frame** (`frame.ts`, browser-safe, no secret): `listenToDashboard`
177
+ (accepts only the exact dashboard origin) and `sendReady` (exact target
178
+ origin, never `"*"`).
179
+
180
+ ## Embedded merchant page (S4 stage 2)
181
+
182
+ The dashboard frames your app's granted merchant page in a
183
+ `sandbox="allow-scripts allow-forms"` iframe and delivers the session token
184
+ by postMessage. Serve the page with a `frame-ancestors` policy naming ONLY
185
+ your dashboard origin, for example:
186
+
187
+ ```http
188
+ Content-Security-Policy: frame-ancestors https://merchant.queek.com
189
+ ```
190
+
191
+ Replace the host with the dashboard origin you registered. `frame-ancestors`
192
+ is a docs-only control the dashboard cannot enforce for you: without it any
193
+ site may frame the page, and the token handshake (origin-bound) is your only
194
+ remaining gate. Verify every token server-side with `verifySessionToken`
195
+ before trusting calls that carry one.
196
+
197
+ ## Local development
198
+
199
+ 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`.
200
+
201
+ Postgres locally: any Postgres works — point `DATABASE_URL` at it and the pg suite runs
202
+ (CI always runs it via a service container); without `DATABASE_URL` those tests skip cleanly.
203
+
204
+ ## Registration
205
+
206
+ 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.
207
+
208
+ ## License
209
+
210
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The app credential (S1, SDK 0.2.0): ONE asymmetric RS256 key per app.
3
+ * Queek holds the PUBLIC keys (`apps.public_keys`, kid-capped); the app
4
+ * holds the PRIVATE key in `APP_PRIVATE_KEY` and signs short-lived app
5
+ * JWTs that mint per-installation tokens (`acquireToken()`).
6
+ *
7
+ * Wire contract (BINDING for B2 and S1): every call under `/api/v1/apps`
8
+ * carries `Authorization: Bearer <app JWT>` (RS256, header `kid`, claims
9
+ * `iss` = app slug, `iat`, `exp`, `exp − iat` ≤ 600 s). `node:crypto` only
10
+ * — no new dependency. The JWT and the private key are NEVER logged (the
11
+ * logger redacts `Bearer …` values; signers below never emit them).
12
+ */
13
+ /** 401 on an app-credential call: bad/expired JWT or unknown kid. Fatal for the app. */
14
+ export declare const INVALID_CLIENT_CODE = "invalid_client";
15
+ /**
16
+ * 403 on an app-credential call: the app is disabled or its token epoch was
17
+ * bumped by `app:revoke-tokens` (kill switch). Confirmed against B1
18
+ * (`ApiError::APP_TOKEN_REVOKED`, returned in both cases); matched in
19
+ * exactly one place (`isAppTokenRevoked`).
20
+ */
21
+ export declare const APP_TOKEN_REVOKED_CODE = "app_token_revoked";
22
+ /** 404 on an installation-scoped app call: purge that installation locally. */
23
+ export declare const APP_INSTALLATION_GONE_CODE = "app_installation_gone";
24
+ /**
25
+ * 409 on an installation-scoped app call: the installation is PENDING
26
+ * (not yet active). Retry later with backoff — NEVER purge, NEVER halt.
27
+ * `GET installations` lists active installations only, so a pending row is
28
+ * absent from the list: resync's purge-absent step must keep local rows
29
+ * the app knows (via a 409) are pending.
30
+ */
31
+ export declare const APP_INSTALLATION_PENDING_CODE = "app_installation_pending";
32
+ /**
33
+ * 429 on a per-installation resync: the ≤1/hour rotation COOLDOWN. Skip
34
+ * that installation (recorded, no retry loop). Any OTHER resync 429 is the
35
+ * per-app bucket (`too_many_requests`): back off on `Retry-After`
36
+ * (jittered) and retry — never recorded as a cooldown skip.
37
+ */
38
+ export declare const RESYNC_COOLDOWN_CODE = "resync_cooldown";
39
+ /** 429 everywhere: honor `Retry-After`, jittered. */
40
+ export declare const TOO_MANY_REQUESTS_CODE = "too_many_requests";
41
+ /** True when a Queek error code is the kill-switch/disabled refusal. */
42
+ export declare function isAppTokenRevoked(code: string | undefined): boolean;
43
+ /** True when a Queek error code is the fatal app-credential refusal. */
44
+ export declare function isInvalidClient(code: string | undefined): boolean;
45
+ /** True when a Queek error code means the installation is gone server-side. */
46
+ export declare function isInstallationGone(code: string | undefined): boolean;
47
+ /** True when a Queek error code means the installation is pending (409: retry later, never purge). */
48
+ export declare function isInstallationPending(code: string | undefined): boolean;
49
+ /** True when a resync 429 code is the per-installation rotation cooldown (skip, recorded). */
50
+ export declare function isResyncCooldown(code: string | undefined): boolean;
51
+ /**
52
+ * Merchant-API token-refusal codes (rev 7): a token in one of these states
53
+ * is dead — drop it, re-mint once, retry once. `api_key_revoked` covers the
54
+ * uninstalled store, the removed kid, and the replaced K-slot; the re-mint
55
+ * then answers 404 `app_installation_gone` (purge) or 401 `invalid_client`
56
+ * (halt) where applicable. Every OTHER merchant 403 (scope, plan, mode)
57
+ * propagates to the caller without a mint.
58
+ */
59
+ export declare const API_KEY_REVOKED_CODE = "api_key_revoked";
60
+ export declare const API_KEY_EXPIRED_CODE = "api_key_expired";
61
+ export declare const INVALID_CLIENT_KEY_CODE = "invalid_client_key";
62
+ /**
63
+ * True when a merchant-API refusal means the presented token is dead: any
64
+ * 401, or a 403 with a revoked/expired-key code. `app_token_revoked` is
65
+ * deliberately NOT here — that is the app-wide kill switch (drop all +
66
+ * halt), not a single dead token.
67
+ */
68
+ export declare function isTokenRefusal(status: number, code: string | undefined): boolean;
69
+ /** App-JWT `iat` is backdated this far (clock skew, GitHub's recommendation). */
70
+ export declare const APP_JWT_SKEW_SECONDS = 60;
71
+ /** App-JWT lifetime: `exp = iat + 540`, so `exp − iat` (540 s) stays ≤ the 600 s wire bound. */
72
+ export declare const APP_JWT_TTL_SECONDS = 540;
73
+ /** A cached installation token counts as valid only while its expiry is more than this far away. */
74
+ export declare const TOKEN_VALIDITY_SKEW_SECONDS = 300;
75
+ /** Mint attempts per `acquireToken` before surfacing a 429/5xx (1 initial + retries). */
76
+ export declare const MAX_MINT_ATTEMPTS = 3;
77
+ /** 5xx backoff base (doubles per attempt); every sleep also carries uniform jitter (see below). */
78
+ export declare const MINT_BACKOFF_BASE_MS = 250;
79
+ /** 5xx backoff ceiling (a 429 `Retry-After` may exceed it — the server's word wins). */
80
+ export declare const MINT_BACKOFF_MAX_MS = 5000;
81
+ /** Uniform jitter added to every mint/resync backoff sleep (0–1000 ms). */
82
+ export declare const RETRY_JITTER_MAX_MS = 1000;
83
+ /** Thrown when `APP_PRIVATE_KEY`/`APP_KEY_ID`/slug fail validation — before any fetch. */
84
+ export declare class InvalidAppCredentialError extends Error {
85
+ readonly code = "invalid_app_credential";
86
+ constructor(message: string);
87
+ }
88
+ /**
89
+ * Thrown (and cached in-process) when Queek refuses the app itself:
90
+ * `invalid_client` (bad/unknown key — fatal until the key is fixed) or the
91
+ * kill-switch 403 (disabled/epoch-revoked — fatal until the app is
92
+ * re-enabled). Minting STOPS: no retry loop. Clears on process restart;
93
+ * call `resumeMinting()` to clear it sooner (tests, or an operator who
94
+ * just re-enabled the app).
95
+ */
96
+ export declare class AppMintHaltedError extends Error {
97
+ readonly code = "app_mint_halted";
98
+ readonly reason: typeof INVALID_CLIENT_CODE | typeof APP_TOKEN_REVOKED_CODE;
99
+ constructor(reason: AppMintHaltedError["reason"], detail: string);
100
+ }
101
+ /** Thrown when no installation row exists locally for an id. */
102
+ export declare class UnknownInstallationError extends Error {
103
+ readonly code = "unknown_installation";
104
+ constructor(installationId: string);
105
+ }
106
+ export interface AppCredential {
107
+ /** `iss`: the app slug (as registered with `app:register`). */
108
+ appSlug: string;
109
+ /** `kid`: which of the app's public keys Queek verifies against (`APP_KEY_ID`). */
110
+ keyId: string;
111
+ /** PEM-encoded PKCS#8 / PKCS#1 RSA private key (`APP_PRIVATE_KEY`). Memory only, never logged. */
112
+ privateKeyPem: string;
113
+ }
114
+ export interface AppCredentialOptions {
115
+ appSlug?: string;
116
+ keyId?: string;
117
+ privateKeyPem?: string;
118
+ env?: NodeJS.ProcessEnv;
119
+ }
120
+ /**
121
+ * Load the app credential: explicit options win, otherwise `APP_SLUG`,
122
+ * `APP_KEY_ID`, `APP_PRIVATE_KEY` from env. The PEM must parse as an RSA
123
+ * private key here, once, so a broken key fails at boot — never mid-mint.
124
+ * Throws `InvalidAppCredentialError` before any fetch.
125
+ */
126
+ export declare function loadAppCredential(options?: AppCredentialOptions): AppCredential;
127
+ export interface SignAppJwtOptions {
128
+ credential: AppCredential;
129
+ /** Seconds since epoch. Defaults to `Date.now() / 1000` (injectable in tests). */
130
+ nowSeconds?: number;
131
+ }
132
+ /**
133
+ * Sign an app JWT (RS256, `node:crypto` only): header `{alg RS256, kid}`,
134
+ * claims `{iss = app slug, iat = now − 60 s, exp = iat + 540 s}` — the
135
+ * `exp − iat` window (540 s) stays under the 600 s wire bound. Returns the
136
+ * compact JWT; NEVER logs it or the private key.
137
+ */
138
+ export declare function signAppJwt(options: SignAppJwtOptions): string;
139
+ //# sourceMappingURL=app-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-auth.d.ts","sourceRoot":"","sources":["../src/app-auth.ts"],"names":[],"mappings":"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"}