Skip to content

Commit bfb2c33

Browse files
committed
feat(webhooks): key-template fallback operator + provider handshake hook
Two provider-agnostic engine capabilities the Slack channel needs, kept generic so any provider reuses them: - Session key templates gain a first-non-empty fallback: {a || b || c} resolves to the first non-empty path. A Slack message that STARTS a thread has no thread_ts (only replies do), so the natural key {body.team_id}:{body.event.channel}:{body.event.thread_ts || body.event.ts} would otherwise drop the first message of every conversation. Implemented in both the runtime evaluator and the type-level key validator. - A generic handshake hook on the verifier artifact: a signed request whose body matches a declared { matchPath, matchValue } is echoed synchronously (the respondPath value, 200) and NOT recorded or routed. Covers Slack url_verification and Discord PING. ingest() consults it after verify, before the front gate. Unreleased feature, so no released-API impact.
1 parent d80e277 commit bfb2c33

8 files changed

Lines changed: 125 additions & 10 deletions

File tree

apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
5454
case "accepted":
5555
logger.info("webhook ingress accepted", { opaqueId, deliveryId: result.deliveryId });
5656
return json({ received: true, deliveryId: result.deliveryFriendlyId }, { status: 200 });
57+
case "handshake":
58+
// Provider handshake echo (e.g. Slack url_verification): the challenge value, plain text, 200.
59+
return new Response(result.body, { status: 200, headers: { "content-type": "text/plain" } });
5760
case "duplicate":
5861
return json({ received: true, deliveryId: result.deliveryId }, { status: 200 });
5962
case "endpoint_not_found":

internal-packages/webhook-engine/src/engine/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
WebhookDeliverTaskErrorType,
2929
WebhookEngineOptions,
3030
} from "./types.js";
31-
import { evaluateSessionKeyTemplate } from "./sessionKey.js";
31+
import { evaluateSessionKeyTemplate, walkPath as resolveBodyPath } from "./sessionKey.js";
3232

3333
// The deliver job's retry budget (redis-worker DLQs after this many attempts).
3434
const WEBHOOK_DELIVER_MAX_ATTEMPTS = webhookWorkerCatalog["webhook.deliver"].retry.maxAttempts;
@@ -164,6 +164,17 @@ export class WebhookEngine {
164164
return { outcome: "verification_failed", error: verdict.error ?? "invalid" };
165165
}
166166

167+
// Provider handshake (Slack url_verification, Discord PING): a signed request that must get a
168+
// synchronous echo, not a recorded/routed delivery. Generic, declared on the verifier artifact.
169+
const handshake =
170+
"handshake" in parsedArtifact.data ? parsedArtifact.data.handshake : undefined;
171+
if (handshake) {
172+
const event = verdict.parsedEvent as unknown;
173+
if (String(resolveBodyPath(event, handshake.matchPath) ?? "") === handshake.matchValue) {
174+
return { outcome: "handshake", body: String(resolveBodyPath(event, handshake.respondPath) ?? "") };
175+
}
176+
}
177+
167178
// If the scheme carries its secret in a custom header, don't persist that header (auth/cookie
168179
// are dropped unconditionally). Verification above already ran against the full header set.
169180
const secretHeader =

internal-packages/webhook-engine/src/engine/ingest.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,56 @@ containerTestWithIsolatedRedisNoClickhouse(
199199
}
200200
);
201201

