@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.
package/README.md CHANGED
@@ -72,7 +72,7 @@ export const installRoute = (request: Request) => handleInstallRequest(request,
72
72
  export const webhookRoute = (request: Request) => handleWebhookRequest(request, webhookOptions);
73
73
  ```
74
74
 
75
- In Next.js App Router, export `installRoute` as `POST` from `app/api/install/route.ts` (and again from `uninstall/route.ts` and `settings/route.ts`), and `webhookRoute` as `POST` from `app/api/webhooks/route.ts`.
75
+ In Next.js App Router, export `installRoute` as `POST`, `GET` and `HEAD` from `app/api/install/route.ts` (and again from `uninstall/route.ts` and `settings/route.ts`), and `webhookRoute` as `POST`, `GET` and `HEAD` from `app/api/webhooks/route.ts`. The `GET` and `HEAD` exports answer the review probe below.
76
76
 
77
77
  ## Which entry do I import?
78
78
 
@@ -101,6 +101,10 @@ The handlers come in three layers that behave identically:
101
101
  2. `handleInstallRequest(request, options)` and `handleWebhookRequest(request, options)` adapt a Web `Request` to a `Response`.
102
102
  3. `createInstallHandlers`, `createWebhookHandler`, `createProxyHandler` and `createAiToolHandler` from `@usequeek/app-sdk/hono`.
103
103
 
104
+ ### The review probe
105
+
106
+ Queek's app review checks that your install, uninstall and webhook URLs answer, and that the install URL verifies signatures. It sends `GET` requests: one unsigned (any 2xx passes), and on the install URL one signed with your app secret (must be 2xx) and one signed with a wrong secret (must be 4xx). The handlers above answer `GET` and `HEAD` on the install, uninstall, settings and webhook routes for you: unsigned gets 200 `{"ok":true}`, a signed probe is verified like a delivery over the empty body (200 if valid, 401 if not), and none of it runs your callbacks or touches the store. If you mount the handlers yourself, route `GET` and `HEAD` to the same function as `POST`. The Hono helpers already do.
107
+
104
108
  ### Status codes
105
109
 
106
110
  | Route | Status | Meaning |
@@ -109,7 +113,7 @@ The handlers come in three layers that behave identically:
109
113
  | | 400 | The body is not the expected envelope or payload. |
110
114
  | | 401 | Missing signature header, stale timestamp, or bad signature. |
111
115
  | | 404 | Unknown route or unknown installation. |
112
- | | 405 | Not a `POST`. |
116
+ | | 405 | Not a `POST`, `GET` or `HEAD`. |
113
117
  | | 409 | This `webhook-id` was already claimed. |
114
118
  | | 500 | Your callback or the store write threw. The claim is released, so Queek's retry can land. |
115
119
  | Topic webhook | 200 | Handled, a repeat (`deduped: true`), or a topic with no handler (`unhandled: true`). |
@@ -22,6 +22,11 @@ export interface CoreDelivery {
22
22
  rawBody: Uint8Array | Buffer | string;
23
23
  headers: CoreHeaders;
24
24
  }
25
+ /** Layer-1 input for the topic webhook receiver. `GET` and `HEAD` are the review probe; any other method is a delivery. */
26
+ export interface WebhookDelivery extends CoreDelivery {
27
+ /** Defaults to `"POST"`. */
28
+ method?: string;
29
+ }
25
30
  /** Layer-1 input for the install handoff, which genuinely needs routing. */
26
31
  export interface InstallDelivery extends CoreDelivery {
27
32
  /** Defaults to `"POST"`. Anything else answers 405. */
@@ -57,5 +62,18 @@ export type DeliveryErrorHandler = (error: unknown, failure: DeliveryFailure) =>
57
62
  * response.
58
63
  */
59
64
  export declare function reportDeliveryFailure(onError: DeliveryErrorHandler | undefined, error: unknown, failure: DeliveryFailure): void;
60
- /** Build the layer-2 `Response` from a layer-1 result. */
61
- export declare function toResponse(result: DeliveryResult): Response;
65
+ /** Build the layer-2 `Response` from a layer-1 result. A `HEAD` request gets the same status and headers with no body. */
66
+ export declare function toResponse(result: DeliveryResult, method?: string): Response;
67
+ /** True for the methods of the review probe (`GET`, `HEAD`). */
68
+ export declare function isProbeMethod(method: string | undefined): boolean;
69
+ /**
70
+ * True when a probe carries no signature, the plain "is anything listening"
71
+ * check: it answers 200 without touching storage or any callback. A probe
72
+ * with a `webhook-signature` header is verified like a delivery.
73
+ */
74
+ export declare function isUnsignedProbe(headers: CoreHeaders): boolean;
75
+ /**
76
+ * The answer to a review probe: never cached, and never the result of an
77
+ * app callback. `error` is set for a refused (401) probe.
78
+ */
79
+ export declare function probeResult(status: 200 | 401, error?: string): DeliveryResult;
package/dist/delivery.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createLogger } from "./logger.js";
2
+ import { WEBHOOK_SIGNATURE_HEADER } from "./signatures.js";
2
3
  /** Case-insensitive header lookup across `Headers` and plain (possibly lowercased, possibly array-valued) objects. */
3
4
  export function readHeader(headers, name) {
4
5
  if (headers instanceof Headers) {
@@ -45,7 +46,34 @@ export function reportDeliveryFailure(onError, error, failure) {
45
46
  // Reporting must never change the HTTP answer.
46
47
  }
47
48
  }
48
- /** Build the layer-2 `Response` from a layer-1 result. */
49
- export function toResponse(result) {
50
- return Response.json(result.body, { status: result.status, headers: result.headers });
49
+ /** Build the layer-2 `Response` from a layer-1 result. A `HEAD` request gets the same status and headers with no body. */
50
+ export function toResponse(result, method) {
51
+ const response = Response.json(result.body, { status: result.status, headers: result.headers });
52
+ if (method?.toUpperCase() !== "HEAD")
53
+ return response;
54
+ return new Response(null, { status: response.status, headers: response.headers });
55
+ }
56
+ /** True for the methods of the review probe (`GET`, `HEAD`). */
57
+ export function isProbeMethod(method) {
58
+ const upper = (method ?? "POST").toUpperCase();
59
+ return upper === "GET" || upper === "HEAD";
60
+ }
61
+ /**
62
+ * True when a probe carries no signature, the plain "is anything listening"
63
+ * check: it answers 200 without touching storage or any callback. A probe
64
+ * with a `webhook-signature` header is verified like a delivery.
65
+ */
66
+ export function isUnsignedProbe(headers) {
67
+ return !readHeader(headers, WEBHOOK_SIGNATURE_HEADER);
68
+ }
69
+ /**
70
+ * The answer to a review probe: never cached, and never the result of an
71
+ * app callback. `error` is set for a refused (401) probe.
72
+ */
73
+ export function probeResult(status, error) {
74
+ return {
75
+ status,
76
+ body: status === 200 ? { ok: true } : { ok: false, error },
77
+ headers: { "Cache-Control": "no-store" },
78
+ };
51
79
  }
package/dist/hono.d.ts CHANGED
@@ -14,9 +14,12 @@ import { type WebhookHandlerOptions } from "./webhooks.js";
14
14
  export type { InstallCallbacks, InstallHandlerOptions } from "./install-handlers.js";
15
15
  export type { AiToolRequestOptions, ProxyRequestOptions, ProxyResponder } from "./proxy.js";
16
16
  export type { QueekWebhookEnvelope, SecretResolution, WebhookHandlerContext, WebhookHandlerFn, WebhookHandlerOptions, } from "./webhooks.js";
17
- /** Mount the signed install/uninstall/settings handoff (`POST /install`, `POST /uninstall`, `POST /settings`). */
17
+ /**
18
+ * Mount the signed install/uninstall/settings handoff (`POST /install`, `POST /uninstall`, `POST /settings`).
19
+ * `GET` and `HEAD` on the same paths answer Queek's app-review probe (no callback runs).
20
+ */
18
21
  export declare function createInstallHandlers(options: InstallHandlerOptions): Hono;
19
- /** Mount the signed topic-delivery receiver (`POST /`). */
22
+ /** Mount the signed topic-delivery receiver (`POST /`). `GET` and `HEAD` answer Queek's app-review probe. */
20
23
  export declare function createWebhookHandler(options: WebhookHandlerOptions): Hono;
21
24
  /**
22
25
  * Mount the signed app-proxy reader (`GET` only, proxy reads are
package/dist/hono.js CHANGED
@@ -11,18 +11,26 @@ import { Hono } from "hono";
11
11
  import { handleInstallRequest } from "./install-handlers.js";
12
12
  import { handleAiToolRequest, handleProxyRequest, } from "./proxy.js";
13
13
  import { handleWebhookRequest } from "./webhooks.js";
14
- /** Mount the signed install/uninstall/settings handoff (`POST /install`, `POST /uninstall`, `POST /settings`). */
14
+ /**
15
+ * Mount the signed install/uninstall/settings handoff (`POST /install`, `POST /uninstall`, `POST /settings`).
16
+ * `GET` and `HEAD` on the same paths answer Queek's app-review probe (no callback runs).
17
+ */
15
18
  export function createInstallHandlers(options) {
16
19
  const app = new Hono();
17
20
  app.post("/install", (c) => handleInstallRequest(c.req.raw, options));
18
21
  app.post("/uninstall", (c) => handleInstallRequest(c.req.raw, options));
19
22
  app.post("/settings", (c) => handleInstallRequest(c.req.raw, options));
23
+ // Hono answers HEAD from the GET route; layer 2 handles both methods.
24
+ app.get("/install", (c) => handleInstallRequest(c.req.raw, options));
25
+ app.get("/uninstall", (c) => handleInstallRequest(c.req.raw, options));
26
+ app.get("/settings", (c) => handleInstallRequest(c.req.raw, options));
20
27
  return app;
21
28
  }
22
- /** Mount the signed topic-delivery receiver (`POST /`). */
29
+ /** Mount the signed topic-delivery receiver (`POST /`). `GET` and `HEAD` answer Queek's app-review probe. */
23
30
  export function createWebhookHandler(options) {
24
31
  const app = new Hono();
25
32
  app.post("/", (c) => handleWebhookRequest(c.req.raw, options));
33
+ app.get("/", (c) => handleWebhookRequest(c.req.raw, options));
26
34
  return app;
27
35
  }
28
36
  /**
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { backgroundTaskCount, type CatchupFailure, type CatchupInstallation, typ
14
14
  export { apiHostsFromEnv, type CursorPage, createQueekClient, DEFAULT_API_HOSTS, DEFAULT_MAX_RETRY_AFTER_MS, DEFAULT_REQUEST_TIMEOUT_MS, devApiHostsFromEnv, InvalidApiBaseError, isWriteMethod, MERCHANT_API_PATH, type MerchantPaths, newIdempotencyKey, type OperationResponse, type PaginateOptions, paginate, QueekApiError, type QueekClient, type QueekClientOptions, type QueekErrorDetails, queekApiErrorFromResponse, type RequestOptions, type ResolvedPaths, type RetryOptions, resolveApiBase, type StoreProfile, } from "./client.js";
15
15
  export { type AlertBody, type AlertSeverity, type CollectedDefinition, type CollectedDefinitionCreate, type CollectedDefinitionUpdate, type CollectedField, collectedDefinitions, createRecord, type MerchantAlert, type SetupNotice, type SetupNoticeBody, type SetupNoticeItem, type SetupNoticeStatus, type SuccessEnvelope, sendAlert, setSetupNotice, } from "./collected.js";
16
16
  export { decryptSecret, encryptSecret, parseStoreKey, storeKeyFingerprint, } from "./crypto.js";
17
- export { type CoreDelivery, type CoreHeaders, type DeliveryResult, decodeBody, type InstallDelivery, readHeader, } from "./delivery.js";
17
+ export { type CoreDelivery, type CoreHeaders, type DeliveryResult, decodeBody, type InstallDelivery, readHeader, type WebhookDelivery, } from "./delivery.js";
18
18
  export { type AiAction, type AiActionValue, APP_SOURCE, type AppInboundMessage, type AppOutboundMessage, BRIDGE_VERSION, type BridgeTheme, buildReadyMessage, capText, clipOutbound, DASHBOARD_SOURCE, type EmbedEvent, type EmbedEventTarget, type EmbedPostTarget, type FrameBridgeOptions, isAllowedImageUrl, isAllowedOpenTarget, listenToDashboard, MAX_ACTION_ID_LENGTH, MAX_CAPABILITIES, MAX_CAPABILITY_LENGTH, MAX_FILTER_LENGTH, MAX_HEADING_LENGTH, MAX_IMAGE_URL_LENGTH, MAX_LABEL_LENGTH, MAX_LOCALE_LENGTH, MAX_PATH_LENGTH, MAX_PICKED_ITEMS, MAX_REQUEST_ID_LENGTH, MAX_RESIZE_HEIGHT, MAX_RESOURCE_TITLE_LENGTH, MAX_SDK_VERSION_LENGTH, MAX_SECONDARY_ACTIONS, MAX_SELECTION_ID_LENGTH, MAX_SELECTION_IDS, MAX_TARGET_LENGTH, MAX_TOAST_DURATION_MS, MAX_TOAST_LENGTH, onAiAction, type PickResourceRequest, parseInboundMessage, parseOutboundMessage, type ReadyOptions, type ResourceItem, type SaveBarAction, type SaveBarState, sendAck, sendBridgeMessage, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, sendToast, type ThemeMode, type TitleActionDef, type TitleActionTone, type ToastTone, } from "./frame.js";
19
19
  export { type HandoffEnvelope, type HandoffEnvelopeAny, type HandoffInstallationRef, type HandoffPidRef, type HandoffStore, INSTALL_EVENT, type InstallData, type InstallEnvelope, RESYNC_EVENT, type ResyncEnvelope, SCOPES_UPDATE_EVENT, type ScopesUpdateData, type ScopesUpdateEnvelope, SETTINGS_EVENT, type SettingsData, type SettingsEnvelope, UNINSTALL_EVENT, type UninstallData, type UninstallEnvelope, } from "./handoff.js";
20
20
  export { buildInstallationRecord, handleInstallDelivery, handleInstallRequest, type InstallCallbacks, type InstallHandlerOptions, installationScopesEqual, saveResyncedInstallation, saveScopesUpdateInstallation, } from "./install-handlers.js";
@@ -105,6 +105,11 @@ export declare function saveResyncedInstallation(existing: InstallationRecord, d
105
105
  * `webhook-id`, 404 for an unknown route, and 500 when your callback or the
106
106
  * store write throws (Queek then retries).
107
107
  *
108
+ * `GET` and `HEAD` on a route are the review probe: 200 `{ ok: true }` with
109
+ * `Cache-Control: no-store` and no callback; with a signature (over the
110
+ * empty body) 200 when it verifies and 401 when not. Any other method
111
+ * answers 405.
112
+ *
108
113
  * @example
109
114
  * const result = await handleInstallDelivery(
110
115
  * { rawBody, headers, method: "POST", path: "/queek/install" },
@@ -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 { INSTALL_EVENT, RESYNC_EVENT, SCOPES_UPDATE_EVENT, SETTINGS_EVENT, UNINSTALL_EVENT, } from "./handoff.js";
3
3
  import { MAX_TIMESTAMP_SKEW_SECONDS, verifyQueekSignatureDetailed, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, } from "./signatures.js";
4
4
  import { normaliseNullablePid, normalisePid } from "./store.js";
@@ -112,6 +112,11 @@ export function saveResyncedInstallation(existing, data, nowIso = new Date().toI
112
112
  * `webhook-id`, 404 for an unknown route, and 500 when your callback or the
113
113
  * store write throws (Queek then retries).
114
114
  *
115
+ * `GET` and `HEAD` on a route are the review probe: 200 `{ ok: true }` with
116
+ * `Cache-Control: no-store` and no callback; with a signature (over the
117
+ * empty body) 200 when it verifies and 401 when not. Any other method
118
+ * answers 405.
119
+ *
115
120
  * @example
116
121
  * const result = await handleInstallDelivery(
117
122
  * { rawBody, headers, method: "POST", path: "/queek/install" },
@@ -120,10 +125,13 @@ export function saveResyncedInstallation(existing, data, nowIso = new Date().toI
120
125
  * // reply with result.status and result.body
121
126
  */
122
127
  export async function handleInstallDelivery(input, options) {
128
+ const route = trailingSegment(input.path ?? "");
129
+ if (isProbeMethod(input.method)) {
130
+ return ROUTES.has(route) ? probeDelivery(input.headers, options) : UNKNOWN_ROUTE;
131
+ }
123
132
  if ((input.method ?? "POST").toUpperCase() !== "POST") {
124
133
  return { status: 405, body: { ok: false, error: "method_not_allowed" } };
125
134
  }
126
- const route = trailingSegment(input.path ?? "");
127
135
  const rawBody = decodeBody(input.rawBody);
128
136
  if (route === "install")
129
137
  return installDelivery(rawBody, input.headers, options);
@@ -131,7 +139,21 @@ export async function handleInstallDelivery(input, options) {
131
139
  return uninstallDelivery(rawBody, input.headers, options);
132
140
  if (route === "settings")
133
141
  return settingsDelivery(rawBody, input.headers, options);
134
- return { status: 404, body: { ok: false, error: "unknown_route" } };
142
+ return UNKNOWN_ROUTE;
143
+ }
144
+ const ROUTES = new Set(["install", "uninstall", "settings"]);
145
+ const UNKNOWN_ROUTE = { status: 404, body: { ok: false, error: "unknown_route" } };
146
+ /**
147
+ * The review probe (`GET` / `HEAD` on a handoff route): answers 200 without
148
+ * running a callback or touching the store. A probe that carries a
149
+ * signature goes through the SAME `guard` as a delivery, over the empty
150
+ * body: 200 when it verifies, 401 when not. A valid probe claims nothing.
151
+ */
152
+ async function probeDelivery(headers, options) {
153
+ if (isUnsignedProbe(headers))
154
+ return probeResult(200);
155
+ const checked = await guard(options, headers, "");
156
+ return checked.ok ? probeResult(200) : probeResult(401, checked.reason);
135
157
  }
136
158
  /** Last path segment of a pathname or full URL (query/fragment stripped, trailing slashes ignored). */
137
159
  function trailingSegment(path) {
@@ -154,7 +176,7 @@ export async function handleInstallRequest(request, options) {
154
176
  headers: request.headers,
155
177
  method: request.method,
156
178
  path: new URL(request.url).pathname,
157
- }, options));
179
+ }, options), request.method);
158
180
  }
159
181
  async function guard(options, headers, rawBody) {
160
182
  const id = readHeader(headers, WEBHOOK_ID_HEADER);