@usequeek/app-sdk 0.7.2 → 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.
- package/README.md +76 -4
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +1 -1
- package/dist/delivery.d.ts +20 -2
- package/dist/delivery.js +31 -3
- package/dist/frame.d.ts +23 -1
- package/dist/frame.js +59 -1
- package/dist/hono.d.ts +16 -4
- package/dist/hono.js +24 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/install-handlers.d.ts +5 -0
- package/dist/install-handlers.js +26 -4
- package/dist/manifest.d.ts +98 -0
- package/dist/manifest.js +54 -0
- package/dist/merchant-schema.d.ts +23354 -8669
- package/dist/proxy.d.ts +62 -2
- package/dist/proxy.js +89 -9
- package/dist/webhooks.d.ts +9 -3
- package/dist/webhooks.js +62 -23
- package/package.json +1 -1
package/dist/proxy.d.ts
CHANGED
|
@@ -30,12 +30,19 @@ export declare const PROXY_NONCE_PARAM = "jti";
|
|
|
30
30
|
export declare const PROXY_SHOP_PARAM = "shop";
|
|
31
31
|
export declare const PROXY_CUSTOMER_PARAM = "logged_in_customer_id";
|
|
32
32
|
export declare const PROXY_KID_PARAM = "kid";
|
|
33
|
+
export declare const PROXY_SURFACE_PARAM = "surface";
|
|
34
|
+
/**
|
|
35
|
+
* The Queek surface that made a call. Queek signs it on every proxy call and
|
|
36
|
+
* is the only one that sets it: a shopper cannot choose it. `storefront` is a
|
|
37
|
+
* shopper, `dashboard` the merchant dashboard, `ai` Queek AI reading a tool.
|
|
38
|
+
*/
|
|
39
|
+
export type ProxySurface = "storefront" | "dashboard" | "ai";
|
|
33
40
|
/** Default allowed clock skew in seconds; the floor (60s) below applies regardless. */
|
|
34
41
|
export declare const PROXY_MAX_SKEW_SECONDS = 300;
|
|
35
42
|
/** Query params as any framework holds them (duplicates collapse to the first value). */
|
|
36
43
|
export type ProxyQuery = Record<string, string | string[] | null | undefined>;
|
|
37
44
|
/** Failure reason when proxy verification rejects a query. */
|
|
38
|
-
export type ProxyFailure = "missing_params" | "stale_timestamp" | "missing_secret" | "signature_mismatch" | "unknown_installation" | "replayed";
|
|
45
|
+
export type ProxyFailure = "missing_params" | "stale_timestamp" | "missing_secret" | "signature_mismatch" | "unknown_installation" | "surface_mismatch" | "replayed";
|
|
39
46
|
/**
|
|
40
47
|
* The canonical string the MAC covers, exactly as Queek builds it (sig
|
|
41
48
|
* excluded, keys byte-sorted, percent-encoded pairs).
|
|
@@ -48,6 +55,13 @@ export declare function buildProxyCanonicalString(path: string, params: Record<s
|
|
|
48
55
|
*/
|
|
49
56
|
export declare function signProxyQuery(path: string, params: Record<string, string>, secret: string): string;
|
|
50
57
|
export interface ProxyVerifyOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Require this signed `surface`. A call signed for another surface (or for
|
|
60
|
+
* none) fails with `surface_mismatch`, after its signature verified. Set it
|
|
61
|
+
* on a handler that serves merchant data (`dashboard`) so a shopper's call
|
|
62
|
+
* cannot reach it.
|
|
63
|
+
*/
|
|
64
|
+
expectedSurface?: ProxySurface;
|
|
51
65
|
/** Unix seconds to judge freshness against (defaults to now). Exposed for tests. */
|
|
52
66
|
nowSeconds?: number;
|
|
53
67
|
/** Allowed clock skew in seconds (defaults to PROXY_MAX_SKEW_SECONDS; floored at 60). */
|
|
@@ -61,6 +75,7 @@ export interface ProxyVerifyOptions {
|
|
|
61
75
|
*/
|
|
62
76
|
export declare function verifyProxyQueryDetailed(path: string, query: ProxyQuery, secrets: string[], options?: ProxyVerifyOptions): {
|
|
63
77
|
ok: true;
|
|
78
|
+
surface: string;
|
|
64
79
|
} | {
|
|
65
80
|
ok: false;
|
|
66
81
|
reason: ProxyFailure;
|
|
@@ -79,10 +94,11 @@ export type ProxyVerification = {
|
|
|
79
94
|
ok: true;
|
|
80
95
|
installation: InstallationRecord;
|
|
81
96
|
params: Record<string, string>;
|
|
97
|
+
surface: string;
|
|
82
98
|
} | {
|
|
83
99
|
ok: false;
|
|
84
100
|
reason: ProxyFailure;
|
|
85
|
-
status: 401;
|
|
101
|
+
status: 401 | 403;
|
|
86
102
|
};
|
|
87
103
|
/**
|
|
88
104
|
* Layer 1: verify one signed proxy query from plain data — no `Request`, no
|
|
@@ -109,6 +125,7 @@ export interface ProxyRequestOptions extends ProxyStoreOptions {
|
|
|
109
125
|
export type ProxyResponder = (verified: {
|
|
110
126
|
installation: InstallationRecord;
|
|
111
127
|
params: Record<string, string>;
|
|
128
|
+
surface: string;
|
|
112
129
|
}) => Response | Promise<Response>;
|
|
113
130
|
/**
|
|
114
131
|
* Layer 2: the Web-standard wrapper, built ONLY on layer 1 — reads the query
|
|
@@ -117,3 +134,46 @@ export type ProxyResponder = (verified: {
|
|
|
117
134
|
* anything else answers 405.
|
|
118
135
|
*/
|
|
119
136
|
export declare function handleProxyRequest(request: Request, options: ProxyRequestOptions, onVerified: ProxyResponder): Promise<Response>;
|
|
137
|
+
/** A verified call to a declared AI read tool: Queek's signed params, the tool key and the actor's ROLE (never a user id). */
|
|
138
|
+
export interface AiToolCall {
|
|
139
|
+
/** The installation p_id the call is for (the signed `kid`). */
|
|
140
|
+
kid: string;
|
|
141
|
+
shop: string;
|
|
142
|
+
/** Everything Queek signed: the tool's inputs as strings, `kid`, `shop`, `ts`, `actor_role` and the rest. */
|
|
143
|
+
params: Record<string, string>;
|
|
144
|
+
tool: string;
|
|
145
|
+
actorRole: string;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* What a tool answers: an OBJECT matching its manifest `ai.tools[].output`, never a bare array. A list tool
|
|
149
|
+
* (`output.list: true`) answers `{ rows: [{ ...declared fields }] }`; any other tool answers the declared fields
|
|
150
|
+
* object. Queek drops every undeclared field. (`length?: never` is what keeps an array out of the type.)
|
|
151
|
+
*/
|
|
152
|
+
export type AiToolOutput = {
|
|
153
|
+
[field: string]: unknown;
|
|
154
|
+
length?: never;
|
|
155
|
+
};
|
|
156
|
+
export type AiToolHandler = (call: AiToolCall) => Promise<AiToolOutput> | AiToolOutput;
|
|
157
|
+
export interface AiToolRequestOptions extends Omit<ProxyVerifyOptions, "expectedSurface"> {
|
|
158
|
+
/** The manifest `extensions.proxy.subpath`; the signed path is `/apps/<subpath>/ai/<tool>`. */
|
|
159
|
+
subpath: string;
|
|
160
|
+
/** The active proxy secrets of installation `kid`, current first (empty when unknown). */
|
|
161
|
+
secrets: (kid: string) => Promise<string[]> | string[];
|
|
162
|
+
/** The read tools the manifest `ai.tools` declares, by key. Only these are served. */
|
|
163
|
+
tools: Record<string, AiToolHandler>;
|
|
164
|
+
/**
|
|
165
|
+
* Makes each call single-use: when given, a verified call's `jti` is claimed atomically in this store, exactly as
|
|
166
|
+
* `verifyProxyDelivery` does, and a replay answers 401 `replayed`. Pass the installation store. Without it a
|
|
167
|
+
* captured call can be replayed inside the skew window.
|
|
168
|
+
*/
|
|
169
|
+
replayStore?: Pick<InstallationStore, "claimWebhookId">;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Layer 2 for Queek AI read tools (manifest `ai.tools`): `GET ai/{tool}` signed with `surface=ai`. The signature and
|
|
173
|
+
* the surface are verified first (401, or 403 for a genuine call signed for another surface). `surface=ai` is served
|
|
174
|
+
* only by a declared tool handler (403 for an undeclared key), a storefront or dashboard call at this path is
|
|
175
|
+
* refused, and an AI call never reaches `handleProxyRequest`'s responder. A handler that answers anything but an
|
|
176
|
+
* object (a bare array, a scalar) is answered 500. Pass `replayStore` to make each call single-use; a tool only
|
|
177
|
+
* reads, so without it a replay inside the skew window repeats nothing but the read.
|
|
178
|
+
*/
|
|
179
|
+
export declare function handleAiToolRequest(request: Request, tool: string, options: AiToolRequestOptions): Promise<Response>;
|
package/dist/proxy.js
CHANGED
|
@@ -32,6 +32,7 @@ export const PROXY_NONCE_PARAM = "jti";
|
|
|
32
32
|
export const PROXY_SHOP_PARAM = "shop";
|
|
33
33
|
export const PROXY_CUSTOMER_PARAM = "logged_in_customer_id";
|
|
34
34
|
export const PROXY_KID_PARAM = "kid";
|
|
35
|
+
export const PROXY_SURFACE_PARAM = "surface";
|
|
35
36
|
/** Default allowed clock skew in seconds; the floor (60s) below applies regardless. */
|
|
36
37
|
export const PROXY_MAX_SKEW_SECONDS = 300;
|
|
37
38
|
/** RFC 3986 percent-encoding: everything except `[A-Za-z0-9-_.~]` is `%XX` (upper-case hex), the encoding Queek signs with. */
|
|
@@ -89,6 +90,13 @@ function proxySkewExceeded(ts, options) {
|
|
|
89
90
|
const maxSkew = Math.max(60, options.maxSkewSeconds ?? PROXY_MAX_SKEW_SECONDS);
|
|
90
91
|
return Math.abs(now - Number(ts)) > maxSkew;
|
|
91
92
|
}
|
|
93
|
+
/** The signed surface, or `""` when the call carries none. Trust it only after the MAC verified. */
|
|
94
|
+
function signedSurface(params) {
|
|
95
|
+
return params[PROXY_SURFACE_PARAM] ?? "";
|
|
96
|
+
}
|
|
97
|
+
function surfaceAllowed(params, options) {
|
|
98
|
+
return options.expectedSurface === undefined || signedSurface(params) === options.expectedSurface;
|
|
99
|
+
}
|
|
92
100
|
function proxyMacMatches(path, params, sig, secret) {
|
|
93
101
|
if (secret === "")
|
|
94
102
|
return false;
|
|
@@ -117,7 +125,9 @@ export function verifyProxyQueryDetailed(path, query, secrets, options = {}) {
|
|
|
117
125
|
}
|
|
118
126
|
for (const secret of secrets) {
|
|
119
127
|
if (proxyMacMatches(path, params, sig, secret)) {
|
|
120
|
-
return
|
|
128
|
+
return surfaceAllowed(params, options)
|
|
129
|
+
? { ok: true, surface: signedSurface(params) }
|
|
130
|
+
: { ok: false, reason: "surface_mismatch" };
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
return { ok: false, reason: "signature_mismatch" };
|
|
@@ -136,7 +146,11 @@ export function verifyProxyQuery(path, query, secrets, options = {}) {
|
|
|
136
146
|
* read cannot be replayed.
|
|
137
147
|
*/
|
|
138
148
|
export async function verifyProxyDelivery(input, options) {
|
|
139
|
-
const fail = (reason) => ({
|
|
149
|
+
const fail = (reason, status = 401) => ({
|
|
150
|
+
ok: false,
|
|
151
|
+
reason,
|
|
152
|
+
status,
|
|
153
|
+
});
|
|
140
154
|
const params = normalizeQuery(input.query);
|
|
141
155
|
const { sig, ts, jti } = readProxyFields(params);
|
|
142
156
|
if (sig === "" || ts === "" || jti === "" || !/^\d+$/.test(ts)) {
|
|
@@ -171,12 +185,25 @@ export async function verifyProxyDelivery(input, options) {
|
|
|
171
185
|
if (!installation) {
|
|
172
186
|
return fail("signature_mismatch");
|
|
173
187
|
}
|
|
188
|
+
// A genuine signature for another surface is forbidden, not unauthenticated.
|
|
189
|
+
if (!surfaceAllowed(params, options)) {
|
|
190
|
+
return fail("surface_mismatch", 403);
|
|
191
|
+
}
|
|
174
192
|
if (options.enforceReplay ?? true) {
|
|
175
193
|
if (!(await options.store.claimWebhookId(`proxy-jti:${jti}`))) {
|
|
176
194
|
return fail("replayed");
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
|
-
return { ok: true, installation, params };
|
|
197
|
+
return { ok: true, installation, params, surface: signedSurface(params) };
|
|
198
|
+
}
|
|
199
|
+
/** The query of a Web `Request`, duplicates collapsing to the first value like `URLSearchParams.get`. */
|
|
200
|
+
function requestQuery(request) {
|
|
201
|
+
const url = new URL(request.url);
|
|
202
|
+
const query = {};
|
|
203
|
+
for (const key of url.searchParams.keys()) {
|
|
204
|
+
query[key] = url.searchParams.get(key);
|
|
205
|
+
}
|
|
206
|
+
return query;
|
|
180
207
|
}
|
|
181
208
|
/**
|
|
182
209
|
* Layer 2: the Web-standard wrapper, built ONLY on layer 1 — reads the query
|
|
@@ -188,15 +215,68 @@ export async function handleProxyRequest(request, options, onVerified) {
|
|
|
188
215
|
if (request.method.toUpperCase() !== "GET") {
|
|
189
216
|
return toResponse({ status: 405, body: { ok: false, error: "method_not_allowed" } });
|
|
190
217
|
}
|
|
191
|
-
const
|
|
192
|
-
const query = {};
|
|
193
|
-
for (const key of url.searchParams.keys()) {
|
|
194
|
-
query[key] = url.searchParams.get(key);
|
|
195
|
-
}
|
|
218
|
+
const query = requestQuery(request);
|
|
196
219
|
const verified = await verifyProxyDelivery({ path: options.path, query }, options);
|
|
197
220
|
if (!verified.ok) {
|
|
198
221
|
const result = { status: verified.status, body: { ok: false, error: verified.reason } };
|
|
199
222
|
return toResponse(result);
|
|
200
223
|
}
|
|
201
|
-
|
|
224
|
+
// A responder serves storefront and dashboard calls. An AI call is served only by a declared tool handler.
|
|
225
|
+
if (verified.surface === "ai") {
|
|
226
|
+
return toResponse({ status: 403, body: { ok: false, error: "forbidden" } });
|
|
227
|
+
}
|
|
228
|
+
return onVerified({
|
|
229
|
+
installation: verified.installation,
|
|
230
|
+
params: verified.params,
|
|
231
|
+
surface: verified.surface,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Layer 2 for Queek AI read tools (manifest `ai.tools`): `GET ai/{tool}` signed with `surface=ai`. The signature and
|
|
236
|
+
* the surface are verified first (401, or 403 for a genuine call signed for another surface). `surface=ai` is served
|
|
237
|
+
* only by a declared tool handler (403 for an undeclared key), a storefront or dashboard call at this path is
|
|
238
|
+
* refused, and an AI call never reaches `handleProxyRequest`'s responder. A handler that answers anything but an
|
|
239
|
+
* object (a bare array, a scalar) is answered 500. Pass `replayStore` to make each call single-use; a tool only
|
|
240
|
+
* reads, so without it a replay inside the skew window repeats nothing but the read.
|
|
241
|
+
*/
|
|
242
|
+
export async function handleAiToolRequest(request, tool, options) {
|
|
243
|
+
if (request.method.toUpperCase() !== "GET") {
|
|
244
|
+
return toResponse({ status: 405, body: { ok: false, error: "method_not_allowed" } });
|
|
245
|
+
}
|
|
246
|
+
const { subpath, secrets, tools, replayStore, ...verifyOptions } = options;
|
|
247
|
+
const query = requestQuery(request);
|
|
248
|
+
const params = normalizeQuery(query);
|
|
249
|
+
const kid = params[PROXY_KID_PARAM] ?? "";
|
|
250
|
+
const verified = verifyProxyQueryDetailed(`/apps/${subpath}/ai/${tool}`, query, await secrets(kid), {
|
|
251
|
+
...verifyOptions,
|
|
252
|
+
expectedSurface: "ai",
|
|
253
|
+
});
|
|
254
|
+
if (!verified.ok) {
|
|
255
|
+
return toResponse({
|
|
256
|
+
status: verified.reason === "surface_mismatch" ? 403 : 401,
|
|
257
|
+
body: { ok: false, error: verified.reason },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (replayStore && !(await replayStore.claimWebhookId(`proxy-jti:${params[PROXY_NONCE_PARAM]}`))) {
|
|
261
|
+
return toResponse({ status: 401, body: { ok: false, error: "replayed" } });
|
|
262
|
+
}
|
|
263
|
+
const handler = Object.hasOwn(tools, tool) ? tools[tool] : undefined;
|
|
264
|
+
if (!handler) {
|
|
265
|
+
return toResponse({ status: 403, body: { ok: false, error: "forbidden" } });
|
|
266
|
+
}
|
|
267
|
+
const output = await handler({
|
|
268
|
+
kid,
|
|
269
|
+
shop: params[PROXY_SHOP_PARAM] ?? "",
|
|
270
|
+
params,
|
|
271
|
+
tool,
|
|
272
|
+
actorRole: params.actor_role ?? "",
|
|
273
|
+
});
|
|
274
|
+
if (output === null || typeof output !== "object" || Array.isArray(output)) {
|
|
275
|
+
return toResponse({ status: 500, body: { ok: false, error: "invalid_tool_output" } });
|
|
276
|
+
}
|
|
277
|
+
return toResponse({
|
|
278
|
+
status: 200,
|
|
279
|
+
body: output,
|
|
280
|
+
headers: { "Cache-Control": "private, no-store, max-age=0" },
|
|
281
|
+
});
|
|
202
282
|
}
|
package/dist/webhooks.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
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:
|
|
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`.
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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`.
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
method,
|
|
202
|
+
}, options), method);
|
|
164
203
|
}
|