@usequeek/app-sdk 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +339 -330
  3. package/dist/app-auth.d.ts +15 -17
  4. package/dist/app-auth.js +20 -23
  5. package/dist/auth-fetch.d.ts +14 -5
  6. package/dist/auth-fetch.js +15 -6
  7. package/dist/background.d.ts +10 -2
  8. package/dist/background.js +9 -1
  9. package/dist/browser.d.ts +2 -3
  10. package/dist/browser.js +2 -3
  11. package/dist/client.d.ts +138 -16
  12. package/dist/client.js +200 -43
  13. package/dist/collected.d.ts +13 -15
  14. package/dist/collected.js +4 -5
  15. package/dist/crypto.d.ts +11 -1
  16. package/dist/crypto.js +25 -10
  17. package/dist/delivery.d.ts +22 -9
  18. package/dist/delivery.js +26 -21
  19. package/dist/frame.d.ts +11 -12
  20. package/dist/frame.js +11 -12
  21. package/dist/handoff.d.ts +35 -39
  22. package/dist/handoff.js +9 -10
  23. package/dist/hono.d.ts +3 -4
  24. package/dist/hono.js +3 -4
  25. package/dist/index.d.ts +1 -2
  26. package/dist/index.js +1 -2
  27. package/dist/install-handlers.d.ts +28 -9
  28. package/dist/install-handlers.js +48 -18
  29. package/dist/logger.d.ts +7 -1
  30. package/dist/logger.js +8 -2
  31. package/dist/merchant-schema.d.ts +15678 -6236
  32. package/dist/merchant-schema.js +0 -1
  33. package/dist/proxy.d.ts +21 -24
  34. package/dist/proxy.js +20 -22
  35. package/dist/react.d.ts +10 -2
  36. package/dist/react.js +11 -3
  37. package/dist/resync.d.ts +15 -5
  38. package/dist/resync.js +22 -20
  39. package/dist/scopes.d.ts +36 -27
  40. package/dist/scopes.js +37 -40
  41. package/dist/server.d.ts +0 -1
  42. package/dist/server.js +0 -1
  43. package/dist/session.d.ts +35 -17
  44. package/dist/session.js +32 -15
  45. package/dist/signatures.d.ts +31 -5
  46. package/dist/signatures.js +36 -9
  47. package/dist/store.d.ts +26 -26
  48. package/dist/store.js +16 -20
  49. package/dist/theme.d.ts +1 -2
  50. package/dist/theme.js +1 -2
  51. package/dist/tokens.d.ts +18 -10
  52. package/dist/tokens.js +32 -57
  53. package/dist/webhooks.d.ts +30 -6
  54. package/dist/webhooks.js +27 -10
  55. package/package.json +21 -4
  56. package/dist/app-auth.d.ts.map +0 -1
  57. package/dist/app-auth.js.map +0 -1
  58. package/dist/auth-fetch.d.ts.map +0 -1
  59. package/dist/auth-fetch.js.map +0 -1
  60. package/dist/background.d.ts.map +0 -1
  61. package/dist/background.js.map +0 -1
  62. package/dist/browser.d.ts.map +0 -1
  63. package/dist/browser.js.map +0 -1
  64. package/dist/client.d.ts.map +0 -1
  65. package/dist/client.js.map +0 -1
  66. package/dist/collected.d.ts.map +0 -1
  67. package/dist/collected.js.map +0 -1
  68. package/dist/crypto.d.ts.map +0 -1
  69. package/dist/crypto.js.map +0 -1
  70. package/dist/delivery.d.ts.map +0 -1
  71. package/dist/delivery.js.map +0 -1
  72. package/dist/frame.d.ts.map +0 -1
  73. package/dist/frame.js.map +0 -1
  74. package/dist/handoff.d.ts.map +0 -1
  75. package/dist/handoff.js.map +0 -1
  76. package/dist/hono.d.ts.map +0 -1
  77. package/dist/hono.js.map +0 -1
  78. package/dist/index.d.ts.map +0 -1
  79. package/dist/index.js.map +0 -1
  80. package/dist/install-handlers.d.ts.map +0 -1
  81. package/dist/install-handlers.js.map +0 -1
  82. package/dist/logger.d.ts.map +0 -1
  83. package/dist/logger.js.map +0 -1
  84. package/dist/merchant-schema.d.ts.map +0 -1
  85. package/dist/merchant-schema.js.map +0 -1
  86. package/dist/proxy.d.ts.map +0 -1
  87. package/dist/proxy.js.map +0 -1
  88. package/dist/react.d.ts.map +0 -1
  89. package/dist/react.js.map +0 -1
  90. package/dist/resync.d.ts.map +0 -1
  91. package/dist/resync.js.map +0 -1
  92. package/dist/scopes.d.ts.map +0 -1
  93. package/dist/scopes.js.map +0 -1
  94. package/dist/server.d.ts.map +0 -1
  95. package/dist/server.js.map +0 -1
  96. package/dist/session.d.ts.map +0 -1
  97. package/dist/session.js.map +0 -1
  98. package/dist/signatures.d.ts.map +0 -1
  99. package/dist/signatures.js.map +0 -1
  100. package/dist/store.d.ts.map +0 -1
  101. package/dist/store.js.map +0 -1
  102. package/dist/theme.d.ts.map +0 -1
  103. package/dist/theme.js.map +0 -1
  104. package/dist/tokens.d.ts.map +0 -1
  105. package/dist/tokens.js.map +0 -1
  106. package/dist/webhooks.d.ts.map +0 -1
  107. package/dist/webhooks.js.map +0 -1
