@usequeek/app-sdk 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +339 -330
- package/dist/app-auth.d.ts +15 -17
- package/dist/app-auth.js +20 -23
- package/dist/auth-fetch.d.ts +14 -5
- package/dist/auth-fetch.js +15 -6
- package/dist/background.d.ts +10 -2
- package/dist/background.js +9 -1
- package/dist/browser.d.ts +2 -3
- package/dist/browser.js +2 -3
- package/dist/client.d.ts +138 -16
- package/dist/client.js +200 -43
- package/dist/collected.d.ts +13 -15
- package/dist/collected.js +4 -5
- package/dist/crypto.d.ts +11 -1
- package/dist/crypto.js +25 -10
- package/dist/delivery.d.ts +22 -9
- package/dist/delivery.js +26 -21
- package/dist/frame.d.ts +11 -12
- package/dist/frame.js +11 -12
- package/dist/handoff.d.ts +35 -39
- package/dist/handoff.js +9 -10
- package/dist/hono.d.ts +3 -4
- package/dist/hono.js +3 -4
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/install-handlers.d.ts +28 -9
- package/dist/install-handlers.js +48 -18
- package/dist/logger.d.ts +7 -1
- package/dist/logger.js +8 -2
- package/dist/merchant-schema.d.ts +15678 -6236
- package/dist/merchant-schema.js +0 -1
- package/dist/proxy.d.ts +21 -24
- package/dist/proxy.js +20 -22
- package/dist/react.d.ts +10 -2
- package/dist/react.js +11 -3
- package/dist/resync.d.ts +15 -5
- package/dist/resync.js +22 -20
- package/dist/scopes.d.ts +36 -27
- package/dist/scopes.js +37 -40
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/session.d.ts +35 -17
- package/dist/session.js +32 -15
- package/dist/signatures.d.ts +31 -5
- package/dist/signatures.js +36 -9
- package/dist/store.d.ts +26 -26
- package/dist/store.js +16 -20
- package/dist/theme.d.ts +1 -2
- package/dist/theme.js +1 -2
- package/dist/tokens.d.ts +18 -10
- package/dist/tokens.js +32 -57
- package/dist/webhooks.d.ts +30 -6
- package/dist/webhooks.js +27 -10
- package/package.json +21 -4
- 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/dist/scopes.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { isInstallationGone, UnknownInstallationError } from "./app-auth.js";
|
|
2
|
-
import { newIdempotencyKey, QueekApiError, queekApiErrorFromResponse } from "./client.js";
|
|
2
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, newIdempotencyKey, QueekApiError, queekApiErrorFromResponse, requestSignal, transportError, tryParseJson, } from "./client.js";
|
|
3
3
|
import { isAllowedOpenTarget } from "./frame.js";
|
|
4
4
|
import { installationScopesEqual } from "./install-handlers.js";
|
|
5
5
|
import { resolveAppApiBase } from "./tokens.js";
|
|
6
6
|
/**
|
|
7
|
-
* Optional scopes
|
|
8
|
-
* parity — symbol names cited only).
|
|
7
|
+
* Optional scopes: query, request and revoke.
|
|
9
8
|
*
|
|
10
9
|
* - `queryScopes`: the cached effective grant split against the app's own
|
|
11
10
|
* declared-optional list. Store-read only, never a fetch: the app-credential
|
|
@@ -17,25 +16,24 @@ import { resolveAppApiBase } from "./tokens.js";
|
|
|
17
16
|
* consent — it hands the app a link shaped like the bridge
|
|
18
17
|
* `open`/`navigate` targets (dashboard-relative path, or an absolute URL
|
|
19
18
|
* under a given dashboard origin) for the app to open via `sendOpen` or
|
|
20
|
-
* redirect to.
|
|
21
|
-
*
|
|
19
|
+
* redirect to. Opening it requires a dashboard that supports the consent
|
|
20
|
+
* screen, which honours this link shape.
|
|
22
21
|
* - `revokeScopes`: `POST
|
|
23
22
|
* /api/v1/apps/installations/{installation}/scopes/revoke` with the app
|
|
24
|
-
* JWT (same credential as mint/resync —
|
|
25
|
-
*
|
|
26
|
-
* `RevokeAppScopesRequest`); an `Idempotency-Key` rides the write for
|
|
23
|
+
* JWT (same credential as mint/resync — not the installation token).
|
|
24
|
+
* Body `{scopes[]}` (1–50); an `Idempotency-Key` rides the write for
|
|
27
25
|
* discipline (generated when the caller does not supply one — the client
|
|
28
26
|
* write discipline), though the route is naturally idempotent: revoking
|
|
29
27
|
* a never-granted optional scope is 200 with no change. Required scopes
|
|
30
28
|
* refuse 422 `app_scope_required` (surfaced as `AppScopeRequiredError`).
|
|
31
29
|
* On 200 the cached grant is refreshed from the response and the cached
|
|
32
30
|
* token dropped only when the grant moved (same compare-and-clear as the
|
|
33
|
-
* scopes_update handler —
|
|
34
|
-
*
|
|
31
|
+
* scopes_update handler — Queek rewrites live token rows in place, so
|
|
32
|
+
* the drop is a backstop, not the mechanism).
|
|
35
33
|
*
|
|
36
34
|
* Types here are hand-written: the revoke endpoint and the scopes_update
|
|
37
|
-
* handoff live on the app-credential surface, which the
|
|
38
|
-
* `openapi/merchant.json` does not cover (types are generated app-side by
|
|
35
|
+
* handoff live on the app-credential surface, which the bundled
|
|
36
|
+
* `openapi/merchant.json` snapshot does not cover (types are generated app-side by
|
|
39
37
|
* `queek app codegen`; the merchant spec file is never hand-edited).
|
|
40
38
|
*/
|
|
41
39
|
/** 422 on the revoke endpoint: required scopes are never revocable (uninstall is the remedy). */
|
|
@@ -48,8 +46,8 @@ export function isAppScopeRequired(status, code) {
|
|
|
48
46
|
}
|
|
49
47
|
/**
|
|
50
48
|
* A required-scope refusal, typed so callers switch on the class (or
|
|
51
|
-
* `code`), never on `message`. Carries the offending scopes when
|
|
52
|
-
*
|
|
49
|
+
* `code`), never on `message`. Carries the offending scopes when Queek
|
|
50
|
+
* named them.
|
|
53
51
|
*/
|
|
54
52
|
export class AppScopeRequiredError extends QueekApiError {
|
|
55
53
|
scopes;
|
|
@@ -79,13 +77,13 @@ export class InvalidScopesError extends Error {
|
|
|
79
77
|
this.name = "InvalidScopesError";
|
|
80
78
|
}
|
|
81
79
|
}
|
|
82
|
-
/**
|
|
80
|
+
/** Bounds of the revoke request (1–50 scopes), mirrored client-side. */
|
|
83
81
|
export const MAX_SCOPE_LIST = 50;
|
|
84
82
|
export const MAX_SCOPE_LENGTH = 120;
|
|
85
83
|
/**
|
|
86
84
|
* Validate + normalise a scope list (trim, drop empties, dedupe keeping
|
|
87
|
-
* first order). Mirrors the
|
|
88
|
-
*
|
|
85
|
+
* first order). Mirrors the revoke endpoint's rules (a required array of
|
|
86
|
+
* 1–50 string items) so a malformed call fails before any fetch.
|
|
89
87
|
*/
|
|
90
88
|
export function normaliseScopeList(scopes) {
|
|
91
89
|
if (!Array.isArray(scopes)) {
|
|
@@ -134,8 +132,7 @@ export function revokeScopesPath(installationId) {
|
|
|
134
132
|
* under `dashboardOrigin` (refused exactly like `sendOpen` refuses a bad
|
|
135
133
|
* target).
|
|
136
134
|
*
|
|
137
|
-
* Link shape (the
|
|
138
|
-
* the only query route the dashboard `AppsPage` serves):
|
|
135
|
+
* Link shape (the contract the dashboard's consent screen honours):
|
|
139
136
|
* `/apps?app={slug}&view=scopes&scopes={a},{b}` (each scope
|
|
140
137
|
* URL-encoded, comma-separated). The link carries the slug + the scope
|
|
141
138
|
* list only: the dashboard resolves the installation from the signed-in
|
|
@@ -164,6 +161,20 @@ export function buildScopeRequestLink(options) {
|
|
|
164
161
|
}
|
|
165
162
|
return link;
|
|
166
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Build the optional-scopes session for one installation: read the cached
|
|
166
|
+
* grant, build the dashboard consent link, and revoke optional scopes.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* const scopes = createInstallationScopesClient({
|
|
170
|
+
* installationId,
|
|
171
|
+
* store,
|
|
172
|
+
* signJwt: () => tokens.signJwt(),
|
|
173
|
+
* appSlug: "my-app",
|
|
174
|
+
* optionalScopes: ["merchant-orders-read"],
|
|
175
|
+
* });
|
|
176
|
+
* const { granted } = await scopes.queryScopes();
|
|
177
|
+
*/
|
|
167
178
|
export function createInstallationScopesClient(options) {
|
|
168
179
|
if (!options.installationId || options.installationId.trim() === "") {
|
|
169
180
|
throw new InvalidScopesError("An installation id is required.");
|
|
@@ -193,7 +204,7 @@ export function createInstallationScopesClient(options) {
|
|
|
193
204
|
* The dashboard deep link that opens the merchant's consent screen in
|
|
194
205
|
* the dashboard. Pure (no store, no network): the app opens it via
|
|
195
206
|
* `sendOpen` or a redirect and the merchant consents in the dashboard
|
|
196
|
-
* (
|
|
207
|
+
* (requires a dashboard that supports the consent screen). Shaped like the bridge `open`
|
|
197
208
|
* targets — dashboard-relative, or absolute under the dashboard origin
|
|
198
209
|
* when one is configured.
|
|
199
210
|
*/
|
|
@@ -209,14 +220,13 @@ export function createInstallationScopesClient(options) {
|
|
|
209
220
|
});
|
|
210
221
|
}
|
|
211
222
|
/**
|
|
212
|
-
* App-initiated revoke of optional scopes
|
|
213
|
-
* `AppInstallationController::revokeScopes`): the app JWT signs the call
|
|
223
|
+
* App-initiated revoke of optional scopes: the app JWT signs the call
|
|
214
224
|
* (same credential as mint/resync), an `Idempotency-Key` rides the write
|
|
215
225
|
* for discipline only (the route is naturally idempotent), and on 200
|
|
216
226
|
* the cached grant is refreshed from the response with the cached token
|
|
217
227
|
* dropped only when the grant moved (same compare-and-clear as the
|
|
218
|
-
* scopes_update handler — backstop, since
|
|
219
|
-
*
|
|
228
|
+
* scopes_update handler — a backstop, since Queek rewrites live token
|
|
229
|
+
* rows in place). A required scope refuses 422
|
|
220
230
|
* `app_scope_required` as `AppScopeRequiredError`; a gone installation
|
|
221
231
|
* purges the local row and rethrows; anything else propagates untouched.
|
|
222
232
|
* A 401 never halts here — the mint path owns that mapping and halts on
|
|
@@ -243,15 +253,11 @@ export function createInstallationScopesClient(options) {
|
|
|
243
253
|
method: "POST",
|
|
244
254
|
headers,
|
|
245
255
|
body: JSON.stringify({ scopes: requested }),
|
|
246
|
-
signal: revokeOptions.signal,
|
|
256
|
+
signal: requestSignal(revokeOptions.signal, DEFAULT_REQUEST_TIMEOUT_MS),
|
|
247
257
|
});
|
|
248
258
|
}
|
|
249
259
|
catch (cause) {
|
|
250
|
-
throw
|
|
251
|
-
status: 0,
|
|
252
|
-
code: "network_error",
|
|
253
|
-
message: cause instanceof Error ? `Could not reach Queek: ${cause.message}` : "Could not reach Queek.",
|
|
254
|
-
});
|
|
260
|
+
throw transportError(cause, revokeOptions.signal);
|
|
255
261
|
}
|
|
256
262
|
const text = await response.text();
|
|
257
263
|
const parsed = text === "" ? null : tryParseJson(text);
|
|
@@ -282,16 +288,7 @@ function parseRevokeBody(body) {
|
|
|
282
288
|
const record = (typeof body === "object" && body !== null ? body : {});
|
|
283
289
|
const scopes = record.scopes;
|
|
284
290
|
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) {
|
|
285
|
-
throw new Error("Malformed revoke response: expected {scopes: string[]}.");
|
|
291
|
+
throw new Error("Malformed revoke response: expected {scopes: string[]}. The grant was not refreshed; call queryScopes() after Queek delivers the scopes update, or retry.");
|
|
286
292
|
}
|
|
287
293
|
return [...scopes];
|
|
288
294
|
}
|
|
289
|
-
function tryParseJson(text) {
|
|
290
|
-
try {
|
|
291
|
-
return JSON.parse(text);
|
|
292
|
-
}
|
|
293
|
-
catch {
|
|
294
|
-
return { message: text.slice(0, 500) };
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
//# sourceMappingURL=scopes.js.map
|
package/dist/server.d.ts
CHANGED
|
@@ -6,4 +6,3 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export type { SessionTokenBinding, SessionTokenClaims, SessionTokenFailure, VerifySessionTokenOptions, } from "./session.js";
|
|
8
8
|
export { EMBED_SECRET_PREFIX, SESSION_CLOCK_TOLERANCE_SECONDS, sessionTokenInstallationId, verifySessionToken, verifySessionTokenDetailed, } from "./session.js";
|
|
9
|
-
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
CHANGED
package/dist/session.d.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dashboard session tokens: short-lived HS256 JWTs
|
|
3
|
-
* installation for the framed merchant page. The app
|
|
4
|
-
* with the per-installation `embsec_…` secret before trusting any
|
|
5
|
-
* that carries one.
|
|
2
|
+
* Dashboard session tokens: short-lived HS256 JWTs Queek mints per
|
|
3
|
+
* installation for the framed merchant page. The app's server verifies
|
|
4
|
+
* them with the per-installation `embsec_…` secret before trusting any
|
|
5
|
+
* call that carries one.
|
|
6
6
|
*
|
|
7
|
-
* ONE token type
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* is the whole check.
|
|
7
|
+
* ONE token type: the dashboard puts the same token in the first-load URL
|
|
8
|
+
* param (`queek_token`, stripped on arrival) and answers bridge `ready`
|
|
9
|
+
* requests with it, so the app's exchange endpoint verifies first-load and
|
|
10
|
+
* refresh tokens with this one verifier. No purpose split: extra claims the
|
|
11
|
+
* mint may carry are ignored, never gated — identity binding
|
|
12
|
+
* (installation, audience, issuer, expiry) is the whole check.
|
|
14
13
|
*
|
|
15
14
|
* Server-only: the secret must never enter a browser bundle, so this module
|
|
16
15
|
* ships behind the `./server` export, not the main entry. Style mirrors
|
|
@@ -20,7 +19,7 @@
|
|
|
20
19
|
export declare const EMBED_SECRET_PREFIX = "embsec_";
|
|
21
20
|
/** The only JWS algorithm the dashboard mints (HS256 over the embsec_ secret). */
|
|
22
21
|
export declare const SESSION_TOKEN_ALG = "HS256";
|
|
23
|
-
/**
|
|
22
|
+
/** Clock skew the verifier tolerates, in seconds (matches the mint side). */
|
|
24
23
|
export declare const SESSION_CLOCK_TOLERANCE_SECONDS = 20;
|
|
25
24
|
export type SessionTokenFailure = "missing_token" | "missing_secret" | "wrong_algorithm" | "invalid_signature" | "expired" | "not_yet_valid" | "wrong_audience" | "wrong_issuer" | "binding_mismatch";
|
|
26
25
|
export interface SessionTokenClaims {
|
|
@@ -46,20 +45,40 @@ export interface VerifySessionTokenOptions {
|
|
|
46
45
|
audience: string;
|
|
47
46
|
/**
|
|
48
47
|
* Expected issuer: the handoff `api_base` VERBATIM
|
|
49
|
-
* (`installation.apiBase` — it equals the bare `app.url`
|
|
50
|
-
*
|
|
48
|
+
* (`installation.apiBase` — it equals the bare `app.url` Queek signs as
|
|
49
|
+
* `iss`). Never a prefix or a suffix of it.
|
|
51
50
|
*/
|
|
52
51
|
issuer: string;
|
|
53
52
|
/**
|
|
54
|
-
* The installation row the token must belong to (the app
|
|
53
|
+
* The installation row the token must belong to (the app server's own
|
|
55
54
|
* record). Every field is compared — a token minted for another
|
|
56
55
|
* installation fails closed even when its signature and audience check
|
|
57
|
-
* out
|
|
56
|
+
* out.
|
|
58
57
|
*/
|
|
59
58
|
expected: SessionTokenBinding;
|
|
60
59
|
clockToleranceSeconds?: number;
|
|
61
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* True when `token` is a valid dashboard session token for the installation
|
|
63
|
+
* in `options.expected`. See {@link verifySessionTokenDetailed} for the
|
|
64
|
+
* reason a token was refused.
|
|
65
|
+
*/
|
|
62
66
|
export declare function verifySessionToken(token: string, options: VerifySessionTokenOptions): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Verify a dashboard session token: algorithm pin, signature, audience,
|
|
69
|
+
* issuer, expiry (20 s clock tolerance), then the installation binding.
|
|
70
|
+
* Returns the claims on success and a stable `reason` on failure. Answer 401
|
|
71
|
+
* for every failure.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* const checked = await verifySessionTokenDetailed(token, {
|
|
75
|
+
* secret: installation.embedSecret ?? "",
|
|
76
|
+
* audience: "my-app",
|
|
77
|
+
* issuer: installation.apiBase,
|
|
78
|
+
* expected: { installationId, vendorId, appSlug: "my-app", appId },
|
|
79
|
+
* });
|
|
80
|
+
* if (!checked.ok) return new Response(checked.reason, { status: 401 });
|
|
81
|
+
*/
|
|
63
82
|
export declare function verifySessionTokenDetailed(token: string, options: VerifySessionTokenOptions): Promise<{
|
|
64
83
|
ok: true;
|
|
65
84
|
claims: SessionTokenClaims;
|
|
@@ -75,4 +94,3 @@ export declare function verifySessionTokenDetailed(token: string, options: Verif
|
|
|
75
94
|
* Null for anything that is not a decodable JWT carrying the claim.
|
|
76
95
|
*/
|
|
77
96
|
export declare function sessionTokenInstallationId(token: string): string | null;
|
|
78
|
-
//# sourceMappingURL=session.d.ts.map
|
package/dist/session.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
|
|
2
2
|
/**
|
|
3
|
-
* Dashboard session tokens: short-lived HS256 JWTs
|
|
4
|
-
* installation for the framed merchant page. The app
|
|
5
|
-
* with the per-installation `embsec_…` secret before trusting any
|
|
6
|
-
* that carries one.
|
|
3
|
+
* Dashboard session tokens: short-lived HS256 JWTs Queek mints per
|
|
4
|
+
* installation for the framed merchant page. The app's server verifies
|
|
5
|
+
* them with the per-installation `embsec_…` secret before trusting any
|
|
6
|
+
* call that carries one.
|
|
7
7
|
*
|
|
8
|
-
* ONE token type
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* is the whole check.
|
|
8
|
+
* ONE token type: the dashboard puts the same token in the first-load URL
|
|
9
|
+
* param (`queek_token`, stripped on arrival) and answers bridge `ready`
|
|
10
|
+
* requests with it, so the app's exchange endpoint verifies first-load and
|
|
11
|
+
* refresh tokens with this one verifier. No purpose split: extra claims the
|
|
12
|
+
* mint may carry are ignored, never gated — identity binding
|
|
13
|
+
* (installation, audience, issuer, expiry) is the whole check.
|
|
15
14
|
*
|
|
16
15
|
* Server-only: the secret must never enter a browser bundle, so this module
|
|
17
16
|
* ships behind the `./server` export, not the main entry. Style mirrors
|
|
@@ -21,12 +20,32 @@ import { decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
|
|
|
21
20
|
export const EMBED_SECRET_PREFIX = "embsec_";
|
|
22
21
|
/** The only JWS algorithm the dashboard mints (HS256 over the embsec_ secret). */
|
|
23
22
|
export const SESSION_TOKEN_ALG = "HS256";
|
|
24
|
-
/**
|
|
23
|
+
/** Clock skew the verifier tolerates, in seconds (matches the mint side). */
|
|
25
24
|
export const SESSION_CLOCK_TOLERANCE_SECONDS = 20;
|
|
25
|
+
/**
|
|
26
|
+
* True when `token` is a valid dashboard session token for the installation
|
|
27
|
+
* in `options.expected`. See {@link verifySessionTokenDetailed} for the
|
|
28
|
+
* reason a token was refused.
|
|
29
|
+
*/
|
|
26
30
|
export async function verifySessionToken(token, options) {
|
|
27
31
|
const result = await verifySessionTokenDetailed(token, options);
|
|
28
32
|
return result.ok;
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Verify a dashboard session token: algorithm pin, signature, audience,
|
|
36
|
+
* issuer, expiry (20 s clock tolerance), then the installation binding.
|
|
37
|
+
* Returns the claims on success and a stable `reason` on failure. Answer 401
|
|
38
|
+
* for every failure.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* const checked = await verifySessionTokenDetailed(token, {
|
|
42
|
+
* secret: installation.embedSecret ?? "",
|
|
43
|
+
* audience: "my-app",
|
|
44
|
+
* issuer: installation.apiBase,
|
|
45
|
+
* expected: { installationId, vendorId, appSlug: "my-app", appId },
|
|
46
|
+
* });
|
|
47
|
+
* if (!checked.ok) return new Response(checked.reason, { status: 401 });
|
|
48
|
+
*/
|
|
30
49
|
export async function verifySessionTokenDetailed(token, options) {
|
|
31
50
|
const envelope = await verifyTokenEnvelope(token, options);
|
|
32
51
|
if (!envelope.ok)
|
|
@@ -132,8 +151,7 @@ function readSessionClaims(payload) {
|
|
|
132
151
|
return { installationId, vendorId, appSlug, appId, subject, sessionId, issuedAt, expiresAt };
|
|
133
152
|
}
|
|
134
153
|
function classifyJoseError(error) {
|
|
135
|
-
// Exact jose v6
|
|
136
|
-
// the `claim` field probed, not guessed): claim failures carry code
|
|
154
|
+
// Exact jose v6 error shapes (jose@6.2.12): claim failures carry code
|
|
137
155
|
// ERR_JWT_CLAIM_VALIDATION_FAILED and name the claim; expiry also
|
|
138
156
|
// surfaces as JWTExpired / ERR_JWT_EXPIRED; algorithm refusal is
|
|
139
157
|
// JOSEAlgNotAllowed / ERR_JOSE_ALG_NOT_ALLOWED (unreachable in practice —
|
|
@@ -171,4 +189,3 @@ function classifyJoseError(error) {
|
|
|
171
189
|
}
|
|
172
190
|
return "invalid_signature";
|
|
173
191
|
}
|
|
174
|
-
//# sourceMappingURL=session.js.map
|
package/dist/signatures.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Standard Webhooks verification, byte-for-byte compatible with
|
|
3
|
-
*
|
|
2
|
+
* Standard Webhooks verification, byte-for-byte compatible with the way
|
|
3
|
+
* Queek signs deliveries
|
|
4
|
+
* (https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md).
|
|
4
5
|
*
|
|
5
6
|
* Signed content: `{id}.{timestamp}.{body}` where `body` is the RAW request
|
|
6
7
|
* bytes. Re-serialising JSON before verifying breaks the MAC — verify first,
|
|
@@ -8,7 +9,7 @@
|
|
|
8
9
|
*
|
|
9
10
|
* The MAC key is the DECODED bytes of the `whsec_<base64>` secret, not the
|
|
10
11
|
* printable string. A secret without the prefix (or with undecodable base64)
|
|
11
|
-
* falls back to the raw string, exactly like
|
|
12
|
+
* falls back to the raw string, exactly like Queek's signer.
|
|
12
13
|
*
|
|
13
14
|
* Header names: `webhook-id`, `webhook-timestamp`, `webhook-signature`.
|
|
14
15
|
* The signature header may carry several space-delimited `v1,<base64>`
|
|
@@ -40,16 +41,41 @@ export interface VerifyOptions {
|
|
|
40
41
|
/** Skip the freshness check (NOT recommended; exposed for replaying stored fixtures). */
|
|
41
42
|
skipFreshnessCheck?: boolean;
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* True when `timestamp` is a plain unix-seconds integer within `maxSkewSeconds`
|
|
46
|
+
* of `nowSeconds`, in either direction. Anything else (`""`, `"1e3"`,
|
|
47
|
+
* `"0x10"`, `"12.5"`) is stale: the signed header must be what Queek sent.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isFreshTimestamp(timestamp: string, nowSeconds: number, maxSkewSeconds: number): boolean;
|
|
50
|
+
/** The decoded MAC key for a `whsec_` secret (a secret that is not valid base64 is used as raw text). */
|
|
43
51
|
export declare function secretKeyBytes(secret: string): Buffer;
|
|
44
|
-
/** One `v1,<base64>` signature for one secret
|
|
52
|
+
/** One `v1,<base64>` signature for one secret, in the format Queek sends. */
|
|
45
53
|
export declare function signQueekPayload(eventId: string, timestamp: number | string, body: string, secret: string): string;
|
|
46
54
|
/** Failure reason when verification rejects a delivery. */
|
|
47
55
|
export type SignatureFailure = "missing_headers" | "stale_timestamp" | "signature_mismatch";
|
|
56
|
+
/**
|
|
57
|
+
* True when one of the signatures in the header matches the body, and the
|
|
58
|
+
* timestamp is within the skew window. The compare is constant-time and
|
|
59
|
+
* covers every signature in the header (a secret rotation sends two).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const ok = verifyQueekSignature({
|
|
63
|
+
* id: request.headers.get("webhook-id") ?? "",
|
|
64
|
+
* timestamp: request.headers.get("webhook-timestamp") ?? "",
|
|
65
|
+
* signatureHeader: request.headers.get("webhook-signature") ?? "",
|
|
66
|
+
* body: await request.text(), // the raw text, before any JSON.parse
|
|
67
|
+
* secret: installation.webhookSecret,
|
|
68
|
+
* });
|
|
69
|
+
*/
|
|
48
70
|
export declare function verifyQueekSignature(input: SignatureInput, options?: VerifyOptions): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Like {@link verifyQueekSignature}, but says why a delivery was rejected:
|
|
73
|
+
* `missing_headers`, `stale_timestamp` or `signature_mismatch`. Log the
|
|
74
|
+
* reason; answer 401 either way.
|
|
75
|
+
*/
|
|
49
76
|
export declare function verifyQueekSignatureDetailed(input: SignatureInput, options?: VerifyOptions): {
|
|
50
77
|
ok: true;
|
|
51
78
|
} | {
|
|
52
79
|
ok: false;
|
|
53
80
|
reason: SignatureFailure;
|
|
54
81
|
};
|
|
55
|
-
//# sourceMappingURL=signatures.d.ts.map
|
package/dist/signatures.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
/**
|
|
3
|
-
* Standard Webhooks verification, byte-for-byte compatible with
|
|
4
|
-
*
|
|
3
|
+
* Standard Webhooks verification, byte-for-byte compatible with the way
|
|
4
|
+
* Queek signs deliveries
|
|
5
|
+
* (https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md).
|
|
5
6
|
*
|
|
6
7
|
* Signed content: `{id}.{timestamp}.{body}` where `body` is the RAW request
|
|
7
8
|
* bytes. Re-serialising JSON before verifying breaks the MAC — verify first,
|
|
@@ -9,7 +10,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
9
10
|
*
|
|
10
11
|
* The MAC key is the DECODED bytes of the `whsec_<base64>` secret, not the
|
|
11
12
|
* printable string. A secret without the prefix (or with undecodable base64)
|
|
12
|
-
* falls back to the raw string, exactly like
|
|
13
|
+
* falls back to the raw string, exactly like Queek's signer.
|
|
13
14
|
*
|
|
14
15
|
* Header names: `webhook-id`, `webhook-timestamp`, `webhook-signature`.
|
|
15
16
|
* The signature header may carry several space-delimited `v1,<base64>`
|
|
@@ -21,12 +22,21 @@ export const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
|
21
22
|
export const SECRET_PREFIX = "whsec_";
|
|
22
23
|
/** Maximum age of a delivery before it is rejected as stale (5 minutes, the Standard Webhooks default). */
|
|
23
24
|
export const MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60;
|
|
25
|
+
/**
|
|
26
|
+
* True when `timestamp` is a plain unix-seconds integer within `maxSkewSeconds`
|
|
27
|
+
* of `nowSeconds`, in either direction. Anything else (`""`, `"1e3"`,
|
|
28
|
+
* `"0x10"`, `"12.5"`) is stale: the signed header must be what Queek sent.
|
|
29
|
+
*/
|
|
30
|
+
export function isFreshTimestamp(timestamp, nowSeconds, maxSkewSeconds) {
|
|
31
|
+
return /^\d{1,15}$/.test(timestamp) && Math.abs(nowSeconds - Number(timestamp)) <= maxSkewSeconds;
|
|
32
|
+
}
|
|
33
|
+
/** The decoded MAC key for a `whsec_` secret (a secret that is not valid base64 is used as raw text). */
|
|
24
34
|
export function secretKeyBytes(secret) {
|
|
25
35
|
const encoded = secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret;
|
|
26
36
|
try {
|
|
27
37
|
const decoded = Buffer.from(encoded, "base64");
|
|
28
|
-
// Round-trip guard:
|
|
29
|
-
//
|
|
38
|
+
// Round-trip guard: Queek's signer falls back to the raw secret string
|
|
39
|
+
// when the input is not strictly valid base64.
|
|
30
40
|
if (decoded.length > 0 && decoded.toString("base64").replace(/=+$/, "") === encoded.replace(/=+$/, "")) {
|
|
31
41
|
return decoded;
|
|
32
42
|
}
|
|
@@ -36,26 +46,44 @@ export function secretKeyBytes(secret) {
|
|
|
36
46
|
return Buffer.from(secret, "utf8");
|
|
37
47
|
}
|
|
38
48
|
}
|
|
39
|
-
/** One `v1,<base64>` signature for one secret
|
|
49
|
+
/** One `v1,<base64>` signature for one secret, in the format Queek sends. */
|
|
40
50
|
export function signQueekPayload(eventId, timestamp, body, secret) {
|
|
41
51
|
const mac = createHmac("sha256", secretKeyBytes(secret))
|
|
42
52
|
.update(`${eventId}.${timestamp}.${body}`, "utf8")
|
|
43
53
|
.digest();
|
|
44
54
|
return `v1,${mac.toString("base64")}`;
|
|
45
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* True when one of the signatures in the header matches the body, and the
|
|
58
|
+
* timestamp is within the skew window. The compare is constant-time and
|
|
59
|
+
* covers every signature in the header (a secret rotation sends two).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const ok = verifyQueekSignature({
|
|
63
|
+
* id: request.headers.get("webhook-id") ?? "",
|
|
64
|
+
* timestamp: request.headers.get("webhook-timestamp") ?? "",
|
|
65
|
+
* signatureHeader: request.headers.get("webhook-signature") ?? "",
|
|
66
|
+
* body: await request.text(), // the raw text, before any JSON.parse
|
|
67
|
+
* secret: installation.webhookSecret,
|
|
68
|
+
* });
|
|
69
|
+
*/
|
|
46
70
|
export function verifyQueekSignature(input, options = {}) {
|
|
47
71
|
return verifyQueekSignatureDetailed(input, options).ok;
|
|
48
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Like {@link verifyQueekSignature}, but says why a delivery was rejected:
|
|
75
|
+
* `missing_headers`, `stale_timestamp` or `signature_mismatch`. Log the
|
|
76
|
+
* reason; answer 401 either way.
|
|
77
|
+
*/
|
|
49
78
|
export function verifyQueekSignatureDetailed(input, options = {}) {
|
|
50
79
|
const { id, timestamp, body, signatureHeader, secret } = input;
|
|
51
80
|
if (!id || !timestamp || !signatureHeader || !secret) {
|
|
52
81
|
return { ok: false, reason: "missing_headers" };
|
|
53
82
|
}
|
|
54
83
|
if (!options.skipFreshnessCheck) {
|
|
55
|
-
const ts = Number(timestamp);
|
|
56
84
|
const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
57
85
|
const maxSkew = options.maxSkewSeconds ?? MAX_TIMESTAMP_SKEW_SECONDS;
|
|
58
|
-
if (!
|
|
86
|
+
if (!isFreshTimestamp(timestamp, now, maxSkew)) {
|
|
59
87
|
return { ok: false, reason: "stale_timestamp" };
|
|
60
88
|
}
|
|
61
89
|
}
|
|
@@ -71,4 +99,3 @@ export function verifyQueekSignatureDetailed(input, options = {}) {
|
|
|
71
99
|
}
|
|
72
100
|
return { ok: false, reason: "signature_mismatch" };
|
|
73
101
|
}
|
|
74
|
-
//# sourceMappingURL=signatures.js.map
|
package/dist/store.d.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import { Pool } from "pg";
|
|
2
2
|
/**
|
|
3
|
-
* The app database holds INSTALLATIONS ONLY
|
|
4
|
-
*
|
|
5
|
-
* Business data lives in Queek (product metafields,
|
|
6
|
-
* orders.integrator_metadata) — never here.
|
|
3
|
+
* The app database holds INSTALLATIONS ONLY. Business data lives in Queek
|
|
4
|
+
* (for example in product metafields) — never here.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* No store-callable credential crosses the install handoff, so there is no
|
|
7
|
+
* `api_key` column. The app mints short-lived
|
|
10
8
|
* installation tokens with its asymmetric app key and caches ONE token per
|
|
11
9
|
* installation here (`token_enc` + `token_expires_at` + `token_kid`);
|
|
12
10
|
* `webhook_secret` AND the whole `settings` blob stay encrypted at rest
|
|
13
11
|
* (AES-256-GCM via node:crypto, key from `APP_ENCRYPTION_KEY`): manifests
|
|
14
|
-
* may declare `secret` settings (API keys, webhook secrets) and
|
|
12
|
+
* may declare `secret` settings (API keys, webhook secrets) and Queek
|
|
15
13
|
* encrypts those too, so the SDK must not be weaker. NOTHING secret is ever
|
|
16
14
|
* logged: only ids and the store p_id may appear in logs.
|
|
17
15
|
*/
|
|
18
16
|
export interface InstallationRecord {
|
|
19
|
-
/** Queek installation id (string form of the
|
|
17
|
+
/** Queek installation id (string form of the UUID). */
|
|
20
18
|
installationId: string;
|
|
21
19
|
/** Queek installation p_id (short public id, safe to log). */
|
|
22
20
|
installationPid: string;
|
|
23
|
-
/** Vendor id (
|
|
21
|
+
/** Vendor id (UUID, string form). */
|
|
24
22
|
vendorId: string;
|
|
25
23
|
/** Store p_id (safe to log). */
|
|
26
24
|
storePid: string | null;
|
|
@@ -54,9 +52,9 @@ export interface InstallationRecord {
|
|
|
54
52
|
webhookSecret: string | null;
|
|
55
53
|
/**
|
|
56
54
|
* Plaintext per-installation proxy secret — memory only, persisted as
|
|
57
|
-
* `proxy_secret_enc`, null when the app has no proxy. Signs
|
|
58
|
-
* slot-claims
|
|
59
|
-
*
|
|
55
|
+
* `proxy_secret_enc`, null when the app has no proxy. Signs claims such
|
|
56
|
+
* as booking slot-claims; never logged (the logger redacts secret-named
|
|
57
|
+
* keys).
|
|
60
58
|
*/
|
|
61
59
|
proxySecret: string | null;
|
|
62
60
|
/**
|
|
@@ -76,6 +74,12 @@ export interface WebhookSecretCandidate {
|
|
|
76
74
|
installationId: string;
|
|
77
75
|
secret: string;
|
|
78
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Where installations live. The SDK ships `SqliteInstallationStore` (local
|
|
79
|
+
* and test) and `PostgresInstallationStore` (production); both encrypt
|
|
80
|
+
* secrets at rest. Implement this interface to use another database, and
|
|
81
|
+
* keep `claimWebhookId` atomic: handlers rely on it to run each delivery once.
|
|
82
|
+
*/
|
|
79
83
|
export interface InstallationStore {
|
|
80
84
|
saveInstallation(record: InstallationRecord): Promise<void> | void;
|
|
81
85
|
getInstallation(installationId: string): Promise<InstallationRecord | null> | InstallationRecord | null;
|
|
@@ -152,19 +156,17 @@ export interface SqliteStoreOptions {
|
|
|
152
156
|
* built-in `node:sqlite` (`DatabaseSync` — synchronous, file-backed, WAL
|
|
153
157
|
* mode).
|
|
154
158
|
*
|
|
155
|
-
* Why not better-sqlite3: its prebuilt binary segfaults on
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* identical synchronous shape.
|
|
159
|
+
* Why not better-sqlite3: its prebuilt binary segfaults on Node 22.12, and
|
|
160
|
+
* building from source would force a C++ toolchain into every environment
|
|
161
|
+
* — dev Macs, CI, and slim alpine images. `node:sqlite` is
|
|
162
|
+
* dependency-free with an identical synchronous shape.
|
|
160
163
|
*
|
|
161
|
-
* `node:sqlite` needs no flag since Node 22.13.0 (
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* swap is contained here, behind `InstallationStore`.
|
|
164
|
+
* `node:sqlite` needs no flag since Node 22.13.0 (an ExperimentalWarning on
|
|
165
|
+
* stderr remains — Stability 1.1). Devs on an older 22 minor set
|
|
166
|
+
* `NODE_OPTIONS=--experimental-sqlite` in their shell. The swap to another
|
|
167
|
+
* driver is contained here, behind `InstallationStore`.
|
|
166
168
|
*
|
|
167
|
-
*
|
|
169
|
+
* Legacy upgrade: databases created by SDK 0.1.x carry a legacy
|
|
168
170
|
* `api_key_enc` column (the handoff no longer delivers that key, so its
|
|
169
171
|
* value is dead — minted tokens replace it; resync restores connectivity).
|
|
170
172
|
* Opening such a database adds the token-cache columns and drops
|
|
@@ -195,8 +197,7 @@ export declare class SqliteInstallationStore implements InstallationStore {
|
|
|
195
197
|
}
|
|
196
198
|
/**
|
|
197
199
|
* Canonical integer-string form of a Queek `p_id`: the handoff contract
|
|
198
|
-
* types p_ids as strings
|
|
199
|
-
* tolerance stays — a JSON integer would reach here as a JS number, and
|
|
200
|
+
* types p_ids as strings, but the tolerance stays — a JSON integer would reach here as a JS number, and
|
|
200
201
|
* `node:sqlite` binds that number into a TEXT column as `"1021.0"`, which
|
|
201
202
|
* never equals the proxy `kid` (`"1021"`). Non-numeric pids pass through
|
|
202
203
|
* untouched.
|
|
@@ -308,4 +309,3 @@ export interface InstallationStoreSelector {
|
|
|
308
309
|
* container-local database).
|
|
309
310
|
*/
|
|
310
311
|
export declare function createInstallationStore(options: InstallationStoreSelector): InstallationStore;
|
|
311
|
-
//# sourceMappingURL=store.d.ts.map
|