202+
containerTestWithIsolatedRedisNoClickhouse(
203+
"a provider handshake (url_verification) echoes the challenge and records no delivery",
204+
async ({ prisma, redisOptions }) => {
205+
const endpoint = await prisma.webhookEndpoint.create({
206+
data: {
207+
friendlyId: WebhookEndpointId.generate().friendlyId,
208+
opaqueId: `op_${randomBytes(12).toString("hex")}`,
209+
organizationId: "org_test",
210+
projectId: "proj_test",
211+
runtimeEnvironmentId: "env_test",
212+
environmentType: "PRODUCTION",
213+
source: "slack",
214+
handlerWebhookId: "handle-slack-channel",
215+
routingTarget: { type: "task", taskId: "unused" },
216+
verifierArtifact: {
217+
kind: "config",
218+
config: VERIFIER_CONFIG,
219+
handshake: { matchPath: "type", matchValue: "url_verification", respondPath: "challenge" },
220+
},
221+
signingSecretKey: SECRET_KEY,
222+
status: "ACTIVE",
223+
},
224+
});
225+
const { triggerTask } = makeTriggerTaskStub();
226+
const engine = buildEngine(prisma, redisOptions, triggerTask);
227+
228+
try {
229+
const body = JSON.stringify({ type: "url_verification", challenge: "chal_xyz" });
230+
const t = Math.floor(Date.now() / 1000);
231+
const sig = createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex");
232+
const result = await engine.ingest({
233+
opaqueId: endpoint.opaqueId,
234+
rawBytes: new TextEncoder().encode(body),
235+
headers: { "stripe-signature": `t=${t},v1=${sig}` },
236+
url: `https://api.example.com/webhooks/v1/ingest/${endpoint.opaqueId}`,
237+
});
238+
239+
expect(result.outcome).toBe("handshake");
240+
if (result.outcome === "handshake") expect(result.body).toBe("chal_xyz");
241+
242+
const count = await prisma.webhookDelivery.count({
243+
where: { webhookEndpointId: endpoint.id },
244+
});
245+
expect(count).toBe(0);
246+
} finally {
247+
await engine.quit();
248+
}
249+
}
250+
);
251+
202252
containerTestWithIsolatedRedisNoClickhouse(
203253
"concurrent duplicate deliveries collapse to one delivery row and one run",
204254
async ({ prisma, redisOptions }) => {

internal-packages/webhook-engine/src/engine/sessionKey.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,19 @@ describe("evaluateSessionKeyTemplate", () => {
6464
it("coerces numbers and booleans (including 0/false) to strings", () => {
6565
expect(evaluateSessionKeyTemplate("{body.n}-{body.b}", ns({ n: 0, b: false }))).toBe("0-false");
6666
});
67+
68+
it("falls back to the next path with || when the first is empty (Slack thread_ts || ts)", () => {
69+
const template = "{body.channel}:{body.thread_ts || body.ts}";
70+
// reply: thread_ts present -> used
71+
expect(
72+
evaluateSessionKeyTemplate(template, ns({ channel: "C1", thread_ts: "t1", ts: "m2" }))
73+
).toBe("C1:t1");
74+
// thread start: no thread_ts -> falls back to ts
75+
expect(evaluateSessionKeyTemplate(template, ns({ channel: "C1", ts: "m2" }))).toBe("C1:m2");
76+
});
77+
78+
it("uses the first non-empty across a || chain, else undefined", () => {
79+
expect(evaluateSessionKeyTemplate("{body.a || body.b || body.c}", ns({ c: "z" }))).toBe("z");
80+
expect(evaluateSessionKeyTemplate("{body.a || body.b}", ns({}))).toBeUndefined();
81+
});
6782
});

internal-packages/webhook-engine/src/engine/sessionKey.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ export function evaluateSessionKeyTemplate(
1515
): string | undefined {
1616
let ok = true;
1717
const out = template.replace(/\{([^}]+)\}/g, (_match, raw: string) => {
18-
const value = resolveKeyPath(raw, ns);
19-
if (value === undefined || value === null || value === "") {
20-
ok = false;
21-
return "";
18+
// A placeholder may list fallbacks: `{a || b || c}` resolves to the first non-empty path (so a
19+
// Slack thread-start with no thread_ts falls back to ts). Missing/empty everywhere fails the key.
20+
for (const path of raw.split("||")) {
21+
const value = resolveKeyPath(path.trim(), ns);
22+
if (value !== undefined && value !== null && value !== "") return String(value);
2223
}
23-
return String(value);
24+
ok = false;
25+
return "";
2426
});
2527
return ok ? out : undefined;
2628
}
@@ -41,7 +43,8 @@ function resolveKeyPath(path: string, ns: SessionKeyNamespaces): unknown {
4143
return walkPath(ns.body, dotted);
4244
}
4345

44-
function walkPath(root: unknown, dotted: string): unknown {
46+
// Resolve a dotted path over an arbitrary object (shared with the ingest handshake hook).
47+
export function walkPath(root: unknown, dotted: string): unknown {
4548
let current: unknown = root;
4649
for (const segment of dotted.split(".")) {
4750
if (current === null || typeof current !== "object") return undefined;

internal-packages/webhook-engine/src/engine/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export type ReplayResult =
9696

9797
export type IngestResult =
9898
| { outcome: "accepted"; deliveryId: string; deliveryFriendlyId: string }
99+
| { outcome: "handshake"; body: string } // provider handshake (Slack url_verification) -> 200 echo
99100
| { outcome: "duplicate"; deliveryId: string } // front-gate hit -> 200
100101
| { outcome: "endpoint_not_found" } // -> 404
101102
| { outcome: "endpoint_inactive" } // -> 404

packages/core/src/v3/schemas/webhookConfig.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,29 @@ export const WebhookVerifierConfig = z.discriminatedUnion("scheme", [
129129
]);
130130
export type WebhookVerifierConfig = z.infer<typeof WebhookVerifierConfig>;
131131

132+
// ── Provider handshake: a signed request that must get a synchronous echo, not a recorded/routed
133+
// delivery (Slack url_verification, Discord PING). Generic + data-only: if the verified body's
134+
// `matchPath` equals `matchValue`, ingest responds 200 with the body's `respondPath` value. ──
135+
export const WebhookHandshakeConfig = z.object({
136+
matchPath: z.string(), // dotted path into the body, e.g. "type"
137+
matchValue: z.string(), // e.g. "url_verification"
138+
respondPath: z.string(), // dotted path to echo, e.g. "challenge"
139+
});
140+
export type WebhookHandshakeConfig = z.infer<typeof WebhookHandshakeConfig>;
141+
132142
// ── Verifier artifact: data-only tagged union stored on WebhookEndpoint.verifierArtifact ──
133143
export const WebhookVerifierArtifact = z.discriminatedUnion("kind", [
134-
z.object({ kind: z.literal("config"), config: WebhookVerifierConfig }),
135-
z.object({ kind: z.literal("preset"), preset: z.string(), config: WebhookVerifierConfig }),
144+
z.object({
145+
kind: z.literal("config"),
146+
config: WebhookVerifierConfig,
147+
handshake: WebhookHandshakeConfig.optional(),
148+
}),
149+
z.object({
150+
kind: z.literal("preset"),
151+
preset: z.string(),
152+
config: WebhookVerifierConfig,
153+
handshake: WebhookHandshakeConfig.optional(),
154+
}),
136155
z.object({ kind: z.literal("bundle"), bundleUrl: z.string(), hash: z.string() }), // P3 seam
137156
]);
138157
export type WebhookVerifierArtifact = z.infer<typeof WebhookVerifierArtifact>;

packages/core/src/v3/types/chatEvents.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@ type PathValue<T, P extends string> = P extends `${infer Head}.${infer Rest}`
2929

3030
export type WebhookKeyError<M extends string> = `✖ ${M}`;
3131

32+
type TrimSpace<S extends string> = S extends ` ${infer R}`
33+
? TrimSpace<R>
34+
: S extends `${infer L} `
35+
? TrimSpace<L>
36+
: S;
37+
3238
// Validate one {…} placeholder by namespace: webhook.→WebhookKeyMeta, header.→any non-empty name,
3339
// body./bare→event. `true` when a scalar path, else a branded error naming it.
34-
type CheckKeyPath<TEvent, Path extends string> = Path extends `webhook.${infer Rest}`
40+
type CheckKeyPathSingle<TEvent, Path extends string> = Path extends `webhook.${infer Rest}`
3541
? PathValue<WebhookKeyMeta, Rest> extends WebhookKeyScalar
3642
? true
3743
: WebhookKeyError<`unknown or non-scalar webhook meta path: ${Rest}`>
@@ -47,6 +53,13 @@ type CheckKeyPath<TEvent, Path extends string> = Path extends `webhook.${infer R
4753
? true
4854
: WebhookKeyError<`unknown or non-scalar event path: ${Path}`>;
4955

56+
// A placeholder may list first-non-empty fallbacks: `{a || b}`. Validate each side as a path.
57+
type CheckKeyPath<TEvent, Path extends string> = Path extends `${infer L}||${infer R}`
58+
? CheckKeyPathSingle<TEvent, TrimSpace<L>> extends true
59+
? CheckKeyPath<TEvent, TrimSpace<R>>
60+
: CheckKeyPathSingle<TEvent, TrimSpace<L>>
61+
: CheckKeyPathSingle<TEvent, TrimSpace<Path>>;
62+
5063
type CheckKeyTemplate<TEvent, S extends string> = S extends `${infer _Pre}{${infer Path}}${infer Rest}`
5164
? CheckKeyPath<TEvent, Path> extends true
5265
? CheckKeyTemplate<TEvent, Rest>

0 commit comments

Comments
 (0)