Skip to content
File

Blob: src/worker/api/inboxes.ts

typescript163 lines
1import type { Hono } from "hono";
2import {
3 CreateInboxRequest,
4 CreateInboxResponse,
5 ErrorResponse,
6 ExtendInboxRequest,
7 ExtendInboxResponse,
8 OkResponse,
9 WebSocketTicketResponse,
10} from "@/shared/contracts";
11import { createLogger, errorContext } from "@/worker/logger";
12import { getPublicErrorMessage } from "@/worker/security";
13import { requireInboxRouteAccess } from "@/worker/middleware/auth";
14import { toInboxInfo } from "@/worker/serializers/inbox";
15import {
16 createTemporaryInbox,
17 createWebSocketTicket,
18 deleteInbox,
19 extendTemporaryInbox,
20 normalizeDomainName,
21} from "@/worker/services/inbox";
22import { verifyTurnstileToken } from "@/worker/services/turnstile";
23import type { AppBindings } from "@/worker/types";
24 
25const logger = createLogger("inbox-api");
26 
27export function registerInboxRoutes(app: Hono<AppBindings>) {
28 app.post("/api/public/inboxes", async (c) => {
29 let body;
30 
31 try {
32 body = CreateInboxRequest.assertDecode(await c.req.json());
33 } catch {
34 return c.json(ErrorResponse.create({ error: "A domain is required" }), 400);
35 }
36 
37 const domain = normalizeDomainName(body.domain);
38 
39 if (!domain) {
40 return c.json(ErrorResponse.create({ error: "A domain is required" }), 400);
41 }
42 
43 const turnstileResult = await verifyTurnstileToken(c.env, {
44 token: body.turnstileToken,
45 expectedAction: "create_inbox",
46 remoteIp: c.req.header("cf-connecting-ip"),
47 requestUrl: c.req.url,
48 });
49 
50 if (!turnstileResult.ok) {
51 logger.warn("create_inbox_turnstile_failed", "Rejected inbox creation request", {
52 domain,
53 ttlHours: body.ttlHours,
54 reason: turnstileResult.reason,
55 errorCodes: turnstileResult.errorCodes,
56 });
57 return c.json(ErrorResponse.create({ error: turnstileResult.message }), turnstileResult.status);
58 }
59 
60 try {
61 const inbox = await createTemporaryInbox(c.env, domain, body.ttlHours, c.get("db"));
62 return c.json(
63 CreateInboxResponse.create({
64 address: inbox.address,
65 token: inbox.token,
66 ttlHours: inbox.ttlHours,
67 expiresAt: inbox.expiresAt.toISOString(),
68 }),
69 201,
70 );
71 } catch (error) {
72 logger.warn("create_inbox_failed", "Could not create inbox", {
73 domain,
74 ttlHours: body.ttlHours,
75 ...errorContext(error),
76 });
77 return c.json(
78 ErrorResponse.create({
79 error: getPublicErrorMessage(error, "Could not create inbox"),
80 }),
81 400,
82 );
83 }
84 });
85 
86 app.get("/api/protected/inboxes/:address", requireInboxRouteAccess, async (c) => {
87 const inbox = c.get("inbox");
88 return c.json(toInboxInfo(inbox));
89 });
90 
91 app.post("/api/protected/inboxes/:address/extend", requireInboxRouteAccess, async (c) => {
92 const inbox = c.get("inbox");
93 const session = c.get("session");
94 
95 if (session.type === "admin") {
96 return c.json(ErrorResponse.create({ error: "Admin inspection for temporary inboxes is read-only" }), 403);
97 }
98 
99 // requireInboxRouteAccess routes user-class requests through
100 // requireInboxAccess, which sets c.var.token to the bearer string.
101 // The admin guard above narrows session.type to "user", so token is
102 // always present here.
103 const token = c.get("token");
104 if (!token) {
105 return c.json(ErrorResponse.create({ error: "Unauthorized" }), 401);
106 }
107 
108 let body;
109 
110 try {
111 body = ExtendInboxRequest.assertDecode(await c.req.json());
112 } catch {
113 return c.json(ErrorResponse.create({ error: "A valid mailbox duration is required" }), 400);
114 }
115 
116 try {
117 const result = await extendTemporaryInbox(c.env, inbox, token, session, body.ttlHours, c.get("db"));
118 return c.json(
119 ExtendInboxResponse.create({
120 address: inbox.fullAddress,
121 ttlHours: result.ttlHours,
122 expiresAt: result.expiresAt.toISOString(),
123 }),
124 );
125 } catch (error) {
126 logger.warn("extend_inbox_failed", "Could not extend inbox", {
127 address: inbox.fullAddress,
128 requestedTtlHours: body.ttlHours,
129 ...errorContext(error),
130 });
131 return c.json(ErrorResponse.create({ error: getPublicErrorMessage(error, "Could not extend inbox") }), 400);
132 }
133 });
134 
135 app.post("/api/protected/inboxes/:address/ws-ticket", requireInboxRouteAccess, async (c) => {
136 const inbox = c.get("inbox");
137 const session = c.get("session");
138 const ticket = await createWebSocketTicket(c.env, inbox.fullAddress, session);
139 
140 return c.json(WebSocketTicketResponse.create({ ticket }));
141 });
142 
143 app.delete("/api/protected/inboxes/:address", requireInboxRouteAccess, async (c) => {
144 const inbox = c.get("inbox");
145 
146 try {
147 await deleteInbox(c.env, inbox, c.get("db"));
148 return c.json(OkResponse.create({ ok: true }));
149 } catch (error) {
150 logger.warn("delete_inbox_failed", "Could not delete inbox", {
151 address: inbox.fullAddress,
152 ...errorContext(error),
153 });
154 return c.json(
155 ErrorResponse.create({
156 error: getPublicErrorMessage(error, "Could not delete inbox"),
157 }),
158 403,
159 );
160 }
161 });
162}