| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | |
| 5 | const 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 | }); |
| 15 | const 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. */ |
| 29 | export const FunctionTriggerEnvelope = Schema.Union([ |
| 30 | ScheduleEnvelope, |
| 31 | BucketEnvelope, |
| 32 | ]); |
| 33 | export type FunctionTriggerEnvelope = typeof FunctionTriggerEnvelope.Type; |
| 34 | |
| 35 | export 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 | } |
| 41 | export 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 | } |
| 48 | export 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 | */ |
| 57 | export 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 | }); |