@usequeek/app-sdk 0.8.0 → 0.8.1

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.
@@ -1,4 +1,4 @@
1
- import { type CoreDelivery, type DeliveryErrorHandler, type DeliveryResult } from "./delivery.js";
1
+ import { type DeliveryErrorHandler, type DeliveryResult, type WebhookDelivery } from "./delivery.js";
2
2
  import type { InstallationRecord, InstallationStore } from "./store.js";
3
3
  /**
4
4
  * Framework-agnostic receiver for Queek topic deliveries (one signed POST
@@ -79,6 +79,11 @@ export interface WebhookHandlerOptions {
79
79
  * 200 for a handled, repeated or unhandled-topic delivery; 500 when your
80
80
  * handler throws (the claim is released so Queek's retry can land).
81
81
  *
82
+ * `method` `GET` or `HEAD` is the review probe: 200 `{ ok: true }` with
83
+ * `Cache-Control: no-store` and no handler; with a signature (over the
84
+ * empty body) 200 when it verifies against an installation secret and 401
85
+ * when not.
86
+ *
82
87
  * @example
83
88
  * const result = await handleWebhookDelivery(
84
89
  * { rawBody, headers },
@@ -93,10 +98,11 @@ export interface WebhookHandlerOptions {
93
98
  * );
94
99
  * // reply with result.status and result.body
95
100
  */
96
- export declare function handleWebhookDelivery(input: CoreDelivery, options: WebhookHandlerOptions): Promise<DeliveryResult>;
101
+ export declare function handleWebhookDelivery(input: WebhookDelivery, options: WebhookHandlerOptions): Promise<DeliveryResult>;
97
102
  /**
98
103
  * Layer 2: the Web-standard wrapper, built ONLY on layer 1 — reads
99
104
  * `await request.arrayBuffer()` + headers, calls the core, builds the
100
- * `Response`. Only `POST` is served; anything else answers 405.
105
+ * `Response`. `POST` is a delivery; `GET` and `HEAD` are the review probe
106
+ * (see `handleWebhookDelivery`); anything else answers 405.
101
107
  */
102
108
  export declare function handleWebhookRequest(request: Request, options: WebhookHandlerOptions): Promise<Response>;
package/dist/webhooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decodeBody, readHeader, reportDeliveryFailure, toResponse, } from "./delivery.js";
1
+ import { decodeBody, isProbeMethod, isUnsignedProbe, probeResult, readHeader, reportDeliveryFailure, toResponse, } from "./delivery.js";
2
2
  import { isFreshTimestamp, MAX_TIMESTAMP_SKEW_SECONDS, verifyQueekSignature, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, } from "./signatures.js";
3
3
  /**
4
4
  * Framework-agnostic receiver for Queek topic deliveries (one signed POST
@@ -57,6 +57,50 @@ async function defaultResolveSecret(store, rawBody, headers, envelope) {
57
57
  }
58
58
  return null;
59
59
  }
60
+ /**
61
+ * Resolve the endpoint secret that signed a delivery and verify freshness
62
+ * and the MAC against it. The single verification path of a delivery AND of
63
+ * a signed review probe (empty body, no envelope).
64
+ */
65
+ async function verifyAgainstResolvedSecret(options, rawBody, headers, envelope) {
66
+ const resolution = options.resolveSecret
67
+ ? await options.resolveSecret(envelope, rawBody, headers)
68
+ : await defaultResolveSecret(options.store, rawBody, headers, envelope);
69
+ if (!resolution) {
70
+ return { ok: false, error: "unknown_installation" };
71
+ }
72
+ // Freshness is enforced HERE, once, against the resolved secret — the
73
+ // lookup above deliberately skips it so a stale delivery cannot be
74
+ // misattributed before it is rejected.
75
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
76
+ if (!isFreshTimestamp(headers.timestamp, now, options.maxSkewSeconds ?? MAX_TIMESTAMP_SKEW_SECONDS)) {
77
+ return { ok: false, error: "stale timestamp" };
78
+ }
79
+ // Freshness was enforced above; this re-checks the MAC only, so a
80
+ // custom resolver cannot claim an installation without its secret.
81
+ if (!verifyQueekSignature({ ...headers, body: rawBody, secret: resolution.secret }, { skipFreshnessCheck: true })) {
82
+ return { ok: false, error: "signature mismatch" };
83
+ }
84
+ return { ok: true, resolution };
85
+ }
86
+ /**
87
+ * The review probe (`GET` / `HEAD`): 200 without running a handler or
88
+ * touching the store. A probe that carries a signature is verified like a
89
+ * delivery, over the empty body and against the stored endpoint secrets:
90
+ * 200 when it verifies, 401 when not. A valid probe claims nothing.
91
+ */
92
+ async function probeDelivery(input, options) {
93
+ if (isUnsignedProbe(input.headers))
94
+ return probeResult(200);
95
+ const id = readHeader(input.headers, WEBHOOK_ID_HEADER);
96
+ const timestamp = readHeader(input.headers, WEBHOOK_TIMESTAMP_HEADER);
97
+ const signatureHeader = readHeader(input.headers, WEBHOOK_SIGNATURE_HEADER);
98
+ if (!id || !timestamp || !signatureHeader) {
99
+ return probeResult(401, "missing signature headers");
100
+ }
101
+ const verified = await verifyAgainstResolvedSecret(options, "", { id, timestamp, signatureHeader }, null);
102
+ return verified.ok ? probeResult(200) : probeResult(401, verified.error);
103
+ }
60
104
  /**
61
105
  * Layer 1: handle one signed Queek topic delivery from plain data — no
62
106
  * `Request`, no framework. Same options, same behaviour, same
@@ -67,6 +111,11 @@ async function defaultResolveSecret(store, rawBody, headers, envelope) {
67
111
  * 200 for a handled, repeated or unhandled-topic delivery; 500 when your
68
112
  * handler throws (the claim is released so Queek's retry can land).
69
113
  *
114
+ * `method` `GET` or `HEAD` is the review probe: 200 `{ ok: true }` with
115
+ * `Cache-Control: no-store` and no handler; with a signature (over the
116
+ * empty body) 200 when it verifies against an installation secret and 401
117
+ * when not.
118
+ *
70
119
  * @example
71
120
  * const result = await handleWebhookDelivery(
72
121
  * { rawBody, headers },
@@ -82,7 +131,8 @@ async function defaultResolveSecret(store, rawBody, headers, envelope) {
82
131
  * // reply with result.status and result.body
83
132
  */
