@agent-native/core 0.176.1 → 0.177.0-nightly-20260828155142

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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/set-workspace-feature-flag.ts +2 -2
  3. package/corpus/templates/analytics/app/components/agents/FeatureFlagsFleetPanel.tsx +16 -1
  4. package/corpus/templates/analytics/changelog/2026-08-27-show-analytics-s-own-registered-flags-in-fleet-feature-flag-.md +6 -0
  5. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +204 -32
  6. package/corpus/templates/assets/changelog/2026-08-26-templates-across-brand-kits.md +1 -0
  7. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +61 -68
  8. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +9 -3
  9. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +3 -3
  10. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +8 -3
  11. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +12 -3
  12. package/corpus/templates/calendar/changelog/2026-08-28-all-day-events-stay-in-the-compact-top-bar-in-day-and-week-v.md +6 -0
  13. package/corpus/templates/calendar/changelog/2026-08-28-event-creators-can-choose-whether-a-meeting-shows-as-free-or.md +6 -0
  14. package/corpus/templates/clips/app/global.css +0 -27
  15. package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
  16. package/corpus/templates/clips/app/routes/download.tsx +131 -16
  17. package/corpus/templates/clips/app/routes/share.$shareId.tsx +35 -20
  18. package/corpus/templates/clips/server/routes/api/clips-latest.json.get.ts +77 -22
  19. package/corpus/templates/forms/actions/response-insights.ts +4 -4
  20. package/corpus/templates/forms/actions/retry-response-deliveries.ts +26 -0
  21. package/corpus/templates/forms/server/db/schema.ts +64 -17
  22. package/corpus/templates/forms/server/handlers/submissions.ts +1022 -94
  23. package/corpus/templates/forms/server/lib/integrations.ts +100 -33
  24. package/corpus/templates/forms/server/middleware/00-public-cors.ts +1 -1
  25. package/corpus/templates/forms/server/plugins/db.ts +40 -0
  26. package/corpus/templates/plan/agent-native.json +1 -4
  27. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +6 -1
  28. package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +2 -2
  29. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -6
  30. package/corpus/templates/slides/app/components/editor/ImageOverlay.tsx +78 -12
  31. package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +4 -4
  32. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +111 -27
  33. package/corpus/templates/slides/app/components/editor/SlidesLayersPanel.tsx +20 -34
  34. package/corpus/templates/slides/app/components/editor/slide-style.ts +2 -0
  35. package/corpus/templates/slides/app/global.css +35 -0
  36. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +133 -5
  37. package/corpus/templates/slides/app/lib/slide-image-replacement.ts +675 -0
  38. package/corpus/templates/slides/app/lib/validate-native-slide-html.ts +1 -0
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -44
  40. package/corpus/templates/slides/app/root.tsx +1 -22
  41. package/corpus/templates/slides/changelog/2026-08-27-image-fit-controls-apply-cover-crops-and-position.md +6 -0
  42. package/corpus/templates/slides/changelog/2026-08-28-shared-slides-with-a-style-block-render-again.md +6 -0
  43. package/dist/action.d.ts +3 -1
  44. package/dist/client/FeedbackButton.d.ts +12 -0
  45. package/dist/client/FeedbackButton.js +62 -28
  46. package/dist/client/app-providers.js +13 -1
  47. package/dist/client/feedback-context.d.ts +1 -0
  48. package/dist/client/feedback-context.js +3 -2
  49. package/dist/client/host-bridge.d.ts +1 -0
  50. package/dist/client/observability/ThumbsFeedback.js +73 -10
  51. package/dist/client/webmcp.d.ts +4 -0
  52. package/dist/client/webmcp.js +51 -4
  53. package/dist/collab/routes.d.ts +1 -1
  54. package/dist/collab/struct-routes.d.ts +1 -1
  55. package/dist/index.browser.d.ts +1 -1
  56. package/dist/index.browser.js +1 -1
  57. package/dist/observability/routes.d.ts +5 -5
  58. package/dist/observability/routes.js +8 -2
  59. package/dist/observability/store.d.ts +1 -1
  60. package/dist/observability/store.js +18 -3
  61. package/dist/observability/types.d.ts +1 -0
  62. package/dist/progress/routes.d.ts +1 -1
  63. package/dist/resources/handlers.d.ts +2 -2
  64. package/dist/secrets/routes.d.ts +9 -9
  65. package/dist/server/action-routes.d.ts +1 -6
  66. package/dist/server/action-routes.js +58 -17
  67. package/dist/server/agent-chat-plugin.js +8 -1
  68. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  69. package/dist/server/transcribe-voice.d.ts +1 -1
  70. package/docs/content/webmcp.mdx +31 -12
  71. package/package.json +1 -1
@@ -5,7 +5,7 @@ import {
5
5
  verifyCaptcha,
6
6
  } from "@agent-native/core/server";
7
7
  import { assertAccess } from "@agent-native/core/sharing";