package/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 (plan item 6, Shopify `scopes.query / request / revoke`
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. The consent screen itself ships with the dashboard (plan
21
- * item 8), which honours this link shape.
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 — never auth:sanctum, never the
25
- * installation token). Body `{scopes[]}` (1–50, backend
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 — the backend rewrites live token rows in place,
34
- * so the drop is backstop, not mechanism).
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 frozen
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 the
52
- * backend named them.
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
- /** Backend `RevokeAppScopesRequest` bounds, mirrored client-side. */
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 backend contract (`required array min:1
88
- * max:50`, string items) so a malformed call fails before any fetch.
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 SDK↔dashboard contract the consent screen honours —
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
- * (shipped with dashboard item 8). Shaped like the bridge `open`
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 (backend
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 the backend rewrites live
219
- * token rows in place). A required scope refuses 422
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 new QueekApiError({
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
@@ -5,4 +5,3 @@
5
5
  * the main entry.
6
6
  */
7
7
  export { EMBED_SECRET_PREFIX, SESSION_CLOCK_TOLERANCE_SECONDS, sessionTokenInstallationId, verifySessionToken, verifySessionTokenDetailed, } from "./session.js";
8
- //# sourceMappingURL=server.js.map
package/dist/session.d.ts CHANGED
@@ -1,16 +1,15 @@
1
1
  /**
2
- * Dashboard session tokens: short-lived HS256 JWTs the backend mints per
3
- * installation for the framed merchant page. The app backend verifies them
4
- * with the per-installation `embsec_…` secret before trusting any call
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, exactly Shopify's `id_token` shape: the dashboard puts
8
- * the same token in the first-load URL param (`queek_token`, stripped on
9
- * arrival) and answers bridge `ready` requests with it, so the app's
10
- * exchange endpoint verifies first-load and refresh tokens with this one
11
- * verifier. No purpose split: extra claims the mint may carry are ignored,
12
- * never gated — identity binding (installation, audience, issuer, expiry)
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
- /** Backend skew the verifier tolerates (matches the mint side). */
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` the backend
50
- * signs as `iss`). Never a prefix or a suffix of it.
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 backend's own
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, mirroring the backend `verify()`.
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 the backend mints per
4
- * installation for the framed merchant page. The app backend verifies them
5
- * with the per-installation `embsec_…` secret before trusting any call
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, exactly Shopify's `id_token` shape: the dashboard puts
9
- * the same token in the first-load URL param (`queek_token`, stripped on
10
- * arrival) and answers bridge `ready` requests with it, so the app's
11
- * exchange endpoint verifies first-load and refresh tokens with this one
12
- * verifier. No purpose split: extra claims the mint may carry are ignored,
13
- * never gated — identity binding (installation, audience, issuer, expiry)
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
- /** Backend skew the verifier tolerates (matches the mint side). */
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 pins (verified against jose@6.2.12 — codes, names, and
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
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Standard Webhooks verification, byte-for-byte compatible with Queek's
3
- * `App\Services\Webhooks\WebhookSigner` (queek_backend).
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 the backend's `keyFor()`.
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 — mirrors `WebhookSigner::sign()`. */
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
@@ -1,7 +1,8 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  /**
3
- * Standard Webhooks verification, byte-for-byte compatible with Queek's
4
- * `App\Services\Webhooks\WebhookSigner` (queek_backend).
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 the backend's `keyFor()`.
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: the backend uses `base64_decode($encoded, true) ?: $secret`,
29
- // i.e. undecodable input falls back to the raw secret string.
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 — mirrors `WebhookSigner::sign()`. */
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 (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkew) {
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 (decision
4
- * 2026-09-23-queek-apps-separate-ts-hono-codebase-public-surface-only).
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
- * S1 (SDK 0.2.0): no store-callable credential crosses the install handoff
9
- * any more, so there is no `api_key` column. The app mints short-lived
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 the backend
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 backend UUID). */
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 (backend UUID, string form). */
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 (booking app); never logged (the logger redacts
59
- * secret-named keys).
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 the project's
156
- * Node 22.12 runtime (observed, not theorised), and building from source
157
- * would force a C++ toolchain into every environment — dev Macs, CI, and
158
- * the slim alpine app images. `node:sqlite` is dependency-free with an
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 (pinned runtime 22.23.3;
162
- * an ExperimentalWarning on stderr remains — Stability 1.1). Devs on an
163
- * older 22 minor set `NODE_OPTIONS=--experimental-sqlite` in their shell.
164
- * Revisit when the apps run on a Node line where it is fully stable — the
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
- * 0.1.x → 0.2.0 upgrade: databases created by SDK 0.1.x carry a legacy
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 and the backend casts on the wire, but the
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