84
133
  export async function handleWebhookDelivery(input, options) {
85
- const maxSkew = options.maxSkewSeconds ?? MAX_TIMESTAMP_SKEW_SECONDS;
134
+ if (isProbeMethod(input.method))
135
+ return probeDelivery(input, options);
86
136
  const rawBody = decodeBody(input.rawBody);
87
137
  const id = readHeader(input.headers, WEBHOOK_ID_HEADER);
88
138
  const timestamp = readHeader(input.headers, WEBHOOK_TIMESTAMP_HEADER);
@@ -103,24 +153,10 @@ export async function handleWebhookDelivery(input, options) {
103
153
  return { status: 400, body: { ok: false, error: "invalid_envelope" } };
104
154
  }
105
155
  const headers = { id, timestamp, signatureHeader };
106
- const resolution = options.resolveSecret
107
- ? await options.resolveSecret(envelope, rawBody, headers)
108
- : await defaultResolveSecret(options.store, rawBody, headers, envelope);
109
- if (!resolution) {
110
- return { status: 401, body: { ok: false, error: "unknown_installation" } };
111
- }
112
- // Freshness is enforced HERE, once, against the resolved secret — the
113
- // lookup above deliberately skips it so a stale delivery cannot be
114
- // misattributed before it is rejected.
115
- const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
116
- if (!isFreshTimestamp(timestamp, now, maxSkew)) {
117
- return { status: 401, body: { ok: false, error: "stale timestamp" } };
118
- }
119
- // Freshness was enforced above; this re-checks the MAC only, so a
120
- // custom resolver cannot claim an installation without its secret.
121
- if (!verifyQueekSignature({ ...headers, body: rawBody, secret: resolution.secret }, { skipFreshnessCheck: true })) {
122
- return { status: 401, body: { ok: false, error: "signature mismatch" } };
123
- }
156
+ const verified = await verifyAgainstResolvedSecret(options, rawBody, headers, envelope);
157
+ if (!verified.ok)
158
+ return { status: 401, body: { ok: false, error: verified.error } };
159
+ const resolution = verified.resolution;
124
160
  const installation = await options.store.getInstallation(resolution.installationId);
125
161
  if (!installation) {
126
162
  return { status: 401, body: { ok: false, error: "unknown_installation" } };
@@ -151,14 +187,17 @@ export async function handleWebhookDelivery(input, options) {
151
187
  /**
152
188
  * Layer 2: the Web-standard wrapper, built ONLY on layer 1 — reads
153
189
  * `await request.arrayBuffer()` + headers, calls the core, builds the
154
- * `Response`. Only `POST` is served; anything else answers 405.
190
+ * `Response`. `POST` is a delivery; `GET` and `HEAD` are the review probe
191
+ * (see `handleWebhookDelivery`); anything else answers 405.
155
192
  */
156
193
  export async function handleWebhookRequest(request, options) {
157
- if (request.method.toUpperCase() !== "POST") {
194
+ const method = request.method.toUpperCase();
195
+ if (method !== "POST" && !isProbeMethod(method)) {
158
196
  return toResponse({ status: 405, body: { ok: false, error: "method_not_allowed" } });
159
197
  }
160
198
  return toResponse(await handleWebhookDelivery({
161
199
  rawBody: new Uint8Array(await request.arrayBuffer()),
162
200
  headers: request.headers,
163
- }, options));
201
+ method,
202
+ }, options), method);
164
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/app-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Queekx Technology LTD",