@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 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
 
@@ -99,7 +99,11 @@ The handlers come in three layers that behave identically:
99
99
 
100
100
  1. `handleInstallDelivery(input, options)` and `handleWebhookDelivery(input, options)` take raw body bytes plus headers and return `{ status, body }`. They use no request or response types, so any framework works.
101
101
  2. `handleInstallRequest(request, options)` and `handleWebhookRequest(request, options)` adapt a Web `Request` to a `Response`.
102
- 3. `createInstallHandlers`, `createWebhookHandler` and `createProxyHandler` from `@usequeek/app-sdk/hono`.
102
+ 3. `createInstallHandlers`, `createWebhookHandler`, `createProxyHandler` and `createAiToolHandler` from `@usequeek/app-sdk/hono`.
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.
103
107
 
104
108
  ### Status codes
105
109
 
@@ -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`). |
@@ -429,6 +433,73 @@ export async function GET(request: Request) {
429
433
 
430
434
  The canonical string is `path`, `shop`, `ts` and the sorted, percent-encoded query pairs (without `sig`), joined with newlines. The signature is a hex HMAC-SHA256 keyed with the full `whsec_…` string, with no base64 decode. `kid` selects the installation, timestamps may skew by 5 minutes, each `jti` is single-use, and only `GET` is served. With Hono, mount `createProxyHandler({ store, path, onVerified })`.
431
435
 
436
+ Queek also signs a `surface` on every proxy call: `storefront` (a shopper), `dashboard` (your merchant page) or `ai` (Queek AI). Only Queek sets it, and it is covered by the signature, so a shopper cannot choose it. A handler that serves merchant data must pass `expectedSurface: "dashboard"`, which answers 403 `surface_mismatch` for a genuine call signed for another surface. `handleProxyRequest` never hands a `surface=ai` call to your responder.
437
+
438
+ ## Queek AI tools and actions
439
+
440
+ Declare an `ai` section (`ManifestAi`) in your manifest and Queek AI, the assistant in the merchant dashboard, can use your app in two small ways. It needs no scope. The merchant approves it with the install, and again when you add or change a tool or action.
441
+
442
+ ```toml
443
+ [ai]
444
+ summary = "Takes bookings for services."
445
+
446
+ [[ai.tools]]
447
+ key = "upcoming_bookings"
448
+ description = "Upcoming bookings, soonest first."
449
+
450
+ [[ai.tools.inputs]]
451
+ key = "days"
452
+ type = "integer"
453
+ label = "Days ahead"
454
+ min = 1
455
+ max = 30
456
+
457
+ [ai.tools.output]
458
+ list = true
459
+
460
+ [[ai.tools.output.fields]]
461
+ key = "date"
462
+ type = "date"
463
+
464
+ [[ai.actions]]
465
+ key = "add_date_exception"
466
+ description = "Open the Hours page with a date filled in."
467
+ page = "/admin/hours"
468
+
469
+ [[ai.actions.inputs]]
470
+ key = "date"
471
+ type = "date"
472
+ label = "Date"
473
+ required = true
474
+ ```
475
+
476
+ Tools read. Queek calls your proxy as a signed `GET ai/{key}` with `surface=ai` and the tool's inputs. Serve them with `handleAiToolRequest(request, tool, { subpath, secrets, tools })`, or mount `createAiToolHandler({ subpath, secrets, tools })` from `@usequeek/app-sdk/hono` at the `ai` segment of your proxy. `secrets(kid)` returns the proxy secrets of that installation, current first:
477
+
478
+ ```ts
479
+ import { createAiToolHandler } from "@usequeek/app-sdk/hono";
480
+
481
+ app.route(
482
+ "/proxy/ai",
483
+ createAiToolHandler({
484
+ subpath: "booking", // the manifest extensions.proxy.subpath
485
+ secrets: async (kid) => proxySecretsFor(kid),
486
+ tools: { upcoming_bookings: async (call) => ({ rows: await upcoming(call.kid, call.params.days) }) },
487
+ }),
488
+ );
489
+ ```
490
+
491
+ A `surface=ai` call is served only by a declared tool handler (403 otherwise), and a storefront or dashboard call at that path is refused. Answer an object, never a bare array: `{ rows: [...] }` for a list tool (`output.list: true`), the declared fields object otherwise. A handler that answers anything else gets a 500. Return only the declared output fields, because Queek drops the rest. Tool inputs are structured, with no free text. `call.actorRole` is the member's role, never a user id. Pass `replayStore` (your installation store) to make each call single-use: the `jti` is claimed like the storefront proxy does, and a replay answers 401. Without it a captured call can be replayed for 5 minutes, which for a read-only tool repeats only the read.
492
+
493
+ Actions never write. Queek AI only opens your `page` (a path under your merchant page) with the values filled in, and shows "Opened by Queek AI, check these values before saving" above it. Receive the values with `onAiAction`, stage them in your form, show them editable, and write nothing until the merchant presses your own Save:
494
+
495
+ ```ts
496
+ import { onAiAction } from "@usequeek/app-sdk/browser";
497
+
498
+ onAiAction(({ action, values }) => stage(action, values), { dashboardOrigin });
499
+ ```
500
+
501
+ `MANIFEST_AI_RESERVED_INPUT_KEYS` lists the input names Queek signs or owns (`sig`, `ts`, `surface`, `order_id` and more), and `isReservedAiInputKey` checks one. A manifest that declares an input with such a name, or any `logged_in_customer_` name, is refused. A setting can opt in to being changed by Queek AI with `ai_editable: true` (`ManifestSetting`, `aiEditableSettingKeys`). A `secret` never can, and each change still needs the merchant's confirmation.
502
+
432
503
  ## Scopes
433
504
 
434
505
  Apps declare required scopes (granted at install) and optional scopes (requested later, revocable).
@@ -469,7 +540,8 @@ Every export has JSDoc in its type definitions. By module:
469
540
  | `install-handlers` | `handleInstallDelivery`, `handleInstallRequest`, `saveResyncedInstallation` |
470
541
  | `webhooks` | `handleWebhookDelivery`, `handleWebhookRequest` |
471
542
  | `signatures` | `verifyQueekSignature`, `verifyQueekSignatureDetailed`, `signQueekPayload` |
472
- | `proxy` | `verifyProxyQuery`, `verifyProxyDelivery`, `handleProxyRequest` |
543
+ | `proxy` | `verifyProxyQuery`, `verifyProxyDelivery`, `handleProxyRequest`, `handleAiToolRequest` |
544
+ | `manifest` | `ManifestSetting`, `ManifestAi` and its parts, `aiEditableSettingKeys`, `isReservedAiInputKey` |
473
545
  | `store` | `SqliteInstallationStore`, `PostgresInstallationStore`, `createInstallationStore` |
474
546
  | `resync`, `background` | `resyncFromQueek`, `runInstallationCatchup`, `detach` |
475
547
  | `scopes` | `createInstallationScopesClient`, `AppScopeRequiredError` |
package/dist/browser.d.ts CHANGED
@@ -20,5 +20,5 @@
20
20
  * `react` dependency, so this entry can never silently regress.
21
21
  */
22
22
  export { type AuthFetchOptions, BRIDGE_TOKEN_TIMEOUT_MS, type InstalledAuth, installAuthFetch, LAUNCH_TOKEN_PARAM, readLaunchToken, stripLaunchToken, } from "./auth-fetch.js";
23
- export { APP_SOURCE, type AppInboundMessage, type AppOutboundMessage, BRIDGE_VERSION, type BridgeTheme, DASHBOARD_SOURCE, type EmbedEvent, type EmbedEventTarget, type EmbedPostTarget, type FrameBridgeOptions, listenToDashboard, type PickResourceRequest, type ReadyOptions, type ResourceItem, type SaveBarAction, type SaveBarState, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, type ThemeMode, type TitleActionDef, } from "./frame.js";
23
+ export { type AiAction, type AiActionValue, APP_SOURCE, type AppInboundMessage, type AppOutboundMessage, BRIDGE_VERSION, type BridgeTheme, DASHBOARD_SOURCE, type EmbedEvent, type EmbedEventTarget, type EmbedPostTarget, type FrameBridgeOptions, listenToDashboard, onAiAction, type PickResourceRequest, type ReadyOptions, type ResourceItem, type SaveBarAction, type SaveBarState, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, type ThemeMode, type TitleActionDef, } from "./frame.js";
24
24
  export { applyTheme, getThemeModeFromUrl, installThemeListener, rememberThemeMode, syncThemeUrl, THEME_PARAM, THEME_STORAGE_KEY, type ThemeDocument, type ThemeDocumentElement, type ThemeListenerOptions, type ThemeStorage, type ThemeUrlOptions, themeBootstrapScript, } from "./theme.js";
package/dist/browser.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * `react` dependency, so this entry can never silently regress.
21
21
  */
22
22
  export { BRIDGE_TOKEN_TIMEOUT_MS, installAuthFetch, LAUNCH_TOKEN_PARAM, readLaunchToken, stripLaunchToken, } from "./auth-fetch.js";
23
- export { APP_SOURCE, BRIDGE_VERSION, DASHBOARD_SOURCE, listenToDashboard, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, } from "./frame.js";
23
+ export { APP_SOURCE, BRIDGE_VERSION, DASHBOARD_SOURCE, listenToDashboard, onAiAction, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, } from "./frame.js";
24
24
  // NOTE: ThemeMode comes from ./frame.js only — theme.js merely re-exports
25
25
  // it, so listing it here too would be ambiguous.
26
26
  export { applyTheme, getThemeModeFromUrl, installThemeListener, rememberThemeMode, syncThemeUrl, THEME_PARAM, THEME_STORAGE_KEY, themeBootstrapScript, } from "./theme.js";
@@ -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/frame.d.ts CHANGED
@@ -121,6 +121,16 @@ export type AppOutboundMessage = {
121
121
  selectionIds?: string[];
122
122
  requestId?: string;
123
123
  };
124
+ /** A value Queek AI filled into an action input: the declared types only, never an object. */
125
+ export type AiActionValue = string | number | boolean;
126
+ /**
127
+ * Queek AI opened one of the app's declared `ai.actions` and sent its values. The values are a SUGGESTION: stage them
128
+ * in the form, show them editable, and write nothing until the merchant presses the app's own Save.
129
+ */
130
+ export interface AiAction {
131
+ action: string;
132
+ values: Record<string, AiActionValue>;
133
+ }
124
134
  export type AppInboundMessage = {
125
135
  source: typeof DASHBOARD_SOURCE;
126
136
  type: "token";
@@ -156,7 +166,10 @@ export type AppInboundMessage = {
156
166
  source: typeof DASHBOARD_SOURCE;
157
167
  type: "resource-pick-cancelled";
158
168
  requestId?: string;
159
- };
169
+ } | ({
170
+ source: typeof DASHBOARD_SOURCE;
171
+ type: "ai-action";
172
+ } & AiAction);
160
173
  export interface EmbedEvent {
161
174
  origin: string;
162
175
  data: unknown;
@@ -188,6 +201,7 @@ export interface FrameBridgeOptions {
188
201
  onNavigate?: (path: string) => void;
189
202
  onResourcePicked?: (items: ResourceItem[], requestId?: string) => void;
190
203
  onResourcePickCancelled?: (requestId?: string) => void;
204
+ onAiAction?: (action: AiAction) => void;
191
205
  }
192
206
  export interface ReadyOptions {
193
207
  capabilities?: string[];
@@ -276,6 +290,14 @@ export declare function sendPickResource(dashboardOrigin: string, target: EmbedP
276
290
  * Never touches any secret.
277
291
  */
278
292
  export declare function listenToDashboard(options: FrameBridgeOptions): () => void;
293
+ /**
294
+ * Receive the action Queek AI opened this page for, from the exact dashboard origin only (the same gate as the token).
295
+ * Like `installAuthFetch`, it listens on the global window and accepts only messages from the embedding parent
296
+ * window; pass `target` / `expectSource` to override (`expectSource: null` turns the source check off).
297
+ * Stage `values` in your form and show them editable; NEVER save on load, only the merchant's own Save writes.
298
+ * Returns an unsubscribe function.
299
+ */
300
+ export declare function onAiAction(handler: (action: AiAction) => void, options: Pick<FrameBridgeOptions, "dashboardOrigin" | "target" | "expectSource">): () => void;
279
301
  /**
280
302
  * The single choke point for everything the app posts: caps every string,
281
303
  * allow-lists every enum, clamps every number, re-gates `open` targets
package/dist/frame.js CHANGED
@@ -64,6 +64,39 @@ function reqText(value, max) {
64
64
  }
65
65
  return capText(value, max);
66
66
  }
67
+ const AI_KEY = /^[a-z][a-z0-9_]{0,39}$/;
68
+ const MAX_AI_ACTION_VALUES = 8;
69
+ const MAX_AI_TEXT_LENGTH = 500;
70
+ /** One `ai-action` message: a declared key, and up to 8 scalar values (a `null` is an unset input and is dropped). */
71
+ function parseAiAction(data) {
72
+ const { action, values } = data;
73
+ if (typeof action !== "string" || !AI_KEY.test(action)) {
74
+ return null;
75
+ }
76
+ if (!isRecord(values) || Array.isArray(values)) {
77
+ return null;
78
+ }
79
+ const entries = Object.entries(values).filter(([, value]) => value !== null);
80
+ if (entries.length > MAX_AI_ACTION_VALUES) {
81
+ return null;
82
+ }
83
+ const clean = {};
84
+ for (const [key, value] of entries) {
85
+ if (!AI_KEY.test(key)) {
86
+ return null;
87
+ }
88
+ if (typeof value === "string") {
89
+ clean[key] = capText(value, MAX_AI_TEXT_LENGTH);
90
+ }
91
+ else if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) {
92
+ clean[key] = value;
93
+ }
94
+ else {
95
+ return null;
96
+ }
97
+ }
98
+ return { action, values: clean };
99
+ }
67
100
  function isThemeMode(value) {
68
101
  return value === "light" || value === "dark";
69
102
  }
@@ -283,6 +316,10 @@ export function parseInboundMessage(data) {
283
316
  }
284
317
  return cancelled;
285
318
  }
319
+ case "ai-action": {
320
+ const aiAction = parseAiAction(data);
321
+ return aiAction === null ? null : { source: DASHBOARD_SOURCE, type: "ai-action", ...aiAction };
322
+ }
286
323
  default:
287
324
  return null;
288
325
  }
@@ -522,7 +559,7 @@ export function sendPickResource(dashboardOrigin, target, request) {
522
559
  * Never touches any secret.
523
560
  */
524
561
  export function listenToDashboard(options) {
525
- const { dashboardOrigin, onToken, onResizeAck, onTheme, onTitleAction, onSaveBarAction, onNavigate, onResourcePicked, onResourcePickCancelled, } = options;
562
+ const { dashboardOrigin, onToken, onResizeAck, onTheme, onTitleAction, onSaveBarAction, onNavigate, onResourcePicked, onResourcePickCancelled, onAiAction, } = options;
526
563
  const target = options.target;
527
564
  const expectSource = options.expectSource;
528
565
  const onMessage = (event) => {
@@ -572,11 +609,32 @@ export function listenToDashboard(options) {
572
609
  case "resource-pick-cancelled":
573
610
  onResourcePickCancelled?.(message.requestId);
574
611
  break;
612
+ case "ai-action":
613
+ onAiAction?.({ action: message.action, values: message.values });
614
+ break;
575
615
  }
576
616
  };
577
617
  target?.addEventListener("message", onMessage);
578
618
  return () => target?.removeEventListener("message", onMessage);
579
619
  }
620
+ /**
621
+ * Receive the action Queek AI opened this page for, from the exact dashboard origin only (the same gate as the token).
622
+ * Like `installAuthFetch`, it listens on the global window and accepts only messages from the embedding parent
623
+ * window; pass `target` / `expectSource` to override (`expectSource: null` turns the source check off).
624
+ * Stage `values` in your form and show them editable; NEVER save on load, only the merchant's own Save writes.
625
+ * Returns an unsubscribe function.
626
+ */
627
+ export function onAiAction(handler, options) {
628
+ const scope = globalThis;
629
+ const win = scope.window?.location !== undefined ? scope.window : undefined;
630
+ const expectSource = options.expectSource !== undefined ? options.expectSource : win?.parent;
631
+ return listenToDashboard({
632
+ dashboardOrigin: options.dashboardOrigin,
633
+ target: options.target ?? win,
634
+ expectSource: expectSource ?? undefined,
635
+ onAiAction: handler,
636
+ });
637
+ }
580
638
  /** Validate a title-bar action shape, then cap its strings. */
581
639
  function clipTitleActionDef(value) {
582
640
  const def = parseTitleActionDef(value);
package/dist/hono.d.ts CHANGED
@@ -9,14 +9,17 @@
9
9
  */
10
10
  import { Hono } from "hono";
11
11
  import { type InstallHandlerOptions } from "./install-handlers.js";
12
- import { type ProxyRequestOptions, type ProxyResponder } from "./proxy.js";
12
+ import { type AiToolRequestOptions, type ProxyRequestOptions, type ProxyResponder } from "./proxy.js";
13
13
  import { type WebhookHandlerOptions } from "./webhooks.js";
14
14
  export type { InstallCallbacks, InstallHandlerOptions } from "./install-handlers.js";
15
- export type { ProxyRequestOptions, ProxyResponder } from "./proxy.js";
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
@@ -29,3 +32,12 @@ export declare function createWebhookHandler(options: WebhookHandlerOptions): Ho
29
32
  export declare function createProxyHandler(options: ProxyRequestOptions & {
30
33
  onVerified: ProxyResponder;
31
34
  }): Hono;
35
+ /**
36
+ * Mount the Queek AI read tools (`GET /:tool`) — a thin wrapper built ONLY on
37
+ * `handleAiToolRequest`. Mount it at the `ai` segment of the proxy
38
+ * (`app.route("/proxy/ai", createAiToolHandler({ subpath, secrets, tools }))`);
39
+ * `subpath` is the manifest `extensions.proxy.subpath`, `secrets` resolves the
40
+ * proxy secrets of installation `kid`, and `tools` holds the handlers of the
41
+ * manifest `ai.tools`.
42
+ */
43
+ export declare function createAiToolHandler(options: AiToolRequestOptions): Hono;
package/dist/hono.js CHANGED
@@ -9,20 +9,28 @@
9
9
  */
10
10
  import { Hono } from "hono";
11
11
  import { handleInstallRequest } from "./install-handlers.js";
12
- import { handleProxyRequest } from "./proxy.js";
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
  /**
@@ -39,3 +47,16 @@ export function createProxyHandler(options) {
39
47
  app.get("*", (c) => handleProxyRequest(c.req.raw, requestOptions, onVerified));
40
48
  return app;
41
49
  }
50
+ /**
51
+ * Mount the Queek AI read tools (`GET /:tool`) — a thin wrapper built ONLY on
52
+ * `handleAiToolRequest`. Mount it at the `ai` segment of the proxy
53
+ * (`app.route("/proxy/ai", createAiToolHandler({ subpath, secrets, tools }))`);
54
+ * `subpath` is the manifest `extensions.proxy.subpath`, `secrets` resolves the
55
+ * proxy secrets of installation `kid`, and `tools` holds the handlers of the
56
+ * manifest `ai.tools`.
57
+ */
58
+ export function createAiToolHandler(options) {
59
+ const app = new Hono();
60
+ app.get("/:tool", (c) => handleAiToolRequest(c.req.raw, c.req.param("tool"), options));
61
+ return app;
62
+ }
package/dist/index.d.ts CHANGED
@@ -14,12 +14,13 @@ 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";
18
- export { 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, 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";
17
+ export { type CoreDelivery, type CoreHeaders, type DeliveryResult, decodeBody, type InstallDelivery, readHeader, type WebhookDelivery, } from "./delivery.js";
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";
21
21
  export { createLogger, type LogFields, type Logger, type LoggerOptions, type LogLevel, REDACTED, } from "./logger.js";
22
- export { buildProxyCanonicalString, handleProxyRequest, PROXY_CUSTOMER_PARAM, PROXY_KID_PARAM, PROXY_MAX_SKEW_SECONDS, PROXY_NONCE_PARAM, PROXY_SHOP_PARAM, PROXY_SIGNATURE_PARAM, PROXY_TIMESTAMP_PARAM, type ProxyFailure, type ProxyQuery, type ProxyRequestOptions, type ProxyResponder, type ProxyStoreOptions, type ProxyVerification, type ProxyVerifyOptions, signProxyQuery, verifyProxyDelivery, verifyProxyQuery, verifyProxyQueryDetailed, } from "./proxy.js";
22
+ export { aiEditableSettingKeys, isReservedAiInputKey, MANIFEST_AI_ACTION_INPUT_TYPES, MANIFEST_AI_OUTPUT_TYPES, MANIFEST_AI_RESERVED_INPUT_KEYS, MANIFEST_AI_RESERVED_INPUT_PREFIX, MANIFEST_AI_TOOL_INPUT_TYPES, MANIFEST_SETTING_TYPES, type ManifestAi, type ManifestAiAction, type ManifestAiInput, type ManifestAiInputType, type ManifestAiOutputField, type ManifestAiOutputType, type ManifestAiTool, type ManifestSetting, type ManifestSettingType, } from "./manifest.js";
23
+ export { type AiToolCall, type AiToolHandler, type AiToolOutput, type AiToolRequestOptions, buildProxyCanonicalString, handleAiToolRequest, handleProxyRequest, PROXY_CUSTOMER_PARAM, PROXY_KID_PARAM, PROXY_MAX_SKEW_SECONDS, PROXY_NONCE_PARAM, PROXY_SHOP_PARAM, PROXY_SIGNATURE_PARAM, PROXY_SURFACE_PARAM, PROXY_TIMESTAMP_PARAM, type ProxyFailure, type ProxyQuery, type ProxyRequestOptions, type ProxyResponder, type ProxyStoreOptions, type ProxySurface, type ProxyVerification, type ProxyVerifyOptions, signProxyQuery, verifyProxyDelivery, verifyProxyQuery, verifyProxyQueryDetailed, } from "./proxy.js";
23
24
  export { type ResyncListItem, type ResyncOptions, type ResyncResult, resyncFromQueek, } from "./resync.js";
24
25
  export { APP_SCOPE_REQUIRED_CODE, APP_SCOPE_UNDECLARED_CODE, AppScopeRequiredError, appScopeRequiredFromError, buildScopeRequestLink, createInstallationScopesClient, type InstallationScopes, type InstallationScopesClient, type InstallationScopesClientOptions, InvalidScopesError, isAppScopeRequired, MAX_SCOPE_LENGTH, MAX_SCOPE_LIST, normaliseScopeList, type QueryScopesOptions, type RequestScopesLinkOptions, type RevokeScopesOptions, revokeScopesPath, type ScopeRequestLinkOptions, splitInstallationScopes, } from "./scopes.js";
25
26
  export { MAX_TIMESTAMP_SKEW_SECONDS, SECRET_PREFIX, type SignatureFailure, type SignatureInput, secretKeyBytes, signQueekPayload, type VerifyOptions, verifyQueekSignature, verifyQueekSignatureDetailed, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, } from "./signatures.js";
package/dist/index.js CHANGED
@@ -15,11 +15,12 @@ export { apiHostsFromEnv, createQueekClient, DEFAULT_API_HOSTS, DEFAULT_MAX_RETR
15
15
  export { collectedDefinitions, createRecord, sendAlert, setSetupNotice, } from "./collected.js";
16
16
  export { decryptSecret, encryptSecret, parseStoreKey, storeKeyFingerprint, } from "./crypto.js";
17
17
  export { decodeBody, readHeader, } from "./delivery.js";
18
- export { APP_SOURCE, BRIDGE_VERSION, buildReadyMessage, capText, clipOutbound, DASHBOARD_SOURCE, 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, parseInboundMessage, parseOutboundMessage, sendAck, sendBridgeMessage, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, sendToast, } from "./frame.js";
18
+ export { APP_SOURCE, BRIDGE_VERSION, buildReadyMessage, capText, clipOutbound, DASHBOARD_SOURCE, 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, parseInboundMessage, parseOutboundMessage, sendAck, sendBridgeMessage, sendNavigated, sendOpen, sendPickResource, sendReady, sendResize, sendSaveBar, sendTitle, sendToast, } from "./frame.js";
19
19
  export { INSTALL_EVENT, RESYNC_EVENT, SCOPES_UPDATE_EVENT, SETTINGS_EVENT, UNINSTALL_EVENT, } from "./handoff.js";
20
20
  export { buildInstallationRecord, handleInstallDelivery, handleInstallRequest, installationScopesEqual, saveResyncedInstallation, saveScopesUpdateInstallation, } from "./install-handlers.js";
21
21
  export { createLogger, REDACTED, } from "./logger.js";
22
- export { buildProxyCanonicalString, handleProxyRequest, PROXY_CUSTOMER_PARAM, PROXY_KID_PARAM, PROXY_MAX_SKEW_SECONDS, PROXY_NONCE_PARAM, PROXY_SHOP_PARAM, PROXY_SIGNATURE_PARAM, PROXY_TIMESTAMP_PARAM, signProxyQuery, verifyProxyDelivery, verifyProxyQuery, verifyProxyQueryDetailed, } from "./proxy.js";
22
+ export { aiEditableSettingKeys, isReservedAiInputKey, MANIFEST_AI_ACTION_INPUT_TYPES, MANIFEST_AI_OUTPUT_TYPES, MANIFEST_AI_RESERVED_INPUT_KEYS, MANIFEST_AI_RESERVED_INPUT_PREFIX, MANIFEST_AI_TOOL_INPUT_TYPES, MANIFEST_SETTING_TYPES, } from "./manifest.js";
23
+ export { buildProxyCanonicalString, handleAiToolRequest, handleProxyRequest, PROXY_CUSTOMER_PARAM, PROXY_KID_PARAM, PROXY_MAX_SKEW_SECONDS, PROXY_NONCE_PARAM, PROXY_SHOP_PARAM, PROXY_SIGNATURE_PARAM, PROXY_SURFACE_PARAM, PROXY_TIMESTAMP_PARAM, signProxyQuery, verifyProxyDelivery, verifyProxyQuery, verifyProxyQueryDetailed, } from "./proxy.js";
23
24
  export { resyncFromQueek, } from "./resync.js";
24
25
  export { APP_SCOPE_REQUIRED_CODE, APP_SCOPE_UNDECLARED_CODE, AppScopeRequiredError, appScopeRequiredFromError, buildScopeRequestLink, createInstallationScopesClient, InvalidScopesError, isAppScopeRequired, MAX_SCOPE_LENGTH, MAX_SCOPE_LIST, normaliseScopeList, revokeScopesPath, splitInstallationScopes, } from "./scopes.js";
25
26
  export { MAX_TIMESTAMP_SKEW_SECONDS, SECRET_PREFIX, secretKeyBytes, signQueekPayload, verifyQueekSignature, verifyQueekSignatureDetailed, WEBHOOK_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, } from "./signatures.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);