1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5const ScheduleEnvelope = Schema.Struct({
6 version: Schema.Literal(1),
7 invocation_id: Schema.String,
8 trigger: Schema.Struct({
9 type: Schema.Literal("schedule"),
10 id: Schema.String,
11 name: Schema.String,
12 }),
13 data: Schema.Struct({ scheduled_at: Schema.String }),
14});
15const BucketEnvelope = Schema.Struct({
16 version: Schema.Literal(1),
17 invocation_id: Schema.String,
18 trigger: Schema.Struct({
19 type: Schema.Literal("storage_object_created"),
20 id: Schema.String,
21 name: Schema.String,
22 }),
23 data: Schema.Struct({
24 bucket_name: Schema.String,
25 object_key: Schema.String,
26 }),
27});
28/** Validated wire envelope shared by native and Effect handlers. */
29export const FunctionTriggerEnvelope = Schema.Union([
30 ScheduleEnvelope,
31 BucketEnvelope,
32]);
33export type FunctionTriggerEnvelope = typeof FunctionTriggerEnvelope.Type;
34
35export interface CronEvent {
36 /** Stable occurrence ID for application idempotency. */ invocationId: string;
37 /** Project-wide trigger identifier. */ triggerId: string;
38 /** Trigger name. */ name: string;
39 /** UTC scheduled time. */ scheduledAt: string;
40}
41export interface BucketEvent {
42 /** Stable occurrence ID for application idempotency. */ invocationId: string;
43 /** Project-wide trigger identifier. */ triggerId: string;
44 /** Trigger name. */ name: string;
45 /** Exact bucket name. */ bucketName: string;
46 /** Exact uploaded key, without path normalization. */ objectKey: string;
47}
48export class FunctionTriggerEventError extends Data.TaggedError(
49 "FunctionTriggerEventError",
50)<{ status: 400 | 403 | 405; message: string }> {}
51
52/**
53 * Decode a Neon POST envelope and compare the edge-attested invocation header.
54 * Trust this header only behind Neon's edge, which strips client-supplied
55 * X-Neon-* headers. It is not authentication for an arbitrary local HTTP server.
56 */
57export const decodeFunctionTriggerEvent = Effect.fn(function* (
58 request: Request,
59) {
60 if (request.method !== "POST")
61 return yield* new FunctionTriggerEventError({
62 status: 405,
63 message: "Trigger delivery requires POST",
64 });
65 const invocation = request.headers
66 .get("x-neon-trigger-invocation-id")
67 ?.trim();
68 if (!invocation)
69 return yield* new FunctionTriggerEventError({
70 status: 403,
71 message: "Missing Neon trigger attestation",
72 });
73 const json = yield* Effect.tryPromise({
74 try: () => request.json(),
75 catch: () =>
76 new FunctionTriggerEventError({
77 status: 400,
78 message: "Invalid trigger JSON",
79 }),
80 });
81 const event = yield* Schema.decodeUnknownEffect(FunctionTriggerEnvelope)(
82 json,
83 ).pipe(
84 Effect.mapError(
85 () =>
86 new FunctionTriggerEventError({
87 status: 400,
88 message: "Invalid trigger discriminator or schema version",
89 }),
90 ),
91 );
92 if (
93 !event.trigger.id ||
94 !event.trigger.name ||
95 (event.trigger.type === "schedule" &&
96 "scheduled_at" in event.data &&
97 !event.data.scheduled_at) ||
98 ("bucket_name" in event.data &&
99 (!event.data.bucket_name || !event.data.object_key))
100 )
101 return yield* new FunctionTriggerEventError({
102 status: 400,
103 message: "Empty trigger identity or event data",
104 });
105 if (event.invocation_id !== invocation || event.invocation_id.length === 0)
106 return yield* new FunctionTriggerEventError({
107 status: 403,
108 message: "Trigger invocation ID mismatch",
109 });
110 return event;
111});