@access-dlsu/leapify 0.260531.1 → 0.260601.2

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/dist/worker.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
+ import { drizzle } from 'drizzle-orm/d1';
4
+ import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
5
+ import { sql, relations, eq, and, count, isNotNull, lte } from 'drizzle-orm';
3
6
  import { createMiddleware } from 'hono/factory';
4
7
  import { betterAuth } from 'better-auth';
5
8
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
6
9
  import { bearer } from 'better-auth/plugins';
7
- import { sql, relations, eq, and, count, lte } from 'drizzle-orm';
8
- import { drizzle } from 'drizzle-orm/d1';
9
- import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
10
10
  import { zValidator } from '@hono/zod-validator';
11
11
  import { z } from 'zod';
12
12
 
@@ -24,6 +24,8 @@ var LeapifyError = class extends Error {
24
24
  this.code = code;
25
25
  this.name = "LeapifyError";
26
26
  }
27
+ statusCode;
28
+ code;
27
29
  };
28
30
  var unauthorized = (message = "Unauthorized") => new LeapifyError(401, "UNAUTHORIZED", message);
29
31
  var domainRestricted = () => new LeapifyError(
@@ -52,212 +54,6 @@ var errorHandler = (err, c) => {
52
54
  500
53
55
  );
54
56
  };
