1import * as Neon from "alchemy/Neon";
2import * as SQL from "alchemy/SQL/Postgres";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import { makeAuthenticate } from "./authenticate.ts";
7import {
8 corsHeaders,
9 objectKey,
10 parseUpload,
11 serializeUploadRow,
12 UUID,
13 type UploadRecord,
14} from "./policy.ts";
15import { resources } from "./resources.ts";
16
17export default class Api extends Neon.Function<Api>()(
18 "Api",
19 Effect.gen(function* () {
20 const { branch, auth, appOrigin } = yield* resources;
21 return {
22 branch,
23 main: import.meta.url,
24 env: {
25 AUTH_URL: auth.baseUrl,
26 AUTH_JWKS_URL: auth.jwksUrl,
27 APP_ORIGIN: appOrigin,
28 },
29 };
30 }),
31 Effect.gen(function* () {
32 const { branch, uploads, auth, settings } = yield* resources;
33 const connection = yield* Neon.ConnectAuth(auth);
34 const defaults = yield* Neon.ReadObject(settings);
35 const db = yield* Neon.Connect(branch);
36 const sql = yield* SQL.Postgres({ url: db.connectionString });
37 const files = yield* Neon.ReadWriteBucket(uploads);
38 const env = yield* Neon.FunctionEnvironment;
39 // Configuration only: the remote key set performs I/O lazily during verification.
40 const authenticate = yield* Effect.cached(
41 Effect.gen(function* () {
42 const baseUrl = yield* connection.baseUrl;
43 const jwksUrl = yield* connection.jwksUrl;
44 return yield* Effect.sync(() => makeAuthenticate(baseUrl, jwksUrl));
45 }),
46 );
47
48 yield* Neon.BucketEventSource(
49 uploads,
50 {
51 name: "ProcessUploads",
52 prefix: "incoming/",
53 },
54 Effect.fn(function* (event) {
55 const object = yield* files.head(event.objectKey);
56 if (!object)
57 return yield* Effect.fail(
58 new Error("Uploaded object is not readable yet"),
59 );
60 const bytes = object.ContentLength ?? 0;
61 // Record delivery and transition the row atomically; duplicate invocations do no work.
62 yield* sql`
63 WITH delivery AS (
64 INSERT INTO upload_events (invocation_id, object_key)
65 VALUES (${event.invocationId}, ${event.objectKey})
66 ON CONFLICT DO NOTHING RETURNING invocation_id
67 )
68 UPDATE uploads SET actual_bytes = ${bytes}, processed_at = now(),
69 status = CASE WHEN expected_bytes = ${bytes} AND content_type = ${object.ContentType ?? ""}
70 THEN 'ready' ELSE 'rejected' END
71 WHERE object_key = ${event.objectKey} AND EXISTS (SELECT 1 FROM delivery)
72 `;
73 }),
74 );
75
76 return {
77 fetch: Effect.gen(function* () {
78 const request = yield* Neon.FunctionRequest;
79 const headers = corsHeaders(
80 request.headers.get("origin"),
81 env.APP_ORIGIN,
82 );
83 if (!headers)
84 return HttpServerResponse.text("Untrusted origin", { status: 403 });
85 const respond = (value: unknown, status = 200) =>
86 HttpServerResponse.json(value, { status, headers });
87 if (request.method === "OPTIONS")
88 return HttpServerResponse.empty({ status: 204, headers });
89 const path = yield* Effect.sync(() => new URL(request.url).pathname);
90 if (path === "/health")
91 return yield* respond({ ok: true, runtime: "effect" });
92 const verify = yield* authenticate;
93 const owner = yield* Effect.tryPromise(() =>
94 verify(request.headers.get("authorization")),
95 );
96 if (!owner)
97 return yield* respond(
98 {
99 error:
100 "Sign in again: your token is missing, invalid, or expired.",
101 },
102 401,
103 );
104
105 if (path === "/api/settings" && request.method === "GET")
106 return yield* respond(yield* defaults.get());
107 if (path === "/api/me" && request.method === "GET")
108 return yield* respond({ userId: owner });
109 if (path === "/api/uploads" && request.method === "GET") {
110 const rows = yield* sql<UploadRecord>`
111 SELECT id, filename, object_key, content_type, expected_bytes, actual_bytes, status, created_at
112 FROM uploads WHERE owner_id = ${owner} ORDER BY created_at DESC LIMIT 100
113 `;
114 return yield* respond(
115 yield* Effect.sync(() => rows.map(serializeUploadRow)),
116 );
117 }
118 if (path === "/api/uploads" && request.method === "POST") {
119 const json = yield* Effect.tryPromise(() => request.json()).pipe(
120 Effect.catch(() => Effect.succeed(undefined)),
121 );
122 const input = parseUpload(json);
123 if (!input)
124 return yield* respond(
125 {
126 error:
127 "Choose a nonempty file up to 10 MiB with a valid content type.",
128 },
129 400,
130 );
131 const id = yield* Effect.sync(() => crypto.randomUUID());
132 const key = objectKey(owner, id);
133 const url = yield* files.presignPut(key, {
134 contentType: input.contentType,
135 expiresIn: 120,
136 });
137 yield* sql`
138 INSERT INTO uploads (id, owner_id, object_key, filename, content_type, expected_bytes)
139 VALUES (${id}, ${owner}, ${key}, ${input.filename}, ${input.contentType}, ${input.size})
140 `;
141 return yield* respond(
142 { id, url, contentType: input.contentType },
143 201,
144 );
145 }
146 const match = /^\/api\/uploads\/([^/]+)\/download$/.exec(path);
147 if (match && request.method === "GET") {
148 if (!UUID.test(match[1]!))
149 return yield* respond({ error: "Not found" }, 404);
150 const [row] =
151 yield* sql<UploadRecord>`SELECT * FROM uploads WHERE id = ${match[1]!} AND owner_id = ${owner}`;
152 if (!row) return yield* respond({ error: "Not found" }, 404);
153 if (row.status !== "ready")
154 return yield* respond(
155 { error: "This upload is not ready to download." },
156 409,
157 );
158 return yield* respond({
159 url: yield* files.presignGet(row.object_key, { expiresIn: 60 }),
160 });
161 }
162 return yield* respond({ error: "Not found" }, 404);
163 }).pipe(
164 Effect.catchCause(() =>
165 Effect.gen(function* () {
166 yield* Effect.logError("Upload API request failed");
167 const request = yield* Neon.FunctionRequest;
168 const headers = corsHeaders(
169 request.headers.get("origin"),
170 env.APP_ORIGIN,
171 );
172 return HttpServerResponse.text(
173 "Upload service unavailable. Retry the request.",
174 { status: 500, headers },
175 );
176 }),
177 ),
178 ),
179 };
180 }).pipe(
181 Effect.provide(
182 Layer.mergeAll(
183 Neon.ConnectHttp,
184 Neon.ConnectAuthHttp,
185 Neon.ReadObjectHttp,
186 Neon.ReadWriteBucketHttp,
187 Neon.BucketEventSourceHttp,
188 ),
189 ),
190 ),
191) {}