8
- import { and, eq, desc, isNull, sql } from "drizzle-orm";
8
+ import { and, desc, eq, isNull, lt, or, sql } from "drizzle-orm";
9
9
  import {
10
10
  defineEventHandler,
11
11
  getRouterParam,
@@ -28,13 +28,20 @@ import {
28
28
  } from "../../shared/submitter-email.js";
29
29
  import type {
30
30
  FormField,
31
- FormIntegration,
32
31
  FormResponse,
33
32
  FormSettings,
34
33
  } from "../../shared/types.js";
35
34
  import { getDb, schema } from "../db/index.js";
36
- import { fireIntegrations } from "../lib/integrations.js";
37
- import { sendNewResponseEmail } from "../lib/response-email.js";
35
+ import {
36
+ buildIntegrationDeliverySnapshots,
37
+ deliverIntegrationDelivery,
38
+ fireIntegrations,
39
+ type IntegrationDeliverySnapshot,
40
+ } from "../lib/integrations.js";
41
+ import {
42
+ sendNewResponseEmail,
43
+ type NewResponseEmailArgs,
44
+ } from "../lib/response-email.js";
38
45
  import {
39
46
  isEmptySubmissionValue,
40
47
  validateSubmissionField,
@@ -44,6 +51,75 @@ const MAX_PAYLOAD_BYTES = 100 * 1024; // 100KB
44
51
  const MIN_FILL_TIME_MS = 500; // reject submits faster than this
45
52
  const MAX_META_TEXT_LENGTH = 500;
46
53
  const MAX_CHAT_SESSION_IDS = 5;
54
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
55
+ const APPLICATION_STATE_DESTINATION = "application-state";
56
+ const RESPONSE_EMAIL_DESTINATION = "email";
57
+ const DELIVERY_CLAIM_LEASE_MS = 60_000;
58
+ const DELIVERY_CLAIM_HEARTBEAT_MS = Math.floor(DELIVERY_CLAIM_LEASE_MS / 3);
59
+
60
+ type ResponseDeliveryStatus = "pending" | "processing" | "succeeded" | "failed";
61
+
62
+ interface DeliverySnapshot {
63
+ formId: string;
64
+ formTitle: string;
65
+ ownerEmail: string | null;
66
+ orgId: string | null;
67
+ fields: FormField[];
68
+ data: Record<string, unknown>;
69
+ submittedAt: string;
70
+ submitterEmail: string | null;
71
+ chatSessionIds: string[];
72
+ activeRunId: string | null;
73
+ pageUrl: string | null;
74
+ clientSurface: string | null;
75
+ emailOnNewResponses: boolean;
76
+ integrations: IntegrationDeliverySnapshot[];
77
+ }
78
+
79
+ type StoredDeliveryPayload =
80
+ | {
81
+ kind: "application-state";
82
+ ownerEmail: string;
83
+ value: { formId: string; responseId: string; timestamp: string };
84
+ }
85
+ | { kind: "email"; args: NewResponseEmailArgs; orgId: string | null }
86
+ | { kind: "integration"; snapshot: IntegrationDeliverySnapshot };
87
+
88
+ function configuredDeliveryStatuses(snapshot: DeliverySnapshot) {
89
+ const statuses: Record<string, "pending"> = {};
90
+ if (snapshot.ownerEmail) statuses[APPLICATION_STATE_DESTINATION] = "pending";
91
+ if (snapshot.emailOnNewResponses && snapshot.ownerEmail) {
92
+ statuses[RESPONSE_EMAIL_DESTINATION] = "pending";
93
+ }
94
+ for (const integration of snapshot.integrations) {
95
+ statuses[`integration:${integration.id}`] = "pending";
96
+ }
97
+ return statuses;
98
+ }
99
+
100
+ function submissionMetadata(body: Record<string, unknown>, anonymous: boolean) {
101
+ const meta =
102
+ !anonymous && typeof body._meta === "object" && body._meta !== null
103
+ ? (body._meta as {
104
+ chatSessionId?: unknown;
105
+ chatSessionIds?: unknown;
106
+ activeRunId?: unknown;
107
+ pageUrl?: unknown;
108
+ clientSurface?: unknown;
109
+ submitterEmail?: unknown;
110
+ })
111
+ : null;
112
+ return {
113
+ submitterEmail: cleanSubmitterEmail(meta?.submitterEmail),
114
+ chatSessionIds: cleanChatSessionIds([
115
+ meta?.chatSessionId,
116
+ meta?.chatSessionIds,
117
+ ]),
118
+ activeRunId: cleanMetaText(meta?.activeRunId),
119
+ pageUrl: scrubPageUrl(meta?.pageUrl),
120
+ clientSurface: cleanClientSurface(meta?.clientSurface),
121
+ };
122
+ }
47
123
 
48
124
  function cleanMetaText(value: unknown): string | null {
49
125
  if (typeof value !== "string") return null;
@@ -77,10 +153,775 @@ function cleanChatSessionIds(value: unknown): string[] {
77
153
  return ids;
78
154
  }
79
155
 
156
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+
160
+ function parseIntegrationSnapshot(value: unknown): IntegrationDeliverySnapshot {
161
+ if (
162
+ !isObjectRecord(value) ||
163
+ typeof value.id !== "string" ||
164
+ typeof value.type !== "string" ||
165
+ typeof value.name !== "string" ||
166
+ typeof value.url !== "string" ||
167
+ !("payload" in value)
168
+ ) {
169
+ throw new Error("Invalid response integration delivery snapshot");
170
+ }
171
+ return {
172
+ id: value.id,
173
+ type: value.type as IntegrationDeliverySnapshot["type"],
174
+ name: value.name,
175
+ url: value.url,
176
+ payload: value.payload,
177
+ };
178
+ }
179
+
180
+ function parseDeliverySnapshot(
181
+ value: string | null | undefined,
182
+ ): DeliverySnapshot | null {
183
+ if (!value) return null;
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(value);
187
+ } catch {
188
+ throw new Error("Unreadable response delivery snapshot");
189
+ }
190
+ if (
191
+ !isObjectRecord(parsed) ||
192
+ typeof parsed.formId !== "string" ||
193
+ typeof parsed.formTitle !== "string" ||
194
+ (parsed.ownerEmail !== null && typeof parsed.ownerEmail !== "string") ||
195
+ (parsed.orgId !== null && typeof parsed.orgId !== "string") ||
196
+ !Array.isArray(parsed.fields) ||
197
+ !isObjectRecord(parsed.data) ||
198
+ typeof parsed.submittedAt !== "string" ||
199
+ (parsed.submitterEmail !== null &&
200
+ typeof parsed.submitterEmail !== "string") ||
201
+ !Array.isArray(parsed.chatSessionIds) ||
202
+ !parsed.chatSessionIds.every((id) => typeof id === "string") ||
203
+ (parsed.activeRunId !== null && typeof parsed.activeRunId !== "string") ||
204
+ (parsed.pageUrl !== null && typeof parsed.pageUrl !== "string") ||
205
+ (parsed.clientSurface !== null &&
206
+ typeof parsed.clientSurface !== "string") ||
207
+ typeof parsed.emailOnNewResponses !== "boolean" ||
208
+ !Array.isArray(parsed.integrations)
209
+ ) {
210
+ throw new Error("Invalid response delivery snapshot");
211
+ }
212
+ return {
213
+ formId: parsed.formId,
214
+ formTitle: parsed.formTitle,
215
+ ownerEmail: parsed.ownerEmail,
216
+ orgId: parsed.orgId,
217
+ fields: parsed.fields as FormField[],
218
+ data: parsed.data,
219
+ submittedAt: parsed.submittedAt,
220
+ submitterEmail: parsed.submitterEmail,
221
+ chatSessionIds: parsed.chatSessionIds,
222
+ activeRunId: parsed.activeRunId,
223
+ pageUrl: parsed.pageUrl,
224
+ clientSurface: parsed.clientSurface,
225
+ emailOnNewResponses: parsed.emailOnNewResponses,
226
+ integrations: parsed.integrations.map(parseIntegrationSnapshot),
227
+ };
228
+ }
229
+
230
+ function buildDeliverySnapshot({
231
+ form,
232
+ settings,
233
+ fields,
234
+ data,
235
+ responseId,
236
+ submittedAt,
237
+ submitterEmail,
238
+ pageUrl,
239
+ clientSurface,
240
+ chatSessionIds,
241
+ activeRunId,
242
+ }: {
243
+ form: typeof schema.forms.$inferSelect;
244
+ settings: FormSettings;
245
+ fields: FormField[];
246
+ data: Record<string, unknown>;
247
+ responseId: string;
248
+ submittedAt: string;
249
+ submitterEmail: string | null;
250
+ pageUrl: string | null;
251
+ clientSurface: string | null;
252
+ chatSessionIds: string[];
253
+ activeRunId: string | null;
254
+ }): DeliverySnapshot {
255
+ const submission = {
256
+ formId: form.id,
257
+ formTitle: form.title,
258
+ responseId,
259
+ fields,
260
+ data,
261
+ submittedAt,
262
+ submitterEmail,
263
+ chatSessionIds,
264
+ activeRunId,
265
+ pageUrl,
266
+ clientSurface,
267
+ };
268
+ return {
269
+ formId: form.id,
270
+ formTitle: form.title,
271
+ ownerEmail: form.ownerEmail ?? null,
272
+ orgId: form.orgId ?? null,
273
+ fields,
274
+ data,
275
+ submittedAt,
276
+ submitterEmail,
277
+ chatSessionIds,
278
+ activeRunId,
279
+ pageUrl,
280
+ clientSurface,
281
+ emailOnNewResponses: settings.emailOnNewResponses === true,
282
+ integrations: buildIntegrationDeliverySnapshots(
283
+ settings.integrations ?? [],
284
+ submission,
285
+ ),
286
+ };
287
+ }
288
+
289
+ function buildResponseDeliveryRows(
290
+ snapshot: DeliverySnapshot,
291
+ responseId: string,
292
+ ) {
293
+ const createdAt = new Date().toISOString();
294
+ const rows: Array<typeof schema.responseDeliveries.$inferInsert> = [];
295
+ const add = (
296
+ destination: string,
297
+ kind: "application-state" | "email" | "integration",
298
+ payload: StoredDeliveryPayload,
299
+ ) => {
300
+ rows.push({
301
+ id: nanoid(),
302
+ responseId,
303
+ destination,
304
+ kind,
305
+ payload: JSON.stringify(payload),
306
+ status: "pending",
307
+ claimToken: null,
308
+ claimedAt: null,
309
+ errorMessage: null,
310
+ createdAt,
311
+ updatedAt: createdAt,
312
+ });
313
+ };
314
+
315
+ if (snapshot.ownerEmail) {
316
+ add(APPLICATION_STATE_DESTINATION, "application-state", {
317
+ kind: "application-state",
318
+ ownerEmail: snapshot.ownerEmail,
319
+ value: {
320
+ formId: snapshot.formId,
321
+ responseId,
322
+ timestamp: snapshot.submittedAt,
323
+ },
324
+ });
325
+ }
326
+ if (snapshot.emailOnNewResponses && snapshot.ownerEmail) {
327
+ add(RESPONSE_EMAIL_DESTINATION, "email", {
328
+ kind: "email",
329
+ orgId: snapshot.orgId,
330
+ args: {
331
+ to: snapshot.ownerEmail,
332
+ formTitle: snapshot.formTitle,
333
+ fields: snapshot.fields,
334
+ data: snapshot.data,
335
+ submittedAt: snapshot.submittedAt,
336
+ },
337
+ });
338
+ }
339
+ for (const integration of snapshot.integrations) {
340
+ add(`integration:${integration.id}`, "integration", {
341
+ kind: "integration",
342
+ snapshot: integration,
343
+ });
344
+ }
345
+ return rows;
346
+ }
347
+
348
+ async function ensureResponseDeliveryRows(
349
+ db: ReturnType<typeof getDb>,
350
+ responseId: string,
351
+ snapshot: DeliverySnapshot,
352
+ ) {
353
+ let rows = await db
354
+ .select()
355
+ .from(schema.responseDeliveries)
356
+ .where(eq(schema.responseDeliveries.responseId, responseId));
357
+ if (rows.length > 0) return rows;
358
+
359
+ const values = buildResponseDeliveryRows(snapshot, responseId);
360
+ if (values.length > 0) {
361
+ await db
362
+ .insert(schema.responseDeliveries)
363
+ .values(values)
364
+ .onConflictDoNothing();
365
+ rows = await db
366
+ .select()
367
+ .from(schema.responseDeliveries)
368
+ .where(eq(schema.responseDeliveries.responseId, responseId));
369
+ }
370
+ return rows;
371
+ }
372
+
373
+ async function claimResponseDelivery(
374
+ db: ReturnType<typeof getDb>,
375
+ deliveryId: string,
376
+ ): Promise<string | null> {
377
+ const claimToken = nanoid();
378
+ const now = new Date();
379
+ const nowIso = now.toISOString();
380
+ const staleIso = new Date(
381
+ now.getTime() - DELIVERY_CLAIM_LEASE_MS,
382
+ ).toISOString();
383
+ const [claimed] = await db
384
+ .update(schema.responseDeliveries)
385
+ .set({
386
+ status: "processing",
387
+ claimToken,
388
+ claimedAt: nowIso,
389
+ updatedAt: nowIso,
390
+ errorMessage: null,
391
+ })
392
+ .where(
393
+ and(
394
+ eq(schema.responseDeliveries.id, deliveryId),
395
+ or(
396
+ eq(schema.responseDeliveries.status, "pending"),
397
+ eq(schema.responseDeliveries.status, "failed"),
398
+ and(
399
+ eq(schema.responseDeliveries.status, "processing"),
400
+ lt(schema.responseDeliveries.claimedAt, staleIso),
401
+ ),
402
+ ),
403
+ ),
404
+ )
405
+ .returning({ id: schema.responseDeliveries.id });
406
+ return claimed ? claimToken : null;
407
+ }
408
+
409
+ async function renewResponseDeliveryClaim(
410
+ db: ReturnType<typeof getDb>,
411
+ deliveryId: string,
412
+ claimToken: string,
413
+ ): Promise<void> {
414
+ const nowIso = new Date().toISOString();
415
+ const [renewed] = await db
416
+ .update(schema.responseDeliveries)
417
+ .set({ claimedAt: nowIso, updatedAt: nowIso })
418
+ .where(
419
+ and(
420
+ eq(schema.responseDeliveries.id, deliveryId),
421
+ eq(schema.responseDeliveries.claimToken, claimToken),
422
+ eq(schema.responseDeliveries.status, "processing"),
423
+ ),
424
+ )
425
+ .returning({ id: schema.responseDeliveries.id });
426
+ if (!renewed) throw new Error("Response delivery claim was lost");
427
+ }
428
+
429
+ function startResponseDeliveryLeaseHeartbeat(
430
+ db: ReturnType<typeof getDb>,
431
+ deliveryId: string,
432
+ claimToken: string,
433
+ ) {
434
+ let stopped = false;
435
+ let failure: unknown = null;
436
+ let pendingRenewal: Promise<void> | null = null;
437
+ const timer = setInterval(() => {
438
+ if (stopped || failure || pendingRenewal) return;
439
+ pendingRenewal = renewResponseDeliveryClaim(db, deliveryId, claimToken)
440
+ .catch((error) => {
441
+ failure = error;
442
+ clearInterval(timer);
443
+ })
444
+ .finally(() => {
445
+ pendingRenewal = null;
446
+ });
447
+ }, DELIVERY_CLAIM_HEARTBEAT_MS);
448
+ (timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
449
+
450
+ return {
451
+ assertHealthy() {
452
+ if (failure) throw failure;
453
+ },
454
+ async stop() {
455
+ stopped = true;
456
+ clearInterval(timer);
457
+ await pendingRenewal;
458
+ },
459
+ };
460
+ }
461
+
462
+ async function markResponseDelivery(
463
+ db: ReturnType<typeof getDb>,
464
+ deliveryId: string,
465
+ claimToken: string,
466
+ status: Exclude<ResponseDeliveryStatus, "processing">,
467
+ errorMessage: string | null = null,
468
+ ): Promise<void> {
469
+ const nowIso = new Date().toISOString();
470
+ try {
471
+ const [updated] = await db
472
+ .update(schema.responseDeliveries)
473
+ .set({
474
+ status,
475
+ claimToken: null,
476
+ claimedAt: null,
477
+ errorMessage,
478
+ updatedAt: nowIso,
479
+ })
480
+ .where(
481
+ and(
482
+ eq(schema.responseDeliveries.id, deliveryId),
483
+ eq(schema.responseDeliveries.claimToken, claimToken),
484
+ ),
485
+ )
486
+ .returning({ id: schema.responseDeliveries.id });
487
+ if (updated) return;
488
+ throw new Error("Response delivery claim was lost");
489
+ } catch (error) {
490
+ // The database client can report a transport error after applying the
491
+ // update. Re-read before retrying an external effect so that accepted
492
+ // delivery is reconciled instead of duplicated.
493
+ const [current] = await db
494
+ .select({
495
+ status: schema.responseDeliveries.status,
496
+ claimToken: schema.responseDeliveries.claimToken,
497
+ })
498
+ .from(schema.responseDeliveries)
499
+ .where(eq(schema.responseDeliveries.id, deliveryId));
500
+ if (current?.status === status && current.claimToken === null) return;
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ function parseStoredDeliveryPayload(value: string): StoredDeliveryPayload {
506
+ let parsed: unknown;
507
+ try {
508
+ parsed = JSON.parse(value);
509
+ } catch {
510
+ throw new Error("Unreadable response delivery payload");
511
+ }
512
+ if (!isObjectRecord(parsed) || typeof parsed.kind !== "string") {
513
+ throw new Error("Invalid response delivery payload");
514
+ }
515
+ if (
516
+ parsed.kind === "application-state" &&
517
+ typeof parsed.ownerEmail === "string" &&
518
+ isObjectRecord(parsed.value) &&
519
+ typeof parsed.value.formId === "string" &&
520
+ typeof parsed.value.responseId === "string" &&
521
+ typeof parsed.value.timestamp === "string"
522
+ ) {
523
+ return {
524
+ kind: "application-state",
525
+ ownerEmail: parsed.ownerEmail,
526
+ value: {
527
+ formId: parsed.value.formId,
528
+ responseId: parsed.value.responseId,
529
+ timestamp: parsed.value.timestamp,
530
+ },
531
+ };
532
+ }
533
+ if (
534
+ parsed.kind === "email" &&
535
+ (parsed.orgId === null || typeof parsed.orgId === "string") &&
536
+ isObjectRecord(parsed.args) &&
537
+ typeof parsed.args.to === "string" &&
538
+ typeof parsed.args.formTitle === "string" &&
539
+ Array.isArray(parsed.args.fields) &&
540
+ isObjectRecord(parsed.args.data) &&
541
+ typeof parsed.args.submittedAt === "string"
542
+ ) {
543
+ return {
544
+ kind: "email",
545
+ orgId: parsed.orgId,
546
+ args: {
547
+ to: parsed.args.to,
548
+ formTitle: parsed.args.formTitle,
549
+ fields: parsed.args.fields as FormField[],
550
+ data: parsed.args.data,
551
+ submittedAt: parsed.args.submittedAt,
552
+ },
553
+ };
554
+ }
555
+ if (parsed.kind === "integration") {
556
+ return {
557
+ kind: "integration",
558
+ snapshot: parseIntegrationSnapshot(parsed.snapshot),
559
+ };
560
+ }
561
+ throw new Error("Invalid response delivery payload");
562
+ }
563
+
564
+ function deliveryErrorMessage(error: unknown): string {
565
+ return (error instanceof Error ? error.message : String(error)).slice(0, 500);
566
+ }
567
+
568
+ async function deliverStoredResponseDelivery(
569
+ row: typeof schema.responseDeliveries.$inferSelect,
570
+ ): Promise<void> {
571
+ const payload = parseStoredDeliveryPayload(row.payload);
572
+ switch (payload.kind) {
573
+ case "application-state": {
574
+ const { appStatePut } =
575
+ await import("@agent-native/core/application-state");
576
+ await appStatePut(payload.ownerEmail, "new-submission", payload.value);
577
+ return;
578
+ }
579
+ case "email":
580
+ await runWithRequestContext(
581
+ {
582
+ userEmail: payload.args.to,
583
+ orgId: payload.orgId ?? undefined,
584
+ },
585
+ () => sendNewResponseEmail(payload.args),
586
+ );
587
+ return;
588
+ case "integration":
589
+ await deliverIntegrationDelivery(
590
+ payload.snapshot,
591
+ `forms:${row.responseId}:${row.destination}`,
592
+ );
593
+ }
594
+ }
595
+
596
+ async function deliverStoredResponseDeliveries(
597
+ db: ReturnType<typeof getDb>,
598
+ responseId: string,
599
+ snapshot: DeliverySnapshot,
600
+ ): Promise<boolean> {
601
+ const rows = await ensureResponseDeliveryRows(db, responseId, snapshot);
602
+ await Promise.all(
603
+ rows.map(async (row) => {
604
+ const claimToken = await claimResponseDelivery(db, row.id);
605
+ if (!claimToken) return;
606
+ const heartbeat = startResponseDeliveryLeaseHeartbeat(
607
+ db,
608
+ row.id,
609
+ claimToken,
610
+ );
611
+ try {
612
+ await deliverStoredResponseDelivery(row);
613
+ await heartbeat.stop();
614
+ heartbeat.assertHealthy();
615
+ await markResponseDelivery(db, row.id, claimToken, "succeeded");
616
+ } catch (error) {
617
+ await heartbeat.stop();
618
+ console.warn(`[forms] ${row.destination} delivery failed:`, error);
619
+ await markResponseDelivery(
620
+ db,
621
+ row.id,
622
+ claimToken,
623
+ "failed",
624
+ deliveryErrorMessage(error),
625
+ );
626
+ }
627
+ }),
628
+ );
629
+ await refreshResponseDeliverySummary(db, responseId);
630
+ const currentRows = await db
631
+ .select()
632
+ .from(schema.responseDeliveries)
633
+ .where(eq(schema.responseDeliveries.responseId, responseId));
634
+ return currentRows.some((row) => row.status !== "succeeded");
635
+ }
636
+
637
+ export async function reconcileResponseDeliveries(responseId: string) {
638
+ const db = getDb();
639
+ const [response] = await db
640
+ .select({
641
+ id: schema.responses.id,
642
+ deliverySnapshot: schema.responses.deliverySnapshot,
643
+ })
644
+ .from(schema.responses)
645
+ .where(eq(schema.responses.id, responseId));
646
+ if (!response) throw new Error(`Response ${responseId} not found`);
647
+
648
+ const snapshot = parseDeliverySnapshot(response.deliverySnapshot);
649
+ if (!snapshot) {
650
+ throw new Error(`Response ${responseId} has no delivery snapshot`);
651
+ }
652
+
653
+ const pending = await deliverStoredResponseDeliveries(
654
+ db,
655
+ response.id,
656
+ snapshot,
657
+ );
658
+ return {
659
+ success: !pending,
660
+ retryable: pending,
661
+ id: response.id,
662
+ };
663
+ }
664
+
665
+ function deliverySummary(
666
+ rows: Array<typeof schema.responseDeliveries.$inferSelect>,
667
+ ) {
668
+ const summary: Record<string, "pending" | "succeeded" | "failed"> = {};
669
+ for (const row of [...rows].sort((a, b) =>
670
+ a.destination.localeCompare(b.destination),
671
+ )) {
672
+ summary[row.destination] =
673
+ row.status === "processing" ? "pending" : row.status;
674
+ }
675
+ return summary;
676
+ }
677
+
678
+ async function refreshResponseDeliverySummary(
679
+ db: ReturnType<typeof getDb>,
680
+ responseId: string,
681
+ ): Promise<void> {
682
+ await db.transaction(async (tx) => {
683
+ const [response] = await tx
684
+ .select({ deliveryStatus: schema.responses.deliveryStatus })
685
+ .from(schema.responses)
686
+ .where(eq(schema.responses.id, responseId));
687
+ if (!response) return;
688
+
689
+ // Serialize all summary writers on the response row before reading the
690
+ // delivery rows, so a retry cannot commit an older aggregate after a
691
+ // newer destination status has been recorded.
692
+ await tx
693
+ .update(schema.responses)
694
+ .set({ deliveryStatus: response.deliveryStatus })
695
+ .where(eq(schema.responses.id, responseId));
696
+ const rows = await tx
697
+ .select()
698
+ .from(schema.responseDeliveries)
699
+ .where(eq(schema.responseDeliveries.responseId, responseId));
700
+ await tx
701
+ .update(schema.responses)
702
+ .set({ deliveryStatus: JSON.stringify(deliverySummary(rows)) })
703
+ .where(eq(schema.responses.id, responseId));
704
+ });
705
+ }
706
+
707
+ async function deliverBestEffortResponseSideEffects({
708
+ form,
709
+ settings,
710
+ fields,
711
+ data,
712
+ responseId,
713
+ submittedAt,
714
+ submitterEmail,
715
+ pageUrl,
716
+ clientSurface,
717
+ chatSessionIds,
718
+ activeRunId,
719
+ }: {
720
+ form: typeof schema.forms.$inferSelect;
721
+ settings: FormSettings;
722
+ fields: FormField[];
723
+ data: Record<string, unknown>;
724
+ responseId: string;
725
+ submittedAt: string;
726
+ submitterEmail: string | null;
727
+ pageUrl: string | null;
728
+ clientSurface: string | null;
729
+ chatSessionIds: string[];
730
+ activeRunId: string | null;
731
+ }): Promise<void> {
732
+ const run = async (label: string, effect: () => Promise<void>) => {
733
+ try {
734
+ await effect();
735
+ } catch (error) {
736
+ console.warn(`[forms] ${label} delivery failed:`, error);
737
+ }
738
+ };
739
+ if (form.ownerEmail) {
740
+ await run("application state", async () => {
741
+ const { appStatePut } =
742
+ await import("@agent-native/core/application-state");
743
+ await appStatePut(form.ownerEmail!, "new-submission", {
744
+ formId: form.id,
745
+ responseId,
746
+ timestamp: submittedAt,
747
+ });
748
+ });
749
+ }
750
+ if (settings.emailOnNewResponses === true && form.ownerEmail) {
751
+ await run("new response email", async () => {
752
+ await runWithRequestContext(
753
+ { userEmail: form.ownerEmail!, orgId: form.orgId ?? undefined },
754
+ () =>
755
+ sendNewResponseEmail({
756
+ to: form.ownerEmail!,
757
+ formTitle: form.title,
758
+ fields,
759
+ data,
760
+ submittedAt,
761
+ }),
762
+ );
763
+ });
764
+ }
765
+ const integrations = settings.integrations ?? [];
766
+ if (integrations.length > 0) {
767
+ await fireIntegrations(integrations, {
768
+ formId: form.id,
769
+ formTitle: form.title,
770
+ responseId,
771
+ fields,
772
+ data,
773
+ submittedAt,
774
+ submitterEmail,
775
+ chatSessionIds,
776
+ activeRunId,
777
+ pageUrl,
778
+ clientSurface,
779
+ });
780
+ }
781
+ }
782
+
783
+ async function deliverResponseSideEffects({
784
+ db,
785
+ form,
786
+ settings,
787
+ fields,
788
+ data,
789
+ responseId,
790
+ submittedAt,
791
+ submitterEmail,
792
+ pageUrl,
793
+ clientSurface,
794
+ chatSessionIds,
795
+ activeRunId,
796
+ trackDelivery,
797
+ deliverySnapshot,
798
+ }: {
799
+ db: ReturnType<typeof getDb>;
800
+ form: typeof schema.forms.$inferSelect;
801
+ settings: FormSettings;
802
+ fields: FormField[];
803
+ data: Record<string, unknown>;
804
+ responseId: string;
805
+ submittedAt: string;
806
+ submitterEmail: string | null;
807
+ pageUrl: string | null;
808
+ clientSurface: string | null;
809
+ chatSessionIds: string[];
810
+ activeRunId: string | null;
811
+ trackDelivery: boolean;
812
+ deliverySnapshot: DeliverySnapshot | null;
813
+ }): Promise<boolean> {
814
+ if (!trackDelivery) {
815
+ await deliverBestEffortResponseSideEffects({
816
+ form,
817
+ settings,
818
+ fields,
819
+ data,
820
+ responseId,
821
+ submittedAt,
822
+ submitterEmail,
823
+ pageUrl,
824
+ clientSurface,
825
+ chatSessionIds,
826
+ activeRunId,
827
+ });
828
+ return false;
829
+ }
830
+ const snapshot =
831
+ deliverySnapshot ??
832
+ buildDeliverySnapshot({
833
+ form,
834
+ settings,
835
+ fields,
836
+ data,
837
+ responseId,
838
+ submittedAt,
839
+ submitterEmail,
840
+ pageUrl,
841
+ clientSurface,
842
+ chatSessionIds,
843
+ activeRunId,
844
+ });
845
+ return deliverStoredResponseDeliveries(db, responseId, snapshot);
846
+ }
847
+
80
848
  export const submitForm = defineEventHandler(async (event: H3Event) => {
81
849
  const db = getDb();
82
850
  const id = getRouterParam(event, "id") as string;
83
851
 
852
+ // coercion-ok: malformed request bodies are rejected as invalid submissions.
853
+ const body = await readBody(event).catch(() => null);
854
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
855
+ setResponseStatus(event, 400);
856
+ return { error: "Invalid submission payload" };
857
+ }
858
+
859
+ // Check overall payload size
860
+ const bodyStr = JSON.stringify(body);
861
+ if (Buffer.byteLength(bodyStr, "utf8") > MAX_PAYLOAD_BYTES) {
862
+ setResponseStatus(event, 413);
863
+ return { error: "Payload too large" };
864
+ }
865
+
866
+ const rawIdempotencyKey = getRequestHeader(event, "idempotency-key");
867
+ if (
868
+ rawIdempotencyKey &&
869
+ rawIdempotencyKey.trim().length > MAX_IDEMPOTENCY_KEY_LENGTH
870
+ ) {
871
+ setResponseStatus(event, 400);
872
+ return { error: "Idempotency-Key is too long" };
873
+ }
874
+ const idempotencyKey = rawIdempotencyKey?.trim() || null;
875
+
876
+ // A retry must be able to reconcile a persisted response even after its
877
+ // form is unpublished or soft-deleted. The immutable snapshot is the only
878
+ // input needed for delivery, so do this lookup before loading the active
879
+ // form or applying one-time submission checks.
880
+ if (idempotencyKey) {
881
+ const [existing] = await db
882
+ .select({
883
+ id: schema.responses.id,
884
+ deliverySnapshot: schema.responses.deliverySnapshot,
885
+ })
886
+ .from(schema.responses)
887
+ .where(
888
+ and(
889
+ eq(schema.responses.formId, id),
890
+ eq(schema.responses.idempotencyKey, idempotencyKey),
891
+ ),
892
+ );
893
+ if (existing) {
894
+ try {
895
+ const snapshot = parseDeliverySnapshot(existing.deliverySnapshot);
896
+ if (!snapshot) {
897
+ throw new Error(`Response ${existing.id} has no delivery snapshot`);
898
+ }
899
+ const deliveryPending = await deliverStoredResponseDeliveries(
900
+ db,
901
+ existing.id,
902
+ snapshot,
903
+ );
904
+ if (deliveryPending) {
905
+ setResponseStatus(event, 503);
906
+ return {
907
+ error: "Response delivery is still pending",
908
+ retryable: true,
909
+ id: existing.id,
910
+ };
911
+ }
912
+ } catch (error) {
913
+ console.warn("[forms] response delivery retry failed:", error);
914
+ setResponseStatus(event, 503);
915
+ return {
916
+ error: "Response delivery is still pending",
917
+ retryable: true,
918
+ id: existing.id,
919
+ };
920
+ }
921
+ return { success: true, id: existing.id };
922
+ }
923
+ }
924
+
84
925
  // guard:allow-unscoped — public submission endpoint intentionally accepts anonymous responses for published forms by id; it returns no owner data and rejects non-published forms.
85
926
  // Public submission endpoint: published forms are intentionally readable
86
927
  // without an authenticated viewer, but only by exact id and published status.
@@ -116,18 +957,95 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
116
957
  }
117
958
  }
118
959
 
119
- const body = await readBody(event).catch(() => null);
120
- if (!body || typeof body !== "object" || Array.isArray(body)) {
121
- setResponseStatus(event, 400);
122
- return { error: "Invalid submission payload" };
123
- }
124
-
125
- // Check overall payload size
126
- const bodyStr = JSON.stringify(body);
127
- if (Buffer.byteLength(bodyStr, "utf8") > MAX_PAYLOAD_BYTES) {
128
- setResponseStatus(event, 413);
129
- return { error: "Payload too large" };
130
- }
960
+ const finishResponse = async ({
961
+ responseId,
962
+ fields,
963
+ data,
964
+ submittedAt,
965
+ submitterEmail,
966
+ pageUrl,
967
+ clientSurface,
968
+ chatSessionIds,
969
+ activeRunId,
970
+ deliverySnapshot,
971
+ }: {
972
+ responseId: string;
973
+ fields: FormField[];
974
+ data: Record<string, unknown>;
975
+ submittedAt: string;
976
+ submitterEmail: string | null;
977
+ pageUrl: string | null;
978
+ clientSurface: string | null;
979
+ chatSessionIds: string[];
980
+ activeRunId: string | null;
981
+ deliverySnapshot: string | null | undefined;
982
+ }) => {
983
+ try {
984
+ const tracked = idempotencyKey !== null;
985
+ let parsedDeliverySnapshot = tracked
986
+ ? parseDeliverySnapshot(deliverySnapshot)
987
+ : null;
988
+ if (tracked && !parsedDeliverySnapshot) {
989
+ parsedDeliverySnapshot = buildDeliverySnapshot({
990
+ form,
991
+ settings,
992
+ fields,
993
+ data,
994
+ responseId,
995
+ submittedAt,
996
+ submitterEmail,
997
+ pageUrl,
998
+ clientSurface,
999
+ chatSessionIds,
1000
+ activeRunId,
1001
+ });
1002
+ await db
1003
+ .update(schema.responses)
1004
+ .set({ deliverySnapshot: JSON.stringify(parsedDeliverySnapshot) })
1005
+ .where(
1006
+ and(
1007
+ eq(schema.responses.id, responseId),
1008
+ isNull(schema.responses.deliverySnapshot),
1009
+ ),
1010
+ );
1011
+ }
1012
+ const deliveryPending = await deliverResponseSideEffects({
1013
+ db,
1014
+ form,
1015
+ settings,
1016
+ fields,
1017
+ data,
1018
+ responseId,
1019
+ submittedAt,
1020
+ submitterEmail,
1021
+ pageUrl,
1022
+ clientSurface,
1023
+ chatSessionIds,
1024
+ activeRunId,
1025
+ trackDelivery: tracked,
1026
+ deliverySnapshot: parsedDeliverySnapshot,
1027
+ });
1028
+ if (deliveryPending) {
1029
+ setResponseStatus(event, 503);
1030
+ return {
1031
+ error: "Response delivery is still pending",
1032
+ retryable: true,
1033
+ id: responseId,
1034
+ };
1035
+ }
1036
+ } catch (error) {
1037
+ console.warn("[forms] response delivery status update failed:", error);
1038
+ if (idempotencyKey) {
1039
+ setResponseStatus(event, 503);
1040
+ return {
1041
+ error: "Response delivery is still pending",
1042
+ retryable: true,
1043
+ id: responseId,
1044
+ };
1045
+ }
1046
+ }
1047
+ return { success: true, id: responseId };
1048
+ };
131
1049
 
132
1050
  // Honeypot: silently accept-and-drop if filled. Bots that fire-and-forget
133
1051
  // get a 200 and never know they were caught.
@@ -207,30 +1125,31 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
207
1125
  // who sent feedback in Slack). Never required. Prefer the Forms-host session
208
1126
  // when present; cross-app feedback submissions fall back to the client hint,
209
1127
  // which is useful context but not verified identity.
210
- const meta =
211
- !anonymous && typeof body._meta === "object" && body._meta !== null
212
- ? (body._meta as {
213
- submitterEmail?: unknown;
214
- chatSessionId?: unknown;
215
- chatSessionIds?: unknown;
216
- activeRunId?: unknown;
217
- pageUrl?: unknown;
218
- clientSurface?: unknown;
219
- })
220
- : null;
1128
+ const metadata = submissionMetadata(
1129
+ body as Record<string, unknown>,
1130
+ anonymous,
1131
+ );
221
1132
  const session = anonymous ? null : await getSession(event).catch(() => null);
222
1133
  const submitterEmail =
223
- cleanSubmitterEmail(session?.email) ??
224
- cleanSubmitterEmail(meta?.submitterEmail);
225
- const chatSessionIds = cleanChatSessionIds([
226
- meta?.chatSessionId,
227
- meta?.chatSessionIds,
228
- ]);
229
- const activeRunId = cleanMetaText(meta?.activeRunId);
230
- const pageUrl = scrubPageUrl(meta?.pageUrl);
231
- const clientSurface = cleanClientSurface(meta?.clientSurface);
232
-
233
- await db.insert(schema.responses).values({
1134
+ cleanSubmitterEmail(session?.email) ?? metadata.submitterEmail;
1135
+ const { chatSessionIds, activeRunId, pageUrl, clientSurface } = metadata;
1136
+ const deliverySnapshot = idempotencyKey
1137
+ ? buildDeliverySnapshot({
1138
+ form,
1139
+ settings,
1140
+ fields,
1141
+ data,
1142
+ responseId,
1143
+ submittedAt: now,
1144
+ submitterEmail,
1145
+ pageUrl,
1146
+ clientSurface,
1147
+ chatSessionIds,
1148
+ activeRunId,
1149
+ })
1150
+ : null;
1151
+
1152
+ const responseValues = {
234
1153
  id: responseId,
235
1154
  formId: id,
236
1155
  data: JSON.stringify(data),
@@ -239,69 +1158,78 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
239
1158
  submitterEmail,
240
1159
  pageUrl,
241
1160
  clientSurface,
242
- });
243
-
244
- // Write submission notification to application state (SQL-backed)
245
- try {
246
- const { appStatePut } =
247
- await import("@agent-native/core/application-state");
248
- await appStatePut(form.ownerEmail, "new-submission", {
249
- formId: id,
250
- responseId,
251
- timestamp: now,
252
- });
253
- } catch {
254
- // Non-critical — don't fail the submission
255
- }
1161
+ idempotencyKey,
1162
+ deliveryStatus: deliverySnapshot
1163
+ ? JSON.stringify(configuredDeliveryStatuses(deliverySnapshot))
1164
+ : null,
1165
+ deliverySnapshot: deliverySnapshot
1166
+ ? JSON.stringify(deliverySnapshot)
1167
+ : null,
1168
+ };
256
1169
 
257
- if (settings.emailOnNewResponses === true && form.ownerEmail) {
258
- try {
259
- await runWithRequestContext(
260
- { userEmail: form.ownerEmail, orgId: form.orgId ?? undefined },
261
- () =>
262
- sendNewResponseEmail({
263
- to: form.ownerEmail!,
264
- formTitle: form.title,
265
- fields,
266
- data,
267
- submittedAt: now,
268
- }),
1170
+ if (idempotencyKey) {
1171
+ const [inserted] = await db
1172
+ .insert(schema.responses)
1173
+ .values(responseValues)
1174
+ .onConflictDoNothing()
1175
+ .returning({ id: schema.responses.id });
1176
+ if (!inserted) {
1177
+ const [existing] = await db
1178
+ .select({
1179
+ id: schema.responses.id,
1180
+ data: schema.responses.data,
1181
+ submittedAt: schema.responses.submittedAt,
1182
+ submitterEmail: schema.responses.submitterEmail,
1183
+ pageUrl: schema.responses.pageUrl,
1184
+ clientSurface: schema.responses.clientSurface,
1185
+ deliveryStatus: schema.responses.deliveryStatus,
1186
+ deliverySnapshot: schema.responses.deliverySnapshot,
1187
+ })
1188
+ .from(schema.responses)
1189
+ .where(
1190
+ and(
1191
+ eq(schema.responses.formId, id),
1192
+ eq(schema.responses.idempotencyKey, idempotencyKey),
1193
+ ),
1194
+ );
1195
+ if (!existing) {
1196
+ throw new Error(
1197
+ "Submission idempotency conflict could not be resolved",
1198
+ );
1199
+ }
1200
+ const existingMetadata = submissionMetadata(
1201
+ body as Record<string, unknown>,
1202
+ anonymous,
269
1203
  );
270
- } catch (error) {
271
- // Email is best-effort — a provider outage must not reject a public
272
- // submission that was already persisted successfully.
273
- console.warn("[forms] new response email failed:", error);
274
- }
275
- }
276
-
277
- // Fire integrations best-effort and never fail the submission. Keep this
278
- // awaited: serverless hosts can freeze fire-and-forget work as soon as the
279
- // HTTP response returns, which silently drops Slack/webhook delivery.
280
- // pageUrl and clientSurface are persisted on the response (above) so owners
281
- // can see which screen and which app feedback came from; chat session ids and
282
- // run ids remain integration-only debug breadcrumbs we don't retain in SQL.
283
- try {
284
- const integrations: FormIntegration[] = settings.integrations ?? [];
285
- if (integrations.length > 0) {
286
- await fireIntegrations(integrations, {
287
- formId: id,
288
- formTitle: form.title,
289
- responseId,
1204
+ return finishResponse({
1205
+ responseId: existing.id,
290
1206
  fields,
291
- data,
292
- submittedAt: now,
293
- submitterEmail,
294
- chatSessionIds,
295
- activeRunId,
296
- pageUrl,
297
- clientSurface,
1207
+ data: JSON.parse(existing.data),
1208
+ submittedAt: existing.submittedAt,
1209
+ submitterEmail: existing.submitterEmail,
1210
+ pageUrl: existing.pageUrl,
1211
+ clientSurface: existing.clientSurface,
1212
+ chatSessionIds: existingMetadata.chatSessionIds,
1213
+ activeRunId: existingMetadata.activeRunId,
1214
+ deliverySnapshot: existing.deliverySnapshot,
298
1215
  });
299
1216
  }
300
- } catch {
301
- // Non-critical
1217
+ } else {
1218
+ await db.insert(schema.responses).values(responseValues);
302
1219
  }
303
1220
 
304
- return { success: true, id: responseId };
1221
+ return finishResponse({
1222
+ responseId,
1223
+ fields,
1224
+ data,
1225
+ submittedAt: now,
1226
+ submitterEmail,
1227
+ pageUrl,
1228
+ clientSurface,
1229
+ chatSessionIds,
1230
+ activeRunId,
1231
+ deliverySnapshot: responseValues.deliverySnapshot,
1232
+ });
305
1233
  });
306
1234
 
307
1235
  export const listResponses = defineEventHandler(async (event: H3Event) => {