55
- function createCorsMiddleware(allowedOrigins) {
56
- return async (c, next) => {
57
- const origin = c.req.header("origin");
58
- const dynamicOriginsJson = await c.env.KV.get("config:allowed_origins", "json");
59
- const currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
60
- if (c.req.path.startsWith("/api/uploads/images")) {
61
- c.header("Access-Control-Allow-Origin", "*");
62
- c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
63
- if (c.req.method === "OPTIONS") {
64
- return c.body(null, 204);
65
- }
66
- return next();
67
- }
68
- if (!c.req.path.startsWith("/health") && !c.req.path.startsWith("/api/auth") && !c.req.path.startsWith("/internal") && origin && !currentAllowedOrigins.includes("*") && !currentAllowedOrigins.includes(origin)) {
69
- return c.json(
70
- {
71
- error: {
72
- code: "DOMAIN_RESTRICTED",
73
- message: `Origin ${origin} is not allowed`
74
- }
75
- },
76
- 403
77
- );
78
- }
79
- const honoCors = cors({
80
- origin: currentAllowedOrigins,
81
- allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
82
- allowHeaders: ["Content-Type", "Authorization"],
83
- exposeHeaders: ["ETag", "Last-Modified", "Cache-Control"],
84
- maxAge: 86400,
85
- credentials: true
86
- });
87
- return honoCors(c, next);
88
- };
89
- }
90
- function createRefererGuard(allowedOrigins) {
91
- const MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
92
- const SKIP_PREFIXES = ["/health", "/internal", "/api/auth", "/.well-known"];
93
- return createMiddleware(async (c, next) => {
94
- if (!MUTATION_METHODS.has(c.req.method)) return next();
95
- if (SKIP_PREFIXES.some((p) => c.req.path.startsWith(p))) return next();
96
- const dynamicOriginsJson = await c.env.KV.get("config:allowed_origins", "json");
97
- const currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
98
- if (currentAllowedOrigins.includes("*")) return next();
99
- const referer = c.req.header("referer") ?? "";
100
- const isAllowed = currentAllowedOrigins.some((origin) => referer.startsWith(origin));
101
- if (!isAllowed) {
102
- throw forbidden("Request origin not permitted");
103
- }
104
- return next();
105
- });
106
- }
107
- var TURNSTILE_PATH = "/.well-known/leapify/turnstile";
108
- var TURNSTILE_VERIFY_PATH = `${TURNSTILE_PATH}/verify`;
109
- var TURNSTILE_COOKIE_NAME = "leapify-turnstile";
110
- var VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
111
- var COOKIE_MAX_AGE_SEC = 86400;
112
- var EXEMPT_PATHS = [
113
- "/health",
114
- "/internal",
115
- "/api/auth",
116
- "/api/uploads/images",
117
- "/api/classes",
118
- "/api/faqs",
119
- "/api/config",
120
- TURNSTILE_VERIFY_PATH
121
- ];
122
- function base64urlEncode(bytes) {
123
- let binary = "";
124
- for (const byte of bytes) {
125
- binary += String.fromCharCode(byte);
126
- }
127
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
128
- }
129
- function base64urlDecode(str) {
130
- const padded = str.replace(/-/g, "+").replace(/_/g, "/");
131
- const binary = atob(padded);
132
- const bytes = new Uint8Array(new ArrayBuffer(binary.length));
133
- for (let i = 0; i < binary.length; i++) {
134
- bytes[i] = binary.charCodeAt(i);
135
- }
136
- return bytes;
137
- }
138
- async function importHmacKey(secret) {
139
- return crypto.subtle.importKey(
140
- "raw",
141
- new TextEncoder().encode(secret),
142
- { name: "HMAC", hash: "SHA-256" },
143
- false,
144
- ["sign", "verify"]
145
- );
146
- }
147
- async function signCookie(secret, ip) {
148
- const ts = Date.now();
149
- const nonce = base64urlEncode(crypto.getRandomValues(new Uint8Array(8)));
150
- const payload = `${ip}:${ts}:${nonce}`;
151
- const key = await importHmacKey(secret);
152
- const sig = await crypto.subtle.sign(
153
- "HMAC",
154
- key,
155
- new TextEncoder().encode(payload)
156
- );
157
- const sigB64 = base64urlEncode(new Uint8Array(sig));
158
- return `${base64urlEncode(new TextEncoder().encode(payload))}.${sigB64}`;
159
- }
160
- async function validateCookie(secret, cookie, ip) {
161
- try {
162
- const [payloadB64, sigB64] = cookie.split(".");
163
- if (!payloadB64 || !sigB64) return false;
164
- const payloadBytes = base64urlDecode(payloadB64);
165
- const sigBytes = base64urlDecode(sigB64);
166
- const key = await importHmacKey(secret);
167
- const valid = await crypto.subtle.verify(
168
- "HMAC",
169
- key,
170
- sigBytes,
171
- payloadBytes
172
- );
173
- if (!valid) return false;
174
- const payload = new TextDecoder().decode(payloadBytes);
175
- const [cookieIp, tsStr] = payload.split(":");
176
- if (cookieIp !== ip) return false;
177
- const ts = parseInt(tsStr, 10);
178
- if (isNaN(ts) || Date.now() - ts > COOKIE_MAX_AGE_SEC * 1e3) return false;
179
- return true;
180
- } catch {
181
- return false;
182
- }
183
- }
184
- function getClientIp(c) {
185
- return c.req.header("CF-Connecting-IP") ?? c.req.header("X-Real-IP") ?? c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ?? "unknown";
186
- }
187
- function isExempt(path) {
188
- const normalized = path.toLowerCase().replace(/\/$/, "");
189
- return EXEMPT_PATHS.some((p) => {
190
- const ep = p.toLowerCase().replace(/\/$/, "");
191
- return normalized === ep || normalized.startsWith(ep + "/");
192
- });
193
- }
194
- function setCookieHeader(c, token) {
195
- const isSecure = c.req.raw.url.startsWith("https") || c.req.header("x-forwarded-proto") === "https";
196
- c.header(
197
- "Set-Cookie",
198
- `${TURNSTILE_COOKIE_NAME}=${token}; Path=/; Max-Age=${COOKIE_MAX_AGE_SEC}; ${isSecure ? "Secure; " : ""}HttpOnly; SameSite=Lax`
199
- );
200
- }
201
- async function handleTurnstileVerify(c) {
202
- const body = await c.req.json();
203
- const { token } = body;
204
- if (!token) {
205
- return c.json(
206
- { error: { code: "VALIDATION_ERROR", message: "Missing Turnstile token" } },
207
- 422
208
- );
209
- }
210
- const secret = c.env.TURNSTILE_SECRET_KEY;
211
- if (!secret) {
212
- return c.json(
213
- { error: { code: "CONFIG_ERROR", message: "Turnstile not configured" } },
214
- 500
215
- );
216
- }
217
- const ip = getClientIp(c);
218
- const formData = new URLSearchParams();
219
- formData.append("secret", secret);
220
- formData.append("response", token);
221
- if (ip !== "unknown") {
222
- formData.append("remoteip", ip);
223
- }
224
- const res = await fetch(VERIFY_URL, {
225
- method: "POST",
226
- body: formData
227
- });
228
- const outcome = await res.json();
229
- if (!outcome.success) {
230
- return c.json(
231
- { error: { code: "TURNSTILE_FAILED", message: "Turnstile verification failed", details: outcome["error-codes"] } },
232
- 403
233
- );
234
- }
235
- const cookieToken = await signCookie(secret, ip);
236
- setCookieHeader(c, cookieToken);
237
- return c.json({ success: true });
238
- }
239
- function createTurnstileMiddleware() {
240
- return createMiddleware(async (c, next) => {
241
- if (isExempt(c.req.path)) return next();
242
- if (c.req.method === "OPTIONS") return next();
243
- if (c.req.header("Authorization")) return next();
244
- const secret = c.env.TURNSTILE_SECRET_KEY;
245
- if (!secret) return next();
246
- const cookieHeader = c.req.header("Cookie") ?? "";
247
- const cookieMatch = cookieHeader.match(
248
- new RegExp(`${TURNSTILE_COOKIE_NAME}=([^;]+)`)
249
- );
250
- if (cookieMatch) {
251
- const ip = getClientIp(c);
252
- const valid = await validateCookie(secret, cookieMatch[1], ip);
253
- if (valid) return next();
254
- }
255
- return c.json(
256
- { error: { code: "TURNSTILE_REQUIRED", message: "Turnstile verification required" } },
257
- 401
258
- );
259
- });
260
- }
261
57
 
262
58
  // src/db/schema/index.ts
263
59
  var schema_exports = {};
@@ -490,8 +286,266 @@ var authVerification = sqliteTable(
490
286
  function createDb(d1) {
491
287
  return drizzle(d1, { schema: schema_exports });
492
288
  }
493
-
494
- // src/auth/auth.ts
289
+ async function getOriginsFromDb(env) {
290
+ try {
291
+ const db = createDb(env.DB);
292
+ const row = await db.query.siteConfig.findFirst({
293
+ where: eq(siteConfig.key, "allowed_origins")
294
+ });
295
+ if (row) return JSON.parse(row.value);
296
+ } catch {
297
+ }
298
+ return null;
299
+ }
300
+ function createCorsMiddleware(allowedOrigins) {
301
+ return async (c, next) => {
302
+ const origin = c.req.header("origin");
303
+ const dynamicOriginsJson = await c.env.KV.get(
304
+ "config:allowed_origins",
305
+ "json"
306
+ );
307
+ let currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
308
+ if (!dynamicOriginsJson) {
309
+ const dbOrigins = await getOriginsFromDb(c.env);
310
+ if (dbOrigins) {
311
+ currentAllowedOrigins = dbOrigins;
312
+ await c.env.KV.put(
313
+ "config:allowed_origins",
314
+ JSON.stringify(dbOrigins),
315
+ { expirationTtl: 86400 }
316
+ );
317
+ }
318
+ }
319
+ if (c.req.path.startsWith("/api/uploads/images")) {
320
+ c.header("Access-Control-Allow-Origin", "*");
321
+ c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
322
+ if (c.req.method === "OPTIONS") {
323
+ return c.body(null, 204);
324
+ }
325
+ return next();
326
+ }
327
+ if (!c.req.path.startsWith("/health") && !c.req.path.startsWith("/api/auth") && !c.req.path.startsWith("/internal") && origin && !currentAllowedOrigins.includes("*") && !currentAllowedOrigins.includes(origin) && origin !== new URL(c.req.url).origin) {
328
+ return c.json(
329
+ {
330
+ error: {
331
+ code: "DOMAIN_RESTRICTED",
332
+ message: `Origin ${origin} is not allowed`
333
+ }
334
+ },
335
+ 403
336
+ );
337
+ }
338
+ const honoCors = cors({
339
+ origin: currentAllowedOrigins,
340
+ allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
341
+ allowHeaders: ["Content-Type", "Authorization"],
342
+ exposeHeaders: ["ETag", "Last-Modified", "Cache-Control"],
343
+ maxAge: 86400,
344
+ credentials: true
345
+ });
346
+ return honoCors(c, next);
347
+ };
348
+ }
349
+ async function getOriginsFromDb2(env) {
350
+ try {
351
+ const db = createDb(env.DB);
352
+ const row = await db.query.siteConfig.findFirst({
353
+ where: eq(siteConfig.key, "allowed_origins")
354
+ });
355
+ if (row) return JSON.parse(row.value);
356
+ } catch {
357
+ }
358
+ return null;
359
+ }
360
+ function createRefererGuard(allowedOrigins) {
361
+ const MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
362
+ const SKIP_PREFIXES = ["/health", "/internal", "/api/auth", "/.well-known"];
363
+ return createMiddleware(async (c, next) => {
364
+ if (!MUTATION_METHODS.has(c.req.method)) return next();
365
+ if (SKIP_PREFIXES.some((p) => c.req.path.startsWith(p))) return next();
366
+ const dynamicOriginsJson = await c.env.KV.get(
367
+ "config:allowed_origins",
368
+ "json"
369
+ );
370
+ let currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
371
+ if (!dynamicOriginsJson) {
372
+ const dbOrigins = await getOriginsFromDb2(c.env);
373
+ if (dbOrigins) {
374
+ currentAllowedOrigins = dbOrigins;
375
+ await c.env.KV.put(
376
+ "config:allowed_origins",
377
+ JSON.stringify(dbOrigins),
378
+ { expirationTtl: 86400 }
379
+ );
380
+ }
381
+ }
382
+ if (currentAllowedOrigins.includes("*")) return next();
383
+ const referer = c.req.header("referer") ?? "";
384
+ const requestOrigin = new URL(c.req.url).origin;
385
+ if (referer.startsWith(requestOrigin)) return next();
386
+ const isAllowed = currentAllowedOrigins.some(
387
+ (origin) => referer.startsWith(origin)
388
+ );
389
+ if (!isAllowed) {
390
+ throw forbidden("Request origin not permitted");
391
+ }
392
+ return next();
393
+ });
394
+ }
395
+ var TURNSTILE_PATH = "/.well-known/leapify/turnstile";
396
+ var TURNSTILE_VERIFY_PATH = `${TURNSTILE_PATH}/verify`;
397
+ var TURNSTILE_COOKIE_NAME = "leapify-turnstile";
398
+ var VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
399
+ var COOKIE_MAX_AGE_SEC = 86400;
400
+ var EXEMPT_PATHS = [
401
+ "/health",
402
+ "/internal",
403
+ "/api/auth",
404
+ "/api/uploads/images",
405
+ "/api/classes",
406
+ "/api/faqs",
407
+ "/api/config",
408
+ TURNSTILE_VERIFY_PATH
409
+ ];
410
+ function base64urlEncode(bytes) {
411
+ let binary = "";
412
+ for (const byte of bytes) {
413
+ binary += String.fromCharCode(byte);
414
+ }
415
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
416
+ }
417
+ function base64urlDecode(str) {
418
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
419
+ const binary = atob(padded);
420
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
421
+ for (let i = 0; i < binary.length; i++) {
422
+ bytes[i] = binary.charCodeAt(i);
423
+ }
424
+ return bytes;
425
+ }
426
+ async function importHmacKey(secret) {
427
+ return crypto.subtle.importKey(
428
+ "raw",
429
+ new TextEncoder().encode(secret),
430
+ { name: "HMAC", hash: "SHA-256" },
431
+ false,
432
+ ["sign", "verify"]
433
+ );
434
+ }
435
+ async function signCookie(secret, ip) {
436
+ const ts = Date.now();
437
+ const nonce = base64urlEncode(crypto.getRandomValues(new Uint8Array(8)));
438
+ const payload = `${ip}:${ts}:${nonce}`;
439
+ const key = await importHmacKey(secret);
440
+ const sig = await crypto.subtle.sign(
441
+ "HMAC",
442
+ key,
443
+ new TextEncoder().encode(payload)
444
+ );
445
+ const sigB64 = base64urlEncode(new Uint8Array(sig));
446
+ return `${base64urlEncode(new TextEncoder().encode(payload))}.${sigB64}`;
447
+ }
448
+ async function validateCookie(secret, cookie, ip) {
449
+ try {
450
+ const [payloadB64, sigB64] = cookie.split(".");
451
+ if (!payloadB64 || !sigB64) return false;
452
+ const payloadBytes = base64urlDecode(payloadB64);
453
+ const sigBytes = base64urlDecode(sigB64);
454
+ const key = await importHmacKey(secret);
455
+ const valid = await crypto.subtle.verify(
456
+ "HMAC",
457
+ key,
458
+ sigBytes,
459
+ payloadBytes
460
+ );
461
+ if (!valid) return false;
462
+ const payload = new TextDecoder().decode(payloadBytes);
463
+ const [cookieIp, tsStr] = payload.split(":");
464
+ if (cookieIp !== ip) return false;
465
+ const ts = parseInt(tsStr, 10);
466
+ if (isNaN(ts) || Date.now() - ts > COOKIE_MAX_AGE_SEC * 1e3) return false;
467
+ return true;
468
+ } catch {
469
+ return false;
470
+ }
471
+ }
472
+ function getClientIp(c) {
473
+ return c.req.header("CF-Connecting-IP") ?? c.req.header("X-Real-IP") ?? c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ?? "unknown";
474
+ }
475
+ function isExempt(path) {
476
+ const normalized = path.toLowerCase().replace(/\/$/, "");
477
+ return EXEMPT_PATHS.some((p) => {
478
+ const ep = p.toLowerCase().replace(/\/$/, "");
479
+ return normalized === ep || normalized.startsWith(ep + "/");
480
+ });
481
+ }
482
+ function setCookieHeader(c, token) {
483
+ const isSecure = c.req.raw.url.startsWith("https") || c.req.header("x-forwarded-proto") === "https";
484
+ c.header(
485
+ "Set-Cookie",
486
+ `${TURNSTILE_COOKIE_NAME}=${token}; Path=/; Max-Age=${COOKIE_MAX_AGE_SEC}; ${isSecure ? "Secure; " : ""}HttpOnly; SameSite=Lax`
487
+ );
488
+ }
489
+ async function handleTurnstileVerify(c) {
490
+ const body = await c.req.json();
491
+ const { token } = body;
492
+ if (!token) {
493
+ return c.json(
494
+ { error: { code: "VALIDATION_ERROR", message: "Missing Turnstile token" } },
495
+ 422
496
+ );
497
+ }
498
+ const secret = c.env.TURNSTILE_SECRET_KEY;
499
+ if (!secret) {
500
+ return c.json(
501
+ { error: { code: "CONFIG_ERROR", message: "Turnstile not configured" } },
502
+ 500
503
+ );
504
+ }
505
+ const ip = getClientIp(c);
506
+ const formData = new URLSearchParams();
507
+ formData.append("secret", secret);
508
+ formData.append("response", token);
509
+ if (ip !== "unknown") {
510
+ formData.append("remoteip", ip);
511
+ }
512
+ const res = await fetch(VERIFY_URL, {
513
+ method: "POST",
514
+ body: formData
515
+ });
516
+ const outcome = await res.json();
517
+ if (!outcome.success) {
518
+ return c.json(
519
+ { error: { code: "TURNSTILE_FAILED", message: "Turnstile verification failed", details: outcome["error-codes"] } },
520
+ 403
521
+ );
522
+ }
523
+ const cookieToken = await signCookie(secret, ip);
524
+ setCookieHeader(c, cookieToken);
525
+ return c.json({ success: true });
526
+ }
527
+ function createTurnstileMiddleware() {
528
+ return createMiddleware(async (c, next) => {
529
+ if (isExempt(c.req.path)) return next();
530
+ if (c.req.method === "OPTIONS") return next();
531
+ if (c.req.header("Authorization")) return next();
532
+ const secret = c.env.TURNSTILE_SECRET_KEY;
533
+ if (!secret) return next();
534
+ const cookieHeader = c.req.header("Cookie") ?? "";
535
+ const cookieMatch = cookieHeader.match(
536
+ new RegExp(`${TURNSTILE_COOKIE_NAME}=([^;]+)`)
537
+ );
538
+ if (cookieMatch) {
539
+ const ip = getClientIp(c);
540
+ const valid = await validateCookie(secret, cookieMatch[1], ip);
541
+ if (valid) return next();
542
+ }
543
+ return c.json(
544
+ { error: { code: "TURNSTILE_REQUIRED", message: "Turnstile verification required" } },
545
+ 401
546
+ );
547
+ });
548
+ }
495
549
  var DLSU_DOMAIN = "@dlsu.edu.ph";
496
550
  function createAuth(env) {
497
551
  const db = createDb(env.DB);
@@ -787,7 +841,14 @@ healthRoute.get("/", async (c) => {
787
841
  const env = c.env;
788
842
  const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
789
843
  const hasResend = Boolean(env.RESEND_API_KEY);
790
- const hasGForms = Boolean(env.GFORMS_SERVICE_ACCOUNT_JSON);
844
+ let hasGForms = false;
845
+ if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
846
+ try {
847
+ const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
848
+ hasGForms = Boolean(parsed.client_email && parsed.private_key);
849
+ } catch {
850
+ }
851
+ }
791
852
  const probes = [];
792
853
  if (hasSes) {
793
854
  probes.push(
@@ -845,6 +906,7 @@ var CacheService = class {
845
906
  constructor(kv) {
846
907
  this.kv = kv;
847
908
  }
909
+ kv;
848
910
  async get(key) {
849
911
  return this.kv.get(key, "json");
850
912
  }
@@ -890,6 +952,8 @@ var SlotsService = class {
890
952
  this.db = db;
891
953
  this.cache = cache;
892
954
  }
955
+ db;
956
+ cache;
893
957
  kvKey(slug) {
894
958
  return `${SLOT_KV_PREFIX}${slug}`;
895
959
  }
@@ -1044,7 +1108,10 @@ var GFormsService = class {
1044
1108
  const response = await fetch(url.toString(), {
1045
1109
  headers: { Authorization: `Bearer ${token}` }
1046
1110
  });
1047
- if (!response.ok) throw new Error(`Forms API error: ${response.status}`);
1111
+ if (!response.ok) {
1112
+ const err = await response.text();
1113
+ throw new Error(`Forms API error: ${response.status} ${err}`);
1114
+ }
1048
1115
  const data = await response.json();
1049
1116
  allResponses.push(...data.responses ?? []);
1050
1117
  pageToken = data.nextPageToken;
@@ -1318,6 +1385,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1318
1385
  c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1319
1386
  return c.json({ data: info });
1320
1387
  });
1388
+ classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
1389
+ const { slug } = c.req.param();
1390
+ const db = createDb(c.env.DB);
1391
+ const cache = new CacheService(c.env.KV);
1392
+ const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1393
+ const slots = new SlotsService(db, cache);
1394
+ const event = await db.query.events.findFirst({
1395
+ where: eq(events.slug, slug),
1396
+ columns: { gformsId: true }
1397
+ });
1398
+ if (!event) throw notFound("Event");
1399
+ if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1400
+ try {
1401
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1402
+ await slots.correctCount(slug, googleCount);
1403
+ return c.json({ data: { registeredSlots: googleCount } });
1404
+ } catch (err) {
1405
+ const message = err?.message ?? "Failed to fetch from Google Forms API";
1406
+ return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1407
+ }
1408
+ });
1321
1409
  classesRoute.post(
1322
1410
  "/",
1323
1411
  authMiddleware,
@@ -1504,7 +1592,7 @@ siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
1504
1592
  set: { value: JSON.stringify(value), updatedAt: now }
1505
1593
  });
1506
1594
  await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
1507
- expirationTtl: 600
1595
+ expirationTtl: 86400
1508
1596
  });
1509
1597
  return c.json({ data: { key, value } });
1510
1598
  });
@@ -1577,54 +1665,244 @@ gformsWebhookRoute.post("/", internalMiddleware, async (c) => {
1577
1665
  return c.json({ error: "Invalid signature" }, 403);
1578
1666
  }
1579
1667
  }
1580
- let payload;
1581
- try {
1582
- payload = JSON.parse(rawBody);
1583
- } catch {
1584
- return c.json({ error: "Invalid payload" }, 400);
1585
- }
1586
- const { formId } = payload;
1587
- if (!formId) return c.json({ error: "Missing formId" }, 400);
1588
- const db = createDb(c.env.DB);
1589
- const cache = new CacheService(c.env.KV);
1590
- const event = await db.query.events.findFirst({
1591
- where: eq(events.gformsId, formId),
1592
- columns: { slug: true, maxSlots: true, registeredSlots: true }
1668
+ let payload;
1669
+ try {
1670
+ payload = JSON.parse(rawBody);
1671
+ } catch {
1672
+ return c.json({ error: "Invalid payload" }, 400);
1673
+ }
1674
+ const { formId } = payload;
1675
+ if (!formId) return c.json({ error: "Missing formId" }, 400);
1676
+ const db = createDb(c.env.DB);
1677
+ const cache = new CacheService(c.env.KV);
1678
+ const event = await db.query.events.findFirst({
1679
+ where: eq(events.gformsId, formId),
1680
+ columns: { slug: true, maxSlots: true, registeredSlots: true }
1681
+ });
1682
+ if (!event) {
1683
+ console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
1684
+ return c.json({ ok: true });
1685
+ }
1686
+ const slotsService = new SlotsService(db, cache);
1687
+ const updated = await slotsService.increment(event.slug);
1688
+ console.log(
1689
+ `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1690
+ );
1691
+ return c.json({ ok: true });
1692
+ });
1693
+ async function verifyGoogSignature(body, signature, secret) {
1694
+ try {
1695
+ const key = await crypto.subtle.importKey(
1696
+ "raw",
1697
+ new TextEncoder().encode(secret),
1698
+ { name: "HMAC", hash: "SHA-256" },
1699
+ false,
1700
+ ["verify"]
1701
+ );
1702
+ const sigHex = signature.replace(/^hmac-sha256=/, "");
1703
+ const sigBytes = Uint8Array.from(
1704
+ sigHex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? []
1705
+ );
1706
+ return crypto.subtle.verify(
1707
+ "HMAC",
1708
+ key,
1709
+ sigBytes,
1710
+ new TextEncoder().encode(body)
1711
+ );
1712
+ } catch {
1713
+ return false;
1714
+ }
1715
+ }
1716
+ var LOCK_KEY = "cron:reconcile-slots:lock";
1717
+ var LOCK_TTL = 300;
1718
+ async function reconcileSlots(env) {
1719
+ const db = createDb(env.DB);
1720
+ const cache = new CacheService(env.KV);
1721
+ const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
1722
+ const slots = new SlotsService(db, cache);
1723
+ const lock = await cache.get(LOCK_KEY);
1724
+ if (lock) {
1725
+ console.log("[reconcile-slots] Lock held, skipping.");
1726
+ return;
1727
+ }
1728
+ await cache.set(LOCK_KEY, "1", LOCK_TTL);
1729
+ try {
1730
+ const publishedEvents = await db.query.events.findMany({
1731
+ where: isNotNull(events.gformsId),
1732
+ columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
1733
+ });
1734
+ const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
1735
+ let corrected = 0;
1736
+ for (const event of eventsWithForms) {
1737
+ try {
1738
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1739
+ const localCount = event.registeredSlots;
1740
+ if (googleCount !== localCount) {
1741
+ console.warn(
1742
+ `[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
1743
+ );
1744
+ await slots.correctCount(event.slug, googleCount);
1745
+ corrected++;
1746
+ }
1747
+ } catch (err) {
1748
+ console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
1749
+ }
1750
+ }
1751
+ console.log(
1752
+ `[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
1753
+ );
1754
+ } finally {
1755
+ await cache.del(LOCK_KEY);
1756
+ }
1757
+ }
1758
+
1759
+ // src/routes/internal/reconcile-slots.ts
1760
+ var reconcileSlotsRoute = new Hono();
1761
+ reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
1762
+ await reconcileSlots(c.env);
1763
+ return c.json({ ok: true });
1764
+ });
1765
+ async function batchRelease(env) {
1766
+ const db = createDb(env.DB);
1767
+ const cache = new CacheService(env.KV);
1768
+ const now = Math.floor(Date.now() / 1e3);
1769
+ const toPublish = await db.query.events.findMany({
1770
+ where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
1771
+ columns: { id: true, slug: true }
1772
+ });
1773
+ if (toPublish.length === 0) return;
1774
+ const ids = toPublish.map((e) => e.id);
1775
+ await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1776
+ // Drizzle doesn't have inArray for D1; use raw SQL for batch
1777
+ sql`${events.id} IN (${sql.join(
1778
+ ids.map((id) => sql`${id}`),
1779
+ sql`, `
1780
+ )})`
1781
+ );
1782
+ await cache.del("events:list");
1783
+ await cache.del("events:etag");
1784
+ console.log(
1785
+ `[batch-release] Published ${toPublish.length} events:`,
1786
+ toPublish.map((e) => e.slug).join(", ")
1787
+ );
1788
+ }
1789
+
1790
+ // src/routes/internal/batch-release.ts
1791
+ var batchReleaseRoute = new Hono();
1792
+ batchReleaseRoute.post("/", internalMiddleware, async (c) => {
1793
+ await batchRelease(c.env);
1794
+ return c.json({ ok: true });
1795
+ });
1796
+ function parseStartTimestamp(dateTime, startTime) {
1797
+ if (!dateTime) return null;
1798
+ const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
1799
+ const ms = Date.parse(combined);
1800
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
1801
+ }
1802
+ async function reminderEmails(env) {
1803
+ if (!env.EMAIL_QUEUE) {
1804
+ console.warn(
1805
+ "[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
1806
+ );
1807
+ return;
1808
+ }
1809
+ const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
1810
+ const hasResend = !!env.RESEND_API_KEY;
1811
+ if (!hasSes && !hasResend) {
1812
+ console.warn(
1813
+ "[reminder-emails] No email providers configured. Skipping reminders."
1814
+ );
1815
+ return;
1816
+ }
1817
+ const db = createDb(env.DB);
1818
+ const now = Math.floor(Date.now() / 1e3);
1819
+ const candidates24h = await db.query.events.findMany({
1820
+ where: and(
1821
+ eq(events.status, "published"),
1822
+ eq(events.reminder24hSent, false)
1823
+ ),
1824
+ columns: {
1825
+ id: true,
1826
+ dateTime: true,
1827
+ startTime: true
1828
+ }
1829
+ });
1830
+ for (const event of candidates24h) {
1831
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
1832
+ if (!startsAt) continue;
1833
+ const hoursUntil = (startsAt - now) / 3600;
1834
+ if (hoursUntil <= 25 && hoursUntil >= 23) {
1835
+ await env.EMAIL_QUEUE.send({
1836
+ type: "send_reminder_email",
1837
+ payload: { eventId: event.id, hoursBeforeEvent: 24 }
1838
+ });
1839
+ }
1840
+ }
1841
+ const candidates1h = await db.query.events.findMany({
1842
+ where: and(
1843
+ eq(events.status, "published"),
1844
+ eq(events.reminder1hSent, false)
1845
+ ),
1846
+ columns: {
1847
+ id: true,
1848
+ dateTime: true,
1849
+ startTime: true
1850
+ }
1593
1851
  });
1594
- if (!event) {
1595
- console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
1596
- return c.json({ ok: true });
1852
+ for (const event of candidates1h) {
1853
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
1854
+ if (!startsAt) continue;
1855
+ const hoursUntil = (startsAt - now) / 3600;
1856
+ if (hoursUntil <= 1.5 && hoursUntil >= 0) {
1857
+ await env.EMAIL_QUEUE.send({
1858
+ type: "send_reminder_email",
1859
+ payload: { eventId: event.id, hoursBeforeEvent: 1 }
1860
+ });
1861
+ }
1597
1862
  }
1598
- const slotsService = new SlotsService(db, cache);
1599
- const updated = await slotsService.increment(event.slug);
1600
- console.log(
1601
- `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1602
- );
1863
+ }
1864
+
1865
+ // src/routes/internal/reminder-emails.ts
1866
+ var reminderEmailsRoute = new Hono();
1867
+ reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
1868
+ await reminderEmails(c.env);
1603
1869
  return c.json({ ok: true });
1604
1870
  });
1605
- async function verifyGoogSignature(body, signature, secret) {
1606
- try {
1607
- const key = await crypto.subtle.importKey(
1608
- "raw",
1609
- new TextEncoder().encode(secret),
1610
- { name: "HMAC", hash: "SHA-256" },
1611
- false,
1612
- ["verify"]
1613
- );
1614
- const sigHex = signature.replace(/^hmac-sha256=/, "");
1615
- const sigBytes = Uint8Array.from(
1616
- sigHex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? []
1617
- );
1618
- return crypto.subtle.verify(
1619
- "HMAC",
1620
- key,
1621
- sigBytes,
1622
- new TextEncoder().encode(body)
1623
- );
1624
- } catch {
1625
- return false;
1871
+ var RENEWAL_WINDOW = 86400;
1872
+ async function renewWatches(env) {
1873
+ const db = createDb(env.DB);
1874
+ const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
1875
+ const now = Math.floor(Date.now() / 1e3);
1876
+ const threshold = now + RENEWAL_WINDOW;
1877
+ const expiring = await db.query.events.findMany({
1878
+ where: and(
1879
+ eq(events.status, "published"),
1880
+ lte(events.watchExpiresAt, threshold)
1881
+ ),
1882
+ columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
1883
+ });
1884
+ const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
1885
+ let renewed = 0;
1886
+ for (const event of watchEvents) {
1887
+ try {
1888
+ const result = await gforms.renewWatch(event.gformsId, event.watchId);
1889
+ const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
1890
+ await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
1891
+ renewed++;
1892
+ console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
1893
+ } catch (err) {
1894
+ console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
1895
+ }
1626
1896
  }
1897
+ console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
1627
1898
  }
1899
+
1900
+ // src/routes/internal/renew-watches.ts
1901
+ var renewWatchesRoute = new Hono();
1902
+ renewWatchesRoute.post("/", internalMiddleware, async (c) => {
1903
+ await renewWatches(c.env);
1904
+ return c.json({ ok: true });
1905
+ });
1628
1906
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1629
1907
  "image/jpeg",
1630
1908
  "image/png",
@@ -1891,6 +2169,10 @@ function createApp(options = {}) {
1891
2169
  app2.route("/api/faqs", faqsRoute);
1892
2170
  app2.route("/api/uploads", uploadsRoute);
1893
2171
  app2.route("/internal/gforms-webhook", gformsWebhookRoute);
2172
+ app2.route("/internal/reconcile-slots", reconcileSlotsRoute);
2173
+ app2.route("/internal/batch-release", batchReleaseRoute);
2174
+ app2.route("/internal/reminder-emails", reminderEmailsRoute);
2175
+ app2.route("/internal/renew-watches", renewWatchesRoute);
1894
2176
  app2.onError(errorHandler);
1895
2177
  app2.notFound(
1896
2178
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
@@ -1996,6 +2278,7 @@ var SesError = class extends Error {
1996
2278
  this.status = status;
1997
2279
  this.name = "SesError";
1998
2280
  }
2281
+ status;
1999
2282
  /**
2000
2283
  * True for errors that are permanent (not worth retrying via SES again).
2001
2284
  * 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.
@@ -2320,168 +2603,6 @@ async function processJob(job, services) {
2320
2603
  }
2321
2604
  }
2322
2605
  }
2323
- async function batchRelease(env) {
2324
- const db = createDb(env.DB);
2325
- const cache = new CacheService(env.KV);
2326
- const now = Math.floor(Date.now() / 1e3);
2327
- const toPublish = await db.query.events.findMany({
2328
- where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
2329
- columns: { id: true, slug: true }
2330
- });
2331
- if (toPublish.length === 0) return;
2332
- const ids = toPublish.map((e) => e.id);
2333
- await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
2334
- // Drizzle doesn't have inArray for D1; use raw SQL for batch
2335
- sql`${events.id} IN (${sql.join(
2336
- ids.map((id) => sql`${id}`),
2337
- sql`, `
2338
- )})`
2339
- );
2340
- await cache.del("events:list");
2341
- await cache.del("events:etag");
2342
- console.log(
2343
- `[batch-release] Published ${toPublish.length} events:`,
2344
- toPublish.map((e) => e.slug).join(", ")
2345
- );
2346
- }
2347
- var LOCK_KEY = "cron:reconcile-slots:lock";
2348
- var LOCK_TTL = 300;
2349
- async function reconcileSlots(env) {
2350
- const db = createDb(env.DB);
2351
- const cache = new CacheService(env.KV);
2352
- const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
2353
- const slots = new SlotsService(db, cache);
2354
- const lock = await cache.get(LOCK_KEY);
2355
- if (lock) {
2356
- console.log("[reconcile-slots] Lock held, skipping.");
2357
- return;
2358
- }
2359
- await cache.set(LOCK_KEY, "1", LOCK_TTL);
2360
- try {
2361
- const publishedEvents = await db.query.events.findMany({
2362
- where: eq(events.status, "published"),
2363
- columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
2364
- });
2365
- const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
2366
- let corrected = 0;
2367
- for (const event of eventsWithForms) {
2368
- try {
2369
- const googleCount = await gforms.getExactResponseCount(event.gformsId);
2370
- const localCount = event.registeredSlots;
2371
- if (googleCount !== localCount) {
2372
- console.warn(
2373
- `[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
2374
- );
2375
- await slots.correctCount(event.slug, googleCount);
2376
- corrected++;
2377
- }
2378
- } catch (err) {
2379
- console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
2380
- }
2381
- }
2382
- console.log(
2383
- `[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
2384
- );
2385
- } finally {
2386
- await cache.del(LOCK_KEY);
2387
- }
2388
- }
2389
- function parseStartTimestamp(dateTime, startTime) {
2390
- if (!dateTime) return null;
2391
- const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
2392
- const ms = Date.parse(combined);
2393
- return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
2394
- }
2395
- async function reminderEmails(env) {
2396
- if (!env.EMAIL_QUEUE) {
2397
- console.warn(
2398
- "[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
2399
- );
2400
- return;
2401
- }
2402
- const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
2403
- const hasResend = !!env.RESEND_API_KEY;
2404
- if (!hasSes && !hasResend) {
2405
- console.warn(
2406
- "[reminder-emails] No email providers configured. Skipping reminders."
2407
- );
2408
- return;
2409
- }
2410
- const db = createDb(env.DB);
2411
- const now = Math.floor(Date.now() / 1e3);
2412
- const candidates24h = await db.query.events.findMany({
2413
- where: and(
2414
- eq(events.status, "published"),
2415
- eq(events.reminder24hSent, false)
2416
- ),
2417
- columns: {
2418
- id: true,
2419
- dateTime: true,
2420
- startTime: true
2421
- }
2422
- });
2423
- for (const event of candidates24h) {
2424
- const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
2425
- if (!startsAt) continue;
2426
- const hoursUntil = (startsAt - now) / 3600;
2427
- if (hoursUntil <= 25 && hoursUntil >= 23) {
2428
- await env.EMAIL_QUEUE.send({
2429
- type: "send_reminder_email",
2430
- payload: { eventId: event.id, hoursBeforeEvent: 24 }
2431
- });
2432
- }
2433
- }
2434
- const candidates1h = await db.query.events.findMany({
2435
- where: and(
2436
- eq(events.status, "published"),
2437
- eq(events.reminder1hSent, false)
2438
- ),
2439
- columns: {
2440
- id: true,
2441
- dateTime: true,
2442
- startTime: true
2443
- }
2444
- });
2445
- for (const event of candidates1h) {
2446
- const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
2447
- if (!startsAt) continue;
2448
- const hoursUntil = (startsAt - now) / 3600;
2449
- if (hoursUntil <= 1.5 && hoursUntil >= 0) {
2450
- await env.EMAIL_QUEUE.send({
2451
- type: "send_reminder_email",
2452
- payload: { eventId: event.id, hoursBeforeEvent: 1 }
2453
- });
2454
- }
2455
- }
2456
- }
2457
- var RENEWAL_WINDOW = 86400;
2458
- async function renewWatches(env) {
2459
- const db = createDb(env.DB);
2460
- const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
2461
- const now = Math.floor(Date.now() / 1e3);
2462
- const threshold = now + RENEWAL_WINDOW;
2463
- const expiring = await db.query.events.findMany({
2464
- where: and(
2465
- eq(events.status, "published"),
2466
- lte(events.watchExpiresAt, threshold)
2467
- ),
2468
- columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
2469
- });
2470
- const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
2471
- let renewed = 0;
2472
- for (const event of watchEvents) {
2473
- try {
2474
- const result = await gforms.renewWatch(event.gformsId, event.watchId);
2475
- const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
2476
- await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
2477
- renewed++;
2478
- console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
2479
- } catch (err) {
2480
- console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
2481
- }
2482
- }
2483
- console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
2484
- }
2485
2606
 
2486
2607
  // src/db/migrate.ts
2487
2608
  var PATCH_STATEMENTS = [