@getjack/jack 0.1.34 → 0.1.36

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.
Files changed (90) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/down.ts +39 -7
  4. package/src/commands/link.ts +2 -4
  5. package/src/commands/logs.ts +2 -4
  6. package/src/commands/mcp.ts +12 -10
  7. package/src/commands/services.ts +4 -2
  8. package/src/commands/sync.ts +5 -6
  9. package/src/commands/update.ts +1 -0
  10. package/src/index.ts +8 -0
  11. package/src/lib/auth/client.ts +5 -2
  12. package/src/lib/binding-validator.ts +39 -3
  13. package/src/lib/build-helper.ts +18 -19
  14. package/src/lib/control-plane.ts +45 -0
  15. package/src/lib/do-config.ts +110 -0
  16. package/src/lib/do-export-validator.ts +26 -0
  17. package/src/lib/jsonc-edit.ts +292 -0
  18. package/src/lib/managed-deploy.ts +36 -1
  19. package/src/lib/project-link.ts +37 -0
  20. package/src/lib/project-operations.ts +31 -66
  21. package/src/lib/resources.ts +4 -5
  22. package/src/lib/schema.ts +8 -12
  23. package/src/lib/services/db-create.ts +2 -2
  24. package/src/lib/services/db-execute.ts +9 -6
  25. package/src/lib/services/db-list.ts +6 -4
  26. package/src/lib/services/endpoint-test.ts +275 -0
  27. package/src/lib/services/project-delete.ts +190 -0
  28. package/src/lib/services/project-environment.ts +579 -0
  29. package/src/lib/services/storage-config.ts +7 -309
  30. package/src/lib/services/storage-create.ts +2 -1
  31. package/src/lib/services/storage-delete.ts +3 -2
  32. package/src/lib/services/storage-info.ts +2 -1
  33. package/src/lib/services/storage-list.ts +6 -3
  34. package/src/lib/services/vectorize-config.ts +7 -264
  35. package/src/lib/services/vectorize-create.ts +2 -1
  36. package/src/lib/services/vectorize-delete.ts +6 -4
  37. package/src/lib/services/vectorize-list.ts +6 -3
  38. package/src/lib/storage/index.ts +21 -23
  39. package/src/lib/telemetry.ts +1 -0
  40. package/src/lib/wrangler-config.ts +43 -312
  41. package/src/lib/zip-packager.ts +28 -0
  42. package/src/mcp/test-utils.ts +31 -0
  43. package/src/mcp/tools/index.ts +280 -2
  44. package/src/templates/index.ts +5 -0
  45. package/src/templates/types.ts +4 -0
  46. package/templates/AI-BINDINGS.md +34 -76
  47. package/templates/CLAUDE.md +1 -1
  48. package/templates/ai-chat/src/index.ts +7 -14
  49. package/templates/ai-chat/src/jack-ai.ts +0 -6
  50. package/templates/chat/.jack.json +45 -0
  51. package/templates/chat/bun.lock +1584 -0
  52. package/templates/chat/components.json +23 -0
  53. package/templates/chat/index.html +12 -0
  54. package/templates/chat/package.json +41 -0
  55. package/templates/chat/src/chat-agent.ts +63 -0
  56. package/templates/chat/src/client/app.tsx +189 -0
  57. package/templates/chat/src/client/chat.tsx +222 -0
  58. package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
  59. package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
  60. package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
  61. package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
  62. package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
  63. package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
  64. package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
  65. package/templates/chat/src/client/components/ui/button.tsx +38 -0
  66. package/templates/chat/src/client/lib/utils.ts +6 -0
  67. package/templates/chat/src/client/main.tsx +11 -0
  68. package/templates/chat/src/client/styles.css +125 -0
  69. package/templates/chat/src/index.ts +25 -0
  70. package/templates/chat/src/jack-ai.ts +94 -0
  71. package/templates/chat/tsconfig.json +18 -0
  72. package/templates/chat/vite.config.ts +14 -0
  73. package/templates/chat/wrangler.jsonc +18 -0
  74. package/templates/cron/.jack.json +18 -28
  75. package/templates/cron/schema.sql +10 -20
  76. package/templates/cron/src/admin.ts +321 -0
  77. package/templates/cron/src/index.ts +151 -81
  78. package/templates/cron/src/monitor.ts +124 -0
  79. package/templates/semantic-search/src/index.ts +5 -43
  80. package/templates/semantic-search/src/jack-ai.ts +0 -6
  81. package/templates/telegram-bot/.jack.json +56 -0
  82. package/templates/telegram-bot/bun.lock +41 -0
  83. package/templates/telegram-bot/package.json +16 -0
  84. package/templates/telegram-bot/src/index.ts +236 -0
  85. package/templates/telegram-bot/src/jack-ai.ts +100 -0
  86. package/templates/telegram-bot/tsconfig.json +11 -0
  87. package/templates/telegram-bot/wrangler.jsonc +8 -0
  88. package/templates/cron/src/jobs.ts +0 -139
  89. package/templates/cron/src/webhooks.ts +0 -95
  90. package/templates/semantic-search/src/jack-vectorize.ts +0 -169
@@ -0,0 +1,236 @@
1
+ import { Bot, webhookCallback } from "grammy";
2
+ import { createJackAI } from "./jack-ai";
3
+
4
+ const MODEL = "@cf/meta/llama-3.1-8b-instruct";
5
+ const MAX_MESSAGE_LENGTH = 4000;
6
+
7
+ interface Env {
8
+ BOT_TOKEN: string;
9
+ WEBHOOK_SECRET: string;
10
+ AI?: Ai;
11
+ __AI_PROXY?: Fetcher;
12
+ __JACK_PROJECT_ID?: string;
13
+ __JACK_ORG_ID?: string;
14
+ }
15
+
16
+ type AIRunner = {
17
+ run: (model: string, inputs: unknown) => Promise<unknown>;
18
+ };
19
+
20
+ function getAI(env: Env): AIRunner {
21
+ if (env.__AI_PROXY && env.__JACK_PROJECT_ID && env.__JACK_ORG_ID) {
22
+ return createJackAI(
23
+ env as Required<Pick<Env, "__AI_PROXY" | "__JACK_PROJECT_ID" | "__JACK_ORG_ID">>,
24
+ );
25
+ }
26
+ if (env.AI) return env.AI as unknown as AIRunner;
27
+ throw new Error("No AI binding available");
28
+ }
29
+
30
+ async function askAI(env: Env, question: string): Promise<string> {
31
+ const ai = getAI(env);
32
+ const result = (await ai.run(MODEL, {
33
+ messages: [
34
+ {
35
+ role: "system",
36
+ content:
37
+ "You are a helpful Telegram bot powered by getjack.org. Keep answers concise and clear. Use plain text, not markdown.",
38
+ },
39
+ { role: "user", content: question },
40
+ ],
41
+ })) as { response?: string };
42
+
43
+ return result.response ?? "Sorry, I couldn't generate a response.";
44
+ }
45
+
46
+ async function sendChunked(
47
+ ctx: { reply: (text: string) => Promise<unknown> },
48
+ text: string,
49
+ ): Promise<void> {
50
+ if (text.length <= MAX_MESSAGE_LENGTH) {
51
+ await ctx.reply(text);
52
+ return;
53
+ }
54
+
55
+ const chunks: string[] = [];
56
+ let remaining = text;
57
+ while (remaining.length > 0) {
58
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
59
+ chunks.push(remaining);
60
+ break;
61
+ }
62
+ let splitAt = remaining.lastIndexOf("\n", MAX_MESSAGE_LENGTH);
63
+ if (splitAt < MAX_MESSAGE_LENGTH / 2) {
64
+ splitAt = remaining.lastIndexOf(" ", MAX_MESSAGE_LENGTH);
65
+ }
66
+ if (splitAt < MAX_MESSAGE_LENGTH / 2) {
67
+ splitAt = MAX_MESSAGE_LENGTH;
68
+ }
69
+ chunks.push(remaining.slice(0, splitAt));
70
+ remaining = remaining.slice(splitAt).trimStart();
71
+ }
72
+
73
+ for (const chunk of chunks) {
74
+ await ctx.reply(chunk);
75
+ }
76
+ }
77
+
78
+ async function getBotUsername(env: Env): Promise<string | null> {
79
+ try {
80
+ const res = await fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/getMe`);
81
+ const data = (await res.json()) as {
82
+ ok: boolean;
83
+ result?: { username?: string };
84
+ };
85
+ return data.ok ? (data.result?.username ?? null) : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async function registerWebhook(env: Env, url: string): Promise<Response> {
92
+ const [webhookRes, username] = await Promise.all([
93
+ fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/setWebhook`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({
97
+ url,
98
+ secret_token: env.WEBHOOK_SECRET,
99
+ allowed_updates: ["message", "callback_query"],
100
+ }),
101
+ }),
102
+ getBotUsername(env),
103
+ ]);
104
+ const data = (await webhookRes.json()) as {
105
+ ok: boolean;
106
+ description?: string;
107
+ };
108
+ const botUrl = username ? `https://t.me/${username}` : null;
109
+ if (data.ok) {
110
+ return new Response(JSON.stringify({ registered: true, url, botUrl, username }), {
111
+ headers: { "Content-Type": "application/json" },
112
+ });
113
+ }
114
+ return new Response(JSON.stringify({ registered: false, error: data.description }), {
115
+ status: 500,
116
+ headers: { "Content-Type": "application/json" },
117
+ });
118
+ }
119
+
120
+ export default {
121
+ async fetch(request: Request, env: Env): Promise<Response> {
122
+ const url = new URL(request.url);
123
+
124
+ if (url.pathname === "/register-webhook") {
125
+ return registerWebhook(env, url.origin);
126
+ }
127
+
128
+ if (url.pathname === "/bot-link") {
129
+ const username = await getBotUsername(env);
130
+ if (username) {
131
+ return new Response(`https://t.me/${username}`, {
132
+ headers: { "Content-Type": "text/plain" },
133
+ });
134
+ }
135
+ return new Response("", { status: 404 });
136
+ }
137
+
138
+ if (request.method !== "POST") {
139
+ const username = await getBotUsername(env);
140
+ const botLink = username
141
+ ? `<p><a href="https://t.me/${username}">Open @${username} in Telegram</a> and send <code>/start</code></p>`
142
+ : `<p>Open your bot in Telegram and send <code>/start</code></p>`;
143
+ return new Response(
144
+ `<!DOCTYPE html><html><head><meta charset="utf-8"><title>jack-template</title>
145
+ <style>body{font-family:system-ui;max-width:480px;margin:60px auto;padding:0 20px}
146
+ a{color:#0088cc}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style></head>
147
+ <body><h2>jack-template</h2>${botLink}
148
+ <p>Commands: <code>/start</code> <code>/help</code> <code>/ask</code> <code>/status</code></p></body></html>`,
149
+ { headers: { "Content-Type": "text/html;charset=utf-8" } },
150
+ );
151
+ }
152
+
153
+ const secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
154
+ if (secret !== env.WEBHOOK_SECRET) {
155
+ return new Response("Unauthorized", { status: 403 });
156
+ }
157
+
158
+ const bot = new Bot(env.BOT_TOKEN);
159
+
160
+ bot.command("start", async (ctx) => {
161
+ await ctx.reply(
162
+ [
163
+ "Hey! I'm an AI-powered bot.",
164
+ "",
165
+ "Commands:",
166
+ "/ask <question> - Ask me anything",
167
+ "/status - Bot info",
168
+ "/help - Show this message",
169
+ "",
170
+ "You can also reply to any of my messages to continue the conversation.",
171
+ ].join("\n"),
172
+ );
173
+ });
174
+
175
+ bot.command("help", async (ctx) => {
176
+ await ctx.reply(
177
+ [
178
+ "Available commands:",
179
+ "",
180
+ "/ask <question> - Ask the AI a question",
181
+ "/status - Show bot status and info",
182
+ "/help - Show this help message",
183
+ "",
184
+ "Tip: Reply to any of my messages to ask a follow-up question.",
185
+ ].join("\n"),
186
+ );
187
+ });
188
+
189
+ bot.command("status", async (ctx) => {
190
+ const cf = (request as Request & { cf?: Record<string, string> }).cf;
191
+ const region = cf?.colo ?? "unknown";
192
+ const country = cf?.country ?? "unknown";
193
+
194
+ await ctx.reply(
195
+ [
196
+ "Bot Status",
197
+ "",
198
+ `Region: ${region} (${country})`,
199
+ `Time: ${new Date().toISOString()}`,
200
+ `Runtime: Edge`,
201
+ `AI Model: ${MODEL}`,
202
+ ].join("\n"),
203
+ );
204
+ });
205
+
206
+ bot.command("ask", async (ctx) => {
207
+ const question = ctx.match;
208
+ if (!question) {
209
+ await ctx.reply(
210
+ "Usage: /ask <your question>\n\nExample: /ask What is the meaning of life?",
211
+ );
212
+ return;
213
+ }
214
+
215
+ const answer = await askAI(env, question);
216
+ await sendChunked(ctx, answer);
217
+ });
218
+
219
+ bot.on("message:text", async (ctx) => {
220
+ // In private chats, respond to every message
221
+ if (ctx.chat.type === "private") {
222
+ const answer = await askAI(env, ctx.message.text);
223
+ await sendChunked(ctx, answer);
224
+ return;
225
+ }
226
+ // In groups, only respond when replying to bot's message
227
+ const replyTo = ctx.message.reply_to_message;
228
+ if (replyTo?.from?.id === bot.botInfo.id) {
229
+ const answer = await askAI(env, ctx.message.text);
230
+ await sendChunked(ctx, answer);
231
+ }
232
+ });
233
+
234
+ return webhookCallback(bot, "cloudflare-mod")(request);
235
+ },
236
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Jack AI Client - Drop-in replacement for Cloudflare AI binding.
3
+ *
4
+ * This wrapper provides the same interface as env.AI but routes calls
5
+ * through jack's binding proxy for metering and quota enforcement.
6
+ *
7
+ * Usage in templates:
8
+ * ```typescript
9
+ * import { createJackAI } from "./jack-ai";
10
+ *
11
+ * interface Env {
12
+ * __AI_PROXY: Fetcher; // Service binding to binding-proxy worker
13
+ * __JACK_PROJECT_ID: string; // Injected by control plane
14
+ * __JACK_ORG_ID: string; // Injected by control plane
15
+ * }
16
+ *
17
+ * export default {
18
+ * async fetch(request: Request, env: Env) {
19
+ * const AI = createJackAI(env);
20
+ * const result = await AI.run("@cf/meta/llama-3.2-1b-instruct", { messages });
21
+ * // Works exactly like env.AI.run()
22
+ * }
23
+ * };
24
+ * ```
25
+ *
26
+ * The wrapper is transparent - it accepts the same parameters as env.AI.run()
27
+ * and returns the same response types, including streaming.
28
+ */
29
+
30
+ interface JackAIEnv {
31
+ __AI_PROXY: Fetcher;
32
+ __JACK_PROJECT_ID: string;
33
+ __JACK_ORG_ID: string;
34
+ }
35
+
36
+ /**
37
+ * Creates a Jack AI client that mirrors the Cloudflare AI binding interface.
38
+ *
39
+ * @param env - Worker environment with jack proxy bindings
40
+ * @returns AI-compatible object with run() method
41
+ */
42
+ export function createJackAI(env: JackAIEnv): {
43
+ run: <T = unknown>(
44
+ model: string,
45
+ inputs: unknown,
46
+ options?: unknown,
47
+ ) => Promise<T | ReadableStream>;
48
+ } {
49
+ return {
50
+ async run<T = unknown>(
51
+ model: string,
52
+ inputs: unknown,
53
+ options?: unknown,
54
+ ): Promise<T | ReadableStream> {
55
+ const response = await env.__AI_PROXY.fetch("http://internal/ai/run", {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ "X-Jack-Project-ID": env.__JACK_PROJECT_ID,
60
+ "X-Jack-Org-ID": env.__JACK_ORG_ID,
61
+ },
62
+ body: JSON.stringify({ model, inputs, options }),
63
+ });
64
+
65
+ // Handle quota exceeded
66
+ if (response.status === 429) {
67
+ const error = await response.json();
68
+ const quotaError = new Error(
69
+ (error as { message?: string }).message || "AI quota exceeded",
70
+ );
71
+ (quotaError as Error & { code: string }).code = "AI_QUOTA_EXCEEDED";
72
+ (quotaError as Error & { resetIn?: number }).resetIn = (
73
+ error as { resetIn?: number }
74
+ ).resetIn;
75
+ throw quotaError;
76
+ }
77
+
78
+ // Handle other errors
79
+ if (!response.ok) {
80
+ const error = await response.json();
81
+ throw new Error((error as { error?: string }).error || "AI request failed");
82
+ }
83
+
84
+ // Handle streaming response
85
+ const contentType = response.headers.get("Content-Type");
86
+ if (contentType?.includes("text/event-stream")) {
87
+ return response.body as ReadableStream;
88
+ }
89
+
90
+ // Handle JSON response
91
+ return response.json() as Promise<T>;
92
+ },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Type-safe wrapper that infers return types based on model.
98
+ * For advanced users who want full type safety.
99
+ */
100
+ export type JackAI = ReturnType<typeof createJackAI>;
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "types": ["@cloudflare/workers-types"]
9
+ },
10
+ "include": ["src/**/*"]
11
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "jack-template",
3
+ "main": "src/index.ts",
4
+ "compatibility_date": "2024-12-01",
5
+ "ai": {
6
+ "binding": "AI"
7
+ }
8
+ }
@@ -1,139 +0,0 @@
1
- export interface Job {
2
- id: string;
3
- type: string;
4
- payload: string;
5
- status: string;
6
- attempts: number;
7
- max_attempts: number;
8
- last_error: string | null;
9
- run_at: number;
10
- created_at: number;
11
- updated_at: number;
12
- }
13
-
14
- export interface CreateJobInput {
15
- type: string;
16
- payload?: Record<string, unknown>;
17
- runAt?: number;
18
- }
19
-
20
- // Create a new job in the queue
21
- export async function createJob(
22
- db: D1Database,
23
- input: CreateJobInput,
24
- ): Promise<string> {
25
- const id = crypto.randomUUID();
26
- const now = Math.floor(Date.now() / 1000);
27
- const runAt = input.runAt || now;
28
- const payload = JSON.stringify(input.payload || {});
29
-
30
- await db
31
- .prepare(
32
- "INSERT INTO jobs (id, type, payload, status, attempts, max_attempts, run_at, created_at, updated_at) VALUES (?, ?, ?, 'pending', 0, 3, ?, ?, ?)",
33
- )
34
- .bind(id, input.type, payload, runAt, now, now)
35
- .run();
36
-
37
- return id;
38
- }
39
-
40
- // Get pending jobs that are ready to run
41
- export async function getPendingJobs(
42
- db: D1Database,
43
- limit = 10,
44
- ): Promise<Job[]> {
45
- const now = Math.floor(Date.now() / 1000);
46
-
47
- const { results } = await db
48
- .prepare(
49
- "SELECT * FROM jobs WHERE status = 'pending' AND run_at <= ? ORDER BY run_at ASC LIMIT ?",
50
- )
51
- .bind(now, limit)
52
- .all<Job>();
53
-
54
- return results;
55
- }
56
-
57
- // Process a single job
58
- // Add your own processing logic in the switch statement below
59
- export async function processJob(db: D1Database, job: Job): Promise<void> {
60
- const now = Math.floor(Date.now() / 1000);
61
-
62
- try {
63
- // Mark as running
64
- await db
65
- .prepare(
66
- "UPDATE jobs SET status = 'running', attempts = attempts + 1, updated_at = ? WHERE id = ?",
67
- )
68
- .bind(now, job.id)
69
- .run();
70
-
71
- // Process based on job type
72
- const payload = JSON.parse(job.payload);
73
-
74
- switch (job.type) {
75
- case "example-task":
76
- // Replace with your own logic
77
- console.log(`Processing example task: ${JSON.stringify(payload)}`);
78
- break;
79
-
80
- default:
81
- // Webhook-created jobs and other types
82
- if (job.type.startsWith("webhook.")) {
83
- console.log(`Processing webhook job: ${job.type}`, payload);
84
- } else {
85
- console.log(`Unknown job type: ${job.type}`);
86
- }
87
- break;
88
- }
89
-
90
- // Mark as completed
91
- await db
92
- .prepare(
93
- "UPDATE jobs SET status = 'completed', updated_at = ? WHERE id = ?",
94
- )
95
- .bind(Math.floor(Date.now() / 1000), job.id)
96
- .run();
97
- } catch (err) {
98
- const errorMessage = err instanceof Error ? err.message : String(err);
99
-
100
- // Mark as failed
101
- await db
102
- .prepare(
103
- "UPDATE jobs SET status = 'failed', last_error = ?, updated_at = ? WHERE id = ?",
104
- )
105
- .bind(errorMessage, Math.floor(Date.now() / 1000), job.id)
106
- .run();
107
- }
108
- }
109
-
110
- // Retry failed jobs that haven't exceeded max attempts
111
- // Uses exponential backoff: 2^attempts * 60 seconds
112
- export async function retryFailedJobs(db: D1Database): Promise<number> {
113
- const now = Math.floor(Date.now() / 1000);
114
-
115
- const { results } = await db
116
- .prepare(
117
- "SELECT id, attempts FROM jobs WHERE status = 'failed' AND attempts < max_attempts",
118
- )
119
- .all<{ id: string; attempts: number }>();
120
-
121
- let retried = 0;
122
-
123
- for (const job of results) {
124
- // Exponential backoff: 2^attempts * 60 seconds
125
- const backoffSeconds = Math.pow(2, job.attempts) * 60;
126
- const nextRunAt = now + backoffSeconds;
127
-
128
- await db
129
- .prepare(
130
- "UPDATE jobs SET status = 'pending', run_at = ?, updated_at = ? WHERE id = ?",
131
- )
132
- .bind(nextRunAt, now, job.id)
133
- .run();
134
-
135
- retried++;
136
- }
137
-
138
- return retried;
139
- }
@@ -1,95 +0,0 @@
1
- // Convert an ArrayBuffer to hex string
2
- function bufferToHex(buffer: ArrayBuffer): string {
3
- const bytes = new Uint8Array(buffer);
4
- const hexChars: string[] = [];
5
- for (const byte of bytes) {
6
- hexChars.push(byte.toString(16).padStart(2, "0"));
7
- }
8
- return hexChars.join("");
9
- }
10
-
11
- // Verify HMAC-SHA256 webhook signature
12
- // Expected header format: sha256=<hex-encoded-signature>
13
- export async function verifyWebhookSignature(
14
- payload: string,
15
- signatureHeader: string,
16
- secret: string,
17
- ): Promise<boolean> {
18
- if (!signatureHeader || !secret) {
19
- return false;
20
- }
21
-
22
- // Parse the signature header
23
- const parts = signatureHeader.split("=");
24
- if (parts.length !== 2 || parts[0] !== "sha256") {
25
- return false;
26
- }
27
-
28
- const receivedSignature = parts[1];
29
- if (!receivedSignature) {
30
- return false;
31
- }
32
-
33
- // Import the secret as a CryptoKey
34
- const encoder = new TextEncoder();
35
- const key = await crypto.subtle.importKey(
36
- "raw",
37
- encoder.encode(secret),
38
- { name: "HMAC", hash: "SHA-256" },
39
- false,
40
- ["sign"],
41
- );
42
-
43
- // Sign the payload
44
- const signatureBuffer = await crypto.subtle.sign(
45
- "HMAC",
46
- key,
47
- encoder.encode(payload),
48
- );
49
-
50
- // Convert to hex and compare
51
- const expectedSignature = bufferToHex(signatureBuffer);
52
-
53
- // Constant-time comparison
54
- if (receivedSignature.length !== expectedSignature.length) {
55
- return false;
56
- }
57
-
58
- let mismatch = 0;
59
- for (let i = 0; i < receivedSignature.length; i++) {
60
- mismatch |=
61
- receivedSignature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
62
- }
63
-
64
- return mismatch === 0;
65
- }
66
-
67
- export interface WebhookEventInput {
68
- source?: string;
69
- eventType?: string;
70
- payload: string;
71
- }
72
-
73
- // Log a webhook event to D1
74
- export async function logWebhookEvent(
75
- db: D1Database,
76
- input: WebhookEventInput,
77
- ): Promise<string> {
78
- const id = crypto.randomUUID();
79
- const now = Math.floor(Date.now() / 1000);
80
-
81
- await db
82
- .prepare(
83
- "INSERT INTO webhook_events (id, source, event_type, payload, status, created_at) VALUES (?, ?, ?, ?, 'received', ?)",
84
- )
85
- .bind(
86
- id,
87
- input.source || "unknown",
88
- input.eventType || null,
89
- input.payload,
90
- now,
91
- )
92
- .run();
93
-
94
- return id;
95
- }