hono-webhooks 0.5.1 → 0.6.0
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 +260 -37
- package/dist/cli.js +183 -35
- package/dist/index.d.ts +69 -11
- package/dist/index.js +60 -19
- package/dist/otel.d.ts +9 -5
- package/dist/otel.js +8 -5
- package/dist/signatures.d.ts +64 -0
- package/dist/signatures.js +171 -24
- package/package.json +2 -2
- package/src/cli-commands.ts +19 -4
- package/src/index.ts +141 -31
- package/src/otel.ts +17 -9
- package/src/signatures.ts +277 -33
package/dist/cli.js
CHANGED
|
@@ -5657,8 +5657,10 @@ function superRefine(fn, params) {
|
|
|
5657
5657
|
}
|
|
5658
5658
|
// src/index.ts
|
|
5659
5659
|
import { Hono } from "hono";
|
|
5660
|
+
import { bodyLimit } from "hono/body-limit";
|
|
5660
5661
|
import { HTTPException } from "hono/http-exception";
|
|
5661
5662
|
import { basePath } from "hono/route";
|
|
5663
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5662
5664
|
|
|
5663
5665
|
class WebhookValidationError extends HTTPException {
|
|
5664
5666
|
target;
|
|
@@ -5709,7 +5711,7 @@ async function parse3(schema, value, target) {
|
|
|
5709
5711
|
}
|
|
5710
5712
|
function defaultErrorResponse(error, c, log) {
|
|
5711
5713
|
if (error instanceof WebhookValidationError) {
|
|
5712
|
-
return c.json({ error: error.message, issues: error.issues }, error.status);
|
|
5714
|
+
return error.target === "signature" ? c.json({ error: error.message }, error.status) : c.json({ error: error.message, issues: error.issues }, error.status);
|
|
5713
5715
|
}
|
|
5714
5716
|
if (error instanceof HTTPException) {
|
|
5715
5717
|
return error.res ?? c.json({ error: error.message }, error.status);
|
|
@@ -5734,6 +5736,9 @@ function makeCreateWebhooks() {
|
|
|
5734
5736
|
const app = new Hono;
|
|
5735
5737
|
const methodsByPath = new Map;
|
|
5736
5738
|
for (const { config } of webhooks) {
|
|
5739
|
+
if (config.method !== "GET" && config.verify === undefined) {
|
|
5740
|
+
throw new Error(`hono-webhooks: ${config.method} ${config.path} has no verify. Pass a verify function, or verify: false if it is public on purpose.`);
|
|
5741
|
+
}
|
|
5737
5742
|
const methods = methodsByPath.get(config.path) ?? [];
|
|
5738
5743
|
if (methods.includes(config.method)) {
|
|
5739
5744
|
throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
|
|
@@ -5775,15 +5780,32 @@ function makeCreateWebhooks() {
|
|
|
5775
5780
|
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
5776
5781
|
for (const { config, handler } of ordered) {
|
|
5777
5782
|
app.on(config.method, config.path, handle(config.path, async (c, event) => {
|
|
5783
|
+
const maxSize = config.maxBodyBytes ?? options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
5784
|
+
if (maxSize !== Infinity) {
|
|
5785
|
+
await bodyLimit({
|
|
5786
|
+
maxSize,
|
|
5787
|
+
onError: () => {
|
|
5788
|
+
throw new HTTPException(413, { message: "Payload too large" });
|
|
5789
|
+
}
|
|
5790
|
+
})(c, async () => {});
|
|
5791
|
+
}
|
|
5778
5792
|
event.rawBody = await c.req.text();
|
|
5779
|
-
if (config.verify
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5793
|
+
if (config.verify) {
|
|
5794
|
+
let reason = "Signature does not match";
|
|
5795
|
+
const ok = await config.verify({
|
|
5796
|
+
c,
|
|
5797
|
+
rawBody: event.rawBody,
|
|
5798
|
+
headers: c.req.header(),
|
|
5799
|
+
fail: (why) => {
|
|
5800
|
+
reason = why;
|
|
5801
|
+
return false;
|
|
5802
|
+
}
|
|
5803
|
+
});
|
|
5804
|
+
if (ok !== true) {
|
|
5805
|
+
throw new WebhookValidationError("signature", [
|
|
5806
|
+
{ message: reason }
|
|
5807
|
+
]);
|
|
5808
|
+
}
|
|
5787
5809
|
}
|
|
5788
5810
|
event.params = await parse3(config.params, c.req.param(), "params");
|
|
5789
5811
|
event.query = await parse3(config.query, c.req.query(), "query");
|
|
@@ -5860,6 +5882,7 @@ function describeWebhooks(source, { target = "draft-2020-12" } = {}) {
|
|
|
5860
5882
|
|
|
5861
5883
|
// src/signatures.ts
|
|
5862
5884
|
var encoder = new TextEncoder;
|
|
5885
|
+
var reject = (input, reason) => input.fail?.(reason) ?? false;
|
|
5863
5886
|
function hmacKey(secret, hash) {
|
|
5864
5887
|
if (!secret.length) {
|
|
5865
5888
|
throw new Error("hono-webhooks: the signing secret is empty");
|
|
@@ -5889,19 +5912,23 @@ function fromBase64(base64) {
|
|
|
5889
5912
|
}
|
|
5890
5913
|
}
|
|
5891
5914
|
var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
|
|
5915
|
+
var signedUrls = async (url, c) => [await url?.(c) ?? c.req.url].flat();
|
|
5916
|
+
var outsideTolerance = (seconds, toleranceSeconds) => Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds;
|
|
5892
5917
|
function hmac(options) {
|
|
5893
5918
|
const { header, encoding, prefix = "", hash = "SHA-256" } = options;
|
|
5894
5919
|
const name = header.toLowerCase();
|
|
5895
5920
|
return {
|
|
5896
|
-
verify: (secret) => async (
|
|
5897
|
-
const value = headers[name];
|
|
5898
|
-
if (!value
|
|
5899
|
-
return
|
|
5921
|
+
verify: (secret) => async (input) => {
|
|
5922
|
+
const value = input.headers[name];
|
|
5923
|
+
if (!value)
|
|
5924
|
+
return reject(input, "missing-header");
|
|
5925
|
+
if (!value.startsWith(prefix))
|
|
5926
|
+
return reject(input, "malformed");
|
|
5900
5927
|
const encoded = value.slice(prefix.length);
|
|
5901
5928
|
const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
|
|
5902
5929
|
if (!signature)
|
|
5903
|
-
return
|
|
5904
|
-
return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
|
|
5930
|
+
return reject(input, "malformed");
|
|
5931
|
+
return await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature, hash) || reject(input, "mismatch");
|
|
5905
5932
|
},
|
|
5906
5933
|
async sign(rawBody, secret) {
|
|
5907
5934
|
const signature = await hmacSign(secret, rawBody, hash);
|
|
@@ -5916,27 +5943,31 @@ var github = hmac({
|
|
|
5916
5943
|
encoding: "hex",
|
|
5917
5944
|
prefix: "sha256="
|
|
5918
5945
|
});
|
|
5946
|
+
var meta2 = github;
|
|
5919
5947
|
var shopify = hmac({
|
|
5920
5948
|
header: "x-shopify-hmac-sha256",
|
|
5921
5949
|
encoding: "base64"
|
|
5922
5950
|
});
|
|
5923
5951
|
var stripe = {
|
|
5924
|
-
verify: (secret, { toleranceSeconds = 300 } = {}) => async (
|
|
5925
|
-
const
|
|
5952
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
|
|
5953
|
+
const header = input.headers["stripe-signature"];
|
|
5954
|
+
if (!header)
|
|
5955
|
+
return reject(input, "missing-header");
|
|
5956
|
+
const parts = header.split(",").map((part) => part.split("="));
|
|
5926
5957
|
const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
|
|
5927
5958
|
if (!/^\d+$/.test(timestamp))
|
|
5928
|
-
return
|
|
5929
|
-
if (
|
|
5930
|
-
return
|
|
5959
|
+
return reject(input, "malformed");
|
|
5960
|
+
if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
|
|
5961
|
+
return reject(input, "timestamp-out-of-tolerance");
|
|
5931
5962
|
}
|
|
5932
|
-
const key = await resolveSecret(secret, c);
|
|
5963
|
+
const key = await resolveSecret(secret, input.c);
|
|
5933
5964
|
for (const [k, v] of parts) {
|
|
5934
5965
|
const signature = k === "v1" && v ? fromHex(v) : undefined;
|
|
5935
|
-
if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
|
|
5966
|
+
if (signature && await hmacVerify(key, `${timestamp}.${input.rawBody}`, signature)) {
|
|
5936
5967
|
return true;
|
|
5937
5968
|
}
|
|
5938
5969
|
}
|
|
5939
|
-
return
|
|
5970
|
+
return reject(input, "mismatch");
|
|
5940
5971
|
},
|
|
5941
5972
|
async sign(rawBody, secret, { timestamp } = {}) {
|
|
5942
5973
|
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
@@ -5952,25 +5983,29 @@ function svixKey(secret) {
|
|
|
5952
5983
|
return key;
|
|
5953
5984
|
}
|
|
5954
5985
|
var svix = {
|
|
5955
|
-
verify: (secret, { toleranceSeconds = 300 } = {}) => async (
|
|
5986
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
|
|
5987
|
+
const { headers } = input;
|
|
5956
5988
|
const prefix = headers["svix-id"] ? "svix" : "webhook";
|
|
5957
5989
|
const id = headers[`${prefix}-id`];
|
|
5958
|
-
const timestamp = headers[`${prefix}-timestamp`]
|
|
5990
|
+
const timestamp = headers[`${prefix}-timestamp`];
|
|
5959
5991
|
const signatures = headers[`${prefix}-signature`];
|
|
5960
|
-
if (!id || !signatures ||
|
|
5961
|
-
return
|
|
5962
|
-
|
|
5963
|
-
|
|
5992
|
+
if (!id || !signatures || !timestamp) {
|
|
5993
|
+
return reject(input, "missing-header");
|
|
5994
|
+
}
|
|
5995
|
+
if (!/^\d+$/.test(timestamp))
|
|
5996
|
+
return reject(input, "malformed");
|
|
5997
|
+
if (outsideTolerance(Number(timestamp), toleranceSeconds)) {
|
|
5998
|
+
return reject(input, "timestamp-out-of-tolerance");
|
|
5964
5999
|
}
|
|
5965
|
-
const key = svixKey(await resolveSecret(secret, c));
|
|
6000
|
+
const key = svixKey(await resolveSecret(secret, input.c));
|
|
5966
6001
|
for (const entry of signatures.split(" ")) {
|
|
5967
6002
|
const [version, encoded] = entry.split(",");
|
|
5968
6003
|
const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
|
|
5969
|
-
if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
|
|
6004
|
+
if (signature && await hmacVerify(key, `${id}.${timestamp}.${input.rawBody}`, signature)) {
|
|
5970
6005
|
return true;
|
|
5971
6006
|
}
|
|
5972
6007
|
}
|
|
5973
|
-
return
|
|
6008
|
+
return reject(input, "mismatch");
|
|
5974
6009
|
},
|
|
5975
6010
|
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
5976
6011
|
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
@@ -5983,14 +6018,122 @@ var svix = {
|
|
|
5983
6018
|
};
|
|
5984
6019
|
}
|
|
5985
6020
|
};
|
|
6021
|
+
var infisical = {
|
|
6022
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async (input) => {
|
|
6023
|
+
const header = input.headers["x-infisical-signature"];
|
|
6024
|
+
if (!header)
|
|
6025
|
+
return reject(input, "missing-header");
|
|
6026
|
+
const signature = fromHex(header.split(";")[1] ?? "");
|
|
6027
|
+
if (!signature?.length)
|
|
6028
|
+
return reject(input, "malformed");
|
|
6029
|
+
let timestamp;
|
|
6030
|
+
try {
|
|
6031
|
+
timestamp = JSON.parse(input.rawBody)?.timestamp;
|
|
6032
|
+
} catch {}
|
|
6033
|
+
if (typeof timestamp !== "number")
|
|
6034
|
+
return reject(input, "malformed");
|
|
6035
|
+
if (!await hmacVerify(await resolveSecret(secret, input.c), input.rawBody, signature)) {
|
|
6036
|
+
return reject(input, "mismatch");
|
|
6037
|
+
}
|
|
6038
|
+
return !outsideTolerance(timestamp / 1000, toleranceSeconds) || reject(input, "timestamp-out-of-tolerance");
|
|
6039
|
+
},
|
|
6040
|
+
async sign(rawBody, secret) {
|
|
6041
|
+
let t;
|
|
6042
|
+
try {
|
|
6043
|
+
t = JSON.parse(rawBody)?.timestamp;
|
|
6044
|
+
} catch {}
|
|
6045
|
+
const signature = toHex(await hmacSign(secret, rawBody));
|
|
6046
|
+
return { "x-infisical-signature": `t=${t ?? Date.now()};${signature}` };
|
|
6047
|
+
}
|
|
6048
|
+
};
|
|
6049
|
+
function twilioPayload(url, rawBody) {
|
|
6050
|
+
const fields = [...new URLSearchParams(rawBody)].sort(([a, x], [b, y]) => a === b ? x < y ? -1 : 1 : a < b ? -1 : 1);
|
|
6051
|
+
return url + fields.map(([k, v]) => k + v).join("");
|
|
6052
|
+
}
|
|
6053
|
+
var twilio = {
|
|
6054
|
+
verify: (secret, { url } = {}) => async (input) => {
|
|
6055
|
+
const signature = input.headers["x-twilio-signature"];
|
|
6056
|
+
if (!signature)
|
|
6057
|
+
return reject(input, "missing-header");
|
|
6058
|
+
const bytes = fromBase64(signature);
|
|
6059
|
+
if (!bytes)
|
|
6060
|
+
return reject(input, "malformed");
|
|
6061
|
+
const key = await resolveSecret(secret, input.c);
|
|
6062
|
+
for (const candidate of await signedUrls(url, input.c)) {
|
|
6063
|
+
const payload = twilioPayload(candidate, input.rawBody);
|
|
6064
|
+
if (await hmacVerify(key, payload, bytes, "SHA-1"))
|
|
6065
|
+
return true;
|
|
6066
|
+
}
|
|
6067
|
+
return reject(input, "mismatch");
|
|
6068
|
+
},
|
|
6069
|
+
async sign(rawBody, secret, options) {
|
|
6070
|
+
if (!options?.url)
|
|
6071
|
+
throw new Error("hono-webhooks: twilio.sign needs a url");
|
|
6072
|
+
const signature = await hmacSign(secret, twilioPayload(options.url, rawBody), "SHA-1");
|
|
6073
|
+
return { "x-twilio-signature": toBase64(signature) };
|
|
6074
|
+
}
|
|
6075
|
+
};
|
|
6076
|
+
var HUBSPOT_DECODE = {
|
|
6077
|
+
"%3A": ":",
|
|
6078
|
+
"%2F": "/",
|
|
6079
|
+
"%3F": "?",
|
|
6080
|
+
"%40": "@",
|
|
6081
|
+
"%21": "!",
|
|
6082
|
+
"%24": "$",
|
|
6083
|
+
"%27": "'",
|
|
6084
|
+
"%28": "(",
|
|
6085
|
+
"%29": ")",
|
|
6086
|
+
"%2A": "*",
|
|
6087
|
+
"%2C": ",",
|
|
6088
|
+
"%3B": ";"
|
|
6089
|
+
};
|
|
6090
|
+
var hubspotUrl = (url) => url.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi, (match) => HUBSPOT_DECODE[match.toUpperCase()] ?? match);
|
|
6091
|
+
var hubspot = {
|
|
6092
|
+
verify: (secret, { url, toleranceSeconds = 300 } = {}) => async (input) => {
|
|
6093
|
+
const signature = input.headers["x-hubspot-signature-v3"];
|
|
6094
|
+
const timestamp = input.headers["x-hubspot-request-timestamp"];
|
|
6095
|
+
if (!signature || !timestamp)
|
|
6096
|
+
return reject(input, "missing-header");
|
|
6097
|
+
const bytes = fromBase64(signature);
|
|
6098
|
+
if (!bytes || !/^\d+$/.test(timestamp))
|
|
6099
|
+
return reject(input, "malformed");
|
|
6100
|
+
if (outsideTolerance(Number(timestamp) / 1000, toleranceSeconds)) {
|
|
6101
|
+
return reject(input, "timestamp-out-of-tolerance");
|
|
6102
|
+
}
|
|
6103
|
+
const key = await resolveSecret(secret, input.c);
|
|
6104
|
+
const method = input.c.req.method.toUpperCase();
|
|
6105
|
+
for (const candidate of await signedUrls(url, input.c)) {
|
|
6106
|
+
const payload = `${method}${hubspotUrl(candidate)}${input.rawBody}${timestamp}`;
|
|
6107
|
+
if (await hmacVerify(key, payload, bytes))
|
|
6108
|
+
return true;
|
|
6109
|
+
}
|
|
6110
|
+
return reject(input, "mismatch");
|
|
6111
|
+
},
|
|
6112
|
+
async sign(rawBody, secret, options) {
|
|
6113
|
+
if (!options?.url)
|
|
6114
|
+
throw new Error("hono-webhooks: hubspot.sign needs a url");
|
|
6115
|
+
const t = options.timestamp ?? Date.now();
|
|
6116
|
+
const method = (options.method ?? "POST").toUpperCase();
|
|
6117
|
+
const signature = await hmacSign(secret, `${method}${hubspotUrl(options.url)}${rawBody}${t}`);
|
|
6118
|
+
return {
|
|
6119
|
+
"x-hubspot-signature-v3": toBase64(signature),
|
|
6120
|
+
"x-hubspot-request-timestamp": String(t)
|
|
6121
|
+
};
|
|
6122
|
+
}
|
|
6123
|
+
};
|
|
5986
6124
|
|
|
5987
6125
|
// src/cli-commands.ts
|
|
5988
6126
|
var schemes = {
|
|
5989
6127
|
github,
|
|
6128
|
+
hubspot,
|
|
6129
|
+
infisical,
|
|
6130
|
+
meta: meta2,
|
|
5990
6131
|
shopify,
|
|
5991
6132
|
stripe,
|
|
5992
|
-
svix
|
|
6133
|
+
svix,
|
|
6134
|
+
twilio
|
|
5993
6135
|
};
|
|
6136
|
+
var ORIGIN = "http://localhost";
|
|
5994
6137
|
var importModule = (specifier) => existsSync(resolve(specifier)) ? import(pathToFileURL(resolve(specifier)).href) : import(specifier);
|
|
5995
6138
|
function isWebhooksApp(value) {
|
|
5996
6139
|
return typeof value === "object" && value !== null && Array.isArray(value.webhooks) && typeof value.request === "function";
|
|
@@ -6042,6 +6185,8 @@ var list = defineCommand2({
|
|
|
6042
6185
|
print(ctx, `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ""}`);
|
|
6043
6186
|
if (validates)
|
|
6044
6187
|
print(ctx, ` validates ${validates}`);
|
|
6188
|
+
if (config.verify === false)
|
|
6189
|
+
print(ctx, " public (verify: false)");
|
|
6045
6190
|
}
|
|
6046
6191
|
}
|
|
6047
6192
|
});
|
|
@@ -6126,6 +6271,7 @@ var testCommand = defineCommand2({
|
|
|
6126
6271
|
headers.set("content-type", "text/plain");
|
|
6127
6272
|
}
|
|
6128
6273
|
}
|
|
6274
|
+
const url = `${path.startsWith("/") ? path : `/${path}`}${input.query ? `?${input.query}` : ""}`;
|
|
6129
6275
|
if (input.sign) {
|
|
6130
6276
|
const scheme = schemes[input.sign];
|
|
6131
6277
|
if (!scheme) {
|
|
@@ -6137,11 +6283,13 @@ var testCommand = defineCommand2({
|
|
|
6137
6283
|
const secret = ctx.env[name];
|
|
6138
6284
|
if (secret === undefined)
|
|
6139
6285
|
throw new CliError2(`${name} is not set`);
|
|
6140
|
-
for (const [key, value] of Object.entries(await scheme.sign(input.body ?? "", secret
|
|
6286
|
+
for (const [key, value] of Object.entries(await scheme.sign(input.body ?? "", secret, {
|
|
6287
|
+
url: ORIGIN + url,
|
|
6288
|
+
method: method.toUpperCase()
|
|
6289
|
+
}))) {
|
|
6141
6290
|
headers.set(key, value);
|
|
6142
6291
|
}
|
|
6143
6292
|
}
|
|
6144
|
-
const url = `${path.startsWith("/") ? path : `/${path}`}${input.query ? `?${input.query}` : ""}`;
|
|
6145
6293
|
const start = performance.now();
|
|
6146
6294
|
const res = await ctx.context.request(url, {
|
|
6147
6295
|
method: method.toUpperCase(),
|
package/dist/index.d.ts
CHANGED
|
@@ -35,8 +35,17 @@ export interface VerifyInput<E extends Env = any> {
|
|
|
35
35
|
rawBody: string;
|
|
36
36
|
/** All request headers, lower-case names, not schema-validated. */
|
|
37
37
|
headers: Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* Records why the check failed and returns `false`:
|
|
40
|
+
* `return fail('timestamp-out-of-tolerance')`. The reason reaches
|
|
41
|
+
* `onEvent` (`validation.issues[0].message`), never the response.
|
|
42
|
+
*/
|
|
43
|
+
fail(reason: string): false;
|
|
38
44
|
}
|
|
39
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Checks a request's signature. `hono-webhooks/signatures` has ready-made
|
|
47
|
+
* ones. Only `true` lets the request through.
|
|
48
|
+
*/
|
|
40
49
|
export type Verify<E extends Env = any> = (input: VerifyInput<E>) => MaybePromise<boolean>;
|
|
41
50
|
export interface WebhookConfig<P extends string = string, TParams extends Schema | undefined = Schema | undefined, TQuery extends Schema | undefined = Schema | undefined, THeaders extends Schema | undefined = Schema | undefined, TBody extends Schema | undefined = Schema | undefined, TResponse extends Schema | undefined = Schema | undefined, E extends Env = any> {
|
|
42
51
|
method: WebhookMethod;
|
|
@@ -51,9 +60,14 @@ export interface WebhookConfig<P extends string = string, TParams extends Schema
|
|
|
51
60
|
* Checks the signature right after routing, before anything is validated
|
|
52
61
|
* or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
|
|
53
62
|
*
|
|
63
|
+
* Required for every method but `GET`: pass `false` for a webhook that is
|
|
64
|
+
* public on purpose, or `createWebhooks` throws.
|
|
65
|
+
*
|
|
54
66
|
* @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
|
|
55
67
|
*/
|
|
56
|
-
verify?: Verify<E
|
|
68
|
+
verify?: Verify<E> | false;
|
|
69
|
+
/** Overrides `WebhooksOptions.maxBodyBytes` for this webhook. */
|
|
70
|
+
maxBodyBytes?: number;
|
|
57
71
|
/**
|
|
58
72
|
* Checks what the handler returns. A mismatch is reported on
|
|
59
73
|
* `WebhookEvent.validation` and the response is still sent.
|
|
@@ -152,6 +166,12 @@ export interface WebhooksOptions<E extends Env = any> {
|
|
|
152
166
|
* @default true
|
|
153
167
|
*/
|
|
154
168
|
logErrors?: boolean;
|
|
169
|
+
/**
|
|
170
|
+
* Larger bodies answer `413 { error: 'Payload too large' }` before they
|
|
171
|
+
* are read. `Infinity` turns the limit off. Each webhook can override it.
|
|
172
|
+
* @default 1_048_576 (1 MiB)
|
|
173
|
+
*/
|
|
174
|
+
maxBodyBytes?: number;
|
|
155
175
|
}
|
|
156
176
|
export type WebhooksApp<E extends Env = any> = Hono<E> & {
|
|
157
177
|
/** The definitions, in the order they were passed. */
|
|
@@ -172,7 +192,12 @@ export declare class WebhookValidationError extends HTTPException {
|
|
|
172
192
|
*
|
|
173
193
|
* @example
|
|
174
194
|
* defineWebhook(
|
|
175
|
-
* {
|
|
195
|
+
* {
|
|
196
|
+
* method: 'POST',
|
|
197
|
+
* path: '/orders/:id',
|
|
198
|
+
* verify: shopify.verify((c) => c.env.SHOPIFY_SECRET),
|
|
199
|
+
* body: z.object({ total: z.number() })
|
|
200
|
+
* },
|
|
176
201
|
* async ({ params, body }) => ({ id: params.id, total: body.total })
|
|
177
202
|
* )
|
|
178
203
|
*/
|
|
@@ -180,7 +205,8 @@ export declare const defineWebhook: <const P extends string, TParams extends Sch
|
|
|
180
205
|
/**
|
|
181
206
|
* Builds a Hono app that serves the webhooks. Mount it under a prefix:
|
|
182
207
|
* `app.route('/webhooks', createWebhooks([...]))`. Unknown paths below the
|
|
183
|
-
* prefix answer 404, known paths with the wrong method answer 405.
|
|
208
|
+
* prefix answer 404, known paths with the wrong method answer 405. Throws
|
|
209
|
+
* for a duplicate route and for a non-GET webhook without `verify`.
|
|
184
210
|
*/
|
|
185
211
|
export declare const createWebhooks: (webhooks: readonly Webhook<any, string, any, any, any, any, any>[], options?: WebhooksOptions<any>) => WebhooksApp<any>;
|
|
186
212
|
/**
|
|
@@ -213,6 +239,8 @@ export interface WebhookEventSummary {
|
|
|
213
239
|
userAgent: string | null;
|
|
214
240
|
params: Record<string, unknown> | null;
|
|
215
241
|
query: Record<string, unknown> | null;
|
|
242
|
+
/** Only the headers named in `SummaryOptions.headers`; absent without that option. */
|
|
243
|
+
headers?: Record<string, string>;
|
|
216
244
|
body: unknown;
|
|
217
245
|
response: unknown;
|
|
218
246
|
/** `null` for validation failures, which `validation` describes. */
|
|
@@ -231,19 +259,31 @@ export interface WebhookEventSummary {
|
|
|
231
259
|
}[];
|
|
232
260
|
} | null;
|
|
233
261
|
}
|
|
262
|
+
/** What `summarizeEvent` keeps. Defaults keep everything but headers. */
|
|
263
|
+
export interface SummaryOptions {
|
|
264
|
+
/** `false` sets `body` and `response` to `null`. @default true */
|
|
265
|
+
body?: boolean;
|
|
266
|
+
/** `false` sets `query` to `null`. @default true */
|
|
267
|
+
query?: boolean;
|
|
268
|
+
/**
|
|
269
|
+
* Request headers to keep, lower-case, e.g. a delivery id such as
|
|
270
|
+
* `x-github-delivery` or `svix-id`. @default []
|
|
271
|
+
*/
|
|
272
|
+
headers?: readonly string[];
|
|
273
|
+
}
|
|
234
274
|
/**
|
|
235
|
-
* Flattens an event for logging or storage. Request headers are left out
|
|
236
|
-
*
|
|
275
|
+
* Flattens an event for logging or storage. Request headers are left out
|
|
276
|
+
* unless named in `options.headers`: they carry signatures and credentials.
|
|
237
277
|
*
|
|
238
278
|
* @example
|
|
239
|
-
* onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e))
|
|
279
|
+
* onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e, { body: false }))
|
|
240
280
|
*/
|
|
241
|
-
export declare function summarizeEvent(event: WebhookEvent): WebhookEventSummary;
|
|
281
|
+
export declare function summarizeEvent(event: WebhookEvent, options?: SummaryOptions): WebhookEventSummary;
|
|
242
282
|
export interface WebhookDescription {
|
|
243
283
|
method: WebhookMethod;
|
|
244
284
|
path: string;
|
|
245
285
|
description: string | null;
|
|
246
|
-
/** Whether the webhook has a `verify` function
|
|
286
|
+
/** Whether the webhook has a `verify` function; `false` for `verify: false`. */
|
|
247
287
|
verified: boolean;
|
|
248
288
|
bodyType: BodyType;
|
|
249
289
|
/**
|
|
@@ -275,8 +315,20 @@ export type TestWebhookInput<W> = W extends Webhook<infer E, infer P, infer TPar
|
|
|
275
315
|
* webhook's `bodyType` is `'form'`.
|
|
276
316
|
*/
|
|
277
317
|
body?: In<TBody, unknown> | string;
|
|
278
|
-
/**
|
|
279
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Headers computed from the raw body and the request, e.g.
|
|
320
|
+
* `(raw) => shopify.sign(raw, secret)` or
|
|
321
|
+
* `(raw, { url }) => twilio.sign(raw, token, { url })`.
|
|
322
|
+
*/
|
|
323
|
+
sign?: (rawBody: string, request: {
|
|
324
|
+
url: string;
|
|
325
|
+
method: string;
|
|
326
|
+
}) => MaybePromise<Record<string, string>>;
|
|
327
|
+
/**
|
|
328
|
+
* Origin the request is sent to, for schemes that sign the URL.
|
|
329
|
+
* @default 'http://localhost'
|
|
330
|
+
*/
|
|
331
|
+
baseUrl?: string;
|
|
280
332
|
/** Bindings, available as `c.env`. */
|
|
281
333
|
env?: E extends {
|
|
282
334
|
Bindings: infer B;
|
|
@@ -295,3 +347,9 @@ export type TestWebhookInput<W> = W extends Webhook<infer E, infer P, infer TPar
|
|
|
295
347
|
* })
|
|
296
348
|
*/
|
|
297
349
|
export declare function testWebhook<W extends Webhook>(webhook: W, input?: TestWebhookInput<W>): Promise<Response>;
|
|
350
|
+
/**
|
|
351
|
+
* The webhook's path with `params` filled in, relative to the mount point:
|
|
352
|
+
* `webhookPath(oauthCallback, { provider: 'gmail' })` gives
|
|
353
|
+
* `/channels/gmail/callback`. Prepend your origin and mount prefix for a URL.
|
|
354
|
+
*/
|
|
355
|
+
export declare function webhookPath<P extends string>(webhook: Webhook<any, P>, ...[params]: [ParamKeys<P>] extends [never] ? [] : [PathParams<P>]): string;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
4
|
import { HTTPException } from "hono/http-exception";
|
|
4
5
|
import { basePath } from "hono/route";
|
|
6
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5
7
|
|
|
6
8
|
class WebhookValidationError extends HTTPException {
|
|
7
9
|
target;
|
|
@@ -52,7 +54,7 @@ async function parse(schema, value, target) {
|
|
|
52
54
|
}
|
|
53
55
|
function defaultErrorResponse(error, c, log) {
|
|
54
56
|
if (error instanceof WebhookValidationError) {
|
|
55
|
-
return c.json({ error: error.message, issues: error.issues }, error.status);
|
|
57
|
+
return error.target === "signature" ? c.json({ error: error.message }, error.status) : c.json({ error: error.message, issues: error.issues }, error.status);
|
|
56
58
|
}
|
|
57
59
|
if (error instanceof HTTPException) {
|
|
58
60
|
return error.res ?? c.json({ error: error.message }, error.status);
|
|
@@ -77,6 +79,9 @@ function makeCreateWebhooks() {
|
|
|
77
79
|
const app = new Hono;
|
|
78
80
|
const methodsByPath = new Map;
|
|
79
81
|
for (const { config } of webhooks) {
|
|
82
|
+
if (config.method !== "GET" && config.verify === undefined) {
|
|
83
|
+
throw new Error(`hono-webhooks: ${config.method} ${config.path} has no verify. Pass a verify function, or verify: false if it is public on purpose.`);
|
|
84
|
+
}
|
|
80
85
|
const methods = methodsByPath.get(config.path) ?? [];
|
|
81
86
|
if (methods.includes(config.method)) {
|
|
82
87
|
throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
|
|
@@ -118,15 +123,32 @@ function makeCreateWebhooks() {
|
|
|
118
123
|
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
119
124
|
for (const { config, handler } of ordered) {
|
|
120
125
|
app.on(config.method, config.path, handle(config.path, async (c, event) => {
|
|
126
|
+
const maxSize = config.maxBodyBytes ?? options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
127
|
+
if (maxSize !== Infinity) {
|
|
128
|
+
await bodyLimit({
|
|
129
|
+
maxSize,
|
|
130
|
+
onError: () => {
|
|
131
|
+
throw new HTTPException(413, { message: "Payload too large" });
|
|
132
|
+
}
|
|
133
|
+
})(c, async () => {});
|
|
134
|
+
}
|
|
121
135
|
event.rawBody = await c.req.text();
|
|
122
|
-
if (config.verify
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
if (config.verify) {
|
|
137
|
+
let reason = "Signature does not match";
|
|
138
|
+
const ok = await config.verify({
|
|
139
|
+
c,
|
|
140
|
+
rawBody: event.rawBody,
|
|
141
|
+
headers: c.req.header(),
|
|
142
|
+
fail: (why) => {
|
|
143
|
+
reason = why;
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (ok !== true) {
|
|
148
|
+
throw new WebhookValidationError("signature", [
|
|
149
|
+
{ message: reason }
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
130
152
|
}
|
|
131
153
|
event.params = await parse(config.params, c.req.param(), "params");
|
|
132
154
|
event.query = await parse(config.query, c.req.query(), "query");
|
|
@@ -191,8 +213,15 @@ function issuePath(issue) {
|
|
|
191
213
|
const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
192
214
|
return path || null;
|
|
193
215
|
}
|
|
194
|
-
function summarizeEvent(event) {
|
|
216
|
+
function summarizeEvent(event, options = {}) {
|
|
195
217
|
const { c, error, validation } = event;
|
|
218
|
+
const keepBody = options.body ?? true;
|
|
219
|
+
const headers = {};
|
|
220
|
+
for (const name of options.headers ?? []) {
|
|
221
|
+
const value = c.req.header(name);
|
|
222
|
+
if (value !== undefined)
|
|
223
|
+
headers[name] = value;
|
|
224
|
+
}
|
|
196
225
|
return {
|
|
197
226
|
route: event.route ?? null,
|
|
198
227
|
method: event.method,
|
|
@@ -202,9 +231,10 @@ function summarizeEvent(event) {
|
|
|
202
231
|
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || c.req.header("cf-connecting-ip") || null,
|
|
203
232
|
userAgent: c.req.header("user-agent") ?? null,
|
|
204
233
|
params: event.params ?? null,
|
|
205
|
-
query: event.query ?? null,
|
|
206
|
-
|
|
207
|
-
|
|
234
|
+
query: options.query === false ? null : event.query ?? null,
|
|
235
|
+
...options.headers && { headers },
|
|
236
|
+
body: keepBody ? event.body ?? null : null,
|
|
237
|
+
response: keepBody ? event.response ?? null : null,
|
|
208
238
|
error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
|
|
209
239
|
name: error.name,
|
|
210
240
|
message: error.message,
|
|
@@ -253,7 +283,13 @@ function fillPath(pattern, params) {
|
|
|
253
283
|
}
|
|
254
284
|
async function testWebhook(webhook, input = {}) {
|
|
255
285
|
const { config } = webhook;
|
|
256
|
-
const {
|
|
286
|
+
const {
|
|
287
|
+
body,
|
|
288
|
+
sign,
|
|
289
|
+
env,
|
|
290
|
+
options,
|
|
291
|
+
baseUrl = "http://localhost"
|
|
292
|
+
} = input;
|
|
257
293
|
const headers = new Headers(input.headers);
|
|
258
294
|
let rawBody;
|
|
259
295
|
if (typeof body === "string") {
|
|
@@ -271,12 +307,16 @@ async function testWebhook(webhook, input = {}) {
|
|
|
271
307
|
headers.set("content-type", "application/json");
|
|
272
308
|
}
|
|
273
309
|
}
|
|
274
|
-
for (const [name, value] of Object.entries(await sign?.(rawBody ?? "") ?? {})) {
|
|
275
|
-
headers.set(name, value);
|
|
276
|
-
}
|
|
277
310
|
const path = input.path ?? fillPath(config.path, input.params ?? {});
|
|
278
311
|
const query = new URLSearchParams(input.query).toString();
|
|
279
|
-
|
|
312
|
+
const url = `${baseUrl.replace(/\/$/, "")}${path || "/"}${query ? `?${query}` : ""}`;
|
|
313
|
+
for (const [name, value] of Object.entries(await sign?.(rawBody ?? "", { url, method: config.method }) ?? {})) {
|
|
314
|
+
headers.set(name, value);
|
|
315
|
+
}
|
|
316
|
+
return createWebhooks([webhook], options).request(url, { method: config.method, headers, body: rawBody }, env);
|
|
317
|
+
}
|
|
318
|
+
function webhookPath(webhook, ...[params]) {
|
|
319
|
+
return fillPath(webhook.config.path, params ?? {}) || "/";
|
|
280
320
|
}
|
|
281
321
|
export {
|
|
282
322
|
WebhookValidationError,
|
|
@@ -286,5 +326,6 @@ export {
|
|
|
286
326
|
describeWebhooks,
|
|
287
327
|
formatIssues,
|
|
288
328
|
summarizeEvent,
|
|
289
|
-
testWebhook
|
|
329
|
+
testWebhook,
|
|
330
|
+
webhookPath
|
|
290
331
|
};
|
package/dist/otel.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Meter, type Span, type Tracer } from '@opentelemetry/api';
|
|
2
2
|
import type { Env } from 'hono';
|
|
3
|
-
import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
|
|
3
|
+
import { type SummaryOptions, type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
|
|
4
4
|
/** The `route` label for 404s, which match no webhook. */
|
|
5
5
|
export declare const UNMATCHED_ROUTE = "__unmatched__";
|
|
6
6
|
/** A metric `otelWebhooks` records, described for a metric catalog. */
|
|
@@ -39,6 +39,8 @@ export interface WebhookCall extends WebhookEventSummary {
|
|
|
39
39
|
/** `error` for 5xx, `warn` for 4xx, else `info`. */
|
|
40
40
|
level: 'info' | 'warn' | 'error';
|
|
41
41
|
outcome: 'success' | 'failed' | 'validation_failed';
|
|
42
|
+
/** The route is in `ignoreRoutes`: no metrics or log were recorded. */
|
|
43
|
+
ignored: boolean;
|
|
42
44
|
}
|
|
43
45
|
/** `console` fits, as does any logger with these three methods. */
|
|
44
46
|
export type WebhookLogger = Record<WebhookCall['level'], (message: string, fields: Record<string, string | number>) => void>;
|
|
@@ -49,16 +51,18 @@ export interface OtelWebhooksOptions<E extends Env = any> {
|
|
|
49
51
|
meter?: Meter;
|
|
50
52
|
/** Routes that get a span but no metrics, e.g. `['/health']`. */
|
|
51
53
|
ignoreRoutes?: readonly string[];
|
|
54
|
+
/** What `call` keeps of the request, as for `summarizeEvent`. */
|
|
55
|
+
summary?: SummaryOptions;
|
|
52
56
|
/**
|
|
53
|
-
* Called after every request, ignored routes included,
|
|
54
|
-
* still active: log it, store it. A returned promise is
|
|
55
|
-
* `onEvent`'s.
|
|
57
|
+
* Called after every request, ignored routes included (`call.ignored`),
|
|
58
|
+
* with the span still active: log it, store it. A returned promise is
|
|
59
|
+
* handled like `onEvent`'s.
|
|
56
60
|
*/
|
|
57
61
|
onWebhookCall?: (call: WebhookCall, event: WebhookEvent<E>) => void | Promise<void>;
|
|
58
62
|
/**
|
|
59
63
|
* Logs one `webhook.completed` event per request at `call.level`, with
|
|
60
64
|
* `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
|
|
61
|
-
* and `
|
|
65
|
+
* `error_type` and `error_message`. 404s and `ignoreRoutes` are skipped. Called with the
|
|
62
66
|
* request's span, so the logger can attach to it.
|
|
63
67
|
*
|
|
64
68
|
* @example
|