Openstatus
www.openstatus.dev
1import { UnkeyCore } from "@unkey/api/core";
2import { keysVerifyKey } from "@unkey/api/funcs/keysVerifyKey";
3import type { Context, Next } from "hono";
4
5import { env } from "@/env";
6import { OpenStatusApiError } from "@/libs/errors";
7import type { Variables } from "@/types";
8import { getLogger } from "@logtape/logtape";
9import { db, eq } from "@openstatus/db";
10import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema";
11import { apiKey } from "@openstatus/db/src/schema/api-keys";
12import {
13 shouldUpdateLastUsed,
14 verifyApiKeyHash,
15} from "@openstatus/db/src/utils/api-key";
16
17const logger = getLogger("api-server");
18
19/**
20 * Looks up a workspace by ID and validates the data.
21 * Throws OpenStatusApiError if workspace is not found or invalid.
22 */
23export async function lookupWorkspace(workspaceId: number) {
24 const _workspace = await db
25 .select()
26 .from(workspace)
27 .where(eq(workspace.id, workspaceId))
28 .get();
29
30 if (!_workspace) {
31 throw new OpenStatusApiError({
32 code: "NOT_FOUND",
33 message: "Workspace not found, please contact support",
34 });
35 }
36
37 const validation = selectWorkspaceSchema.safeParse(_workspace);
38
39 if (!validation.success) {
40 throw new OpenStatusApiError({
41 code: "BAD_REQUEST",
42 message: "Workspace data is invalid",
43 });
44 }
45
46 return validation.data;
47}
48
49export async function authMiddleware(
50 c: Context<{ Variables: Variables }, "/*">,
51 next: Next,
52) {
53 const key = c.req.header("x-openstatus-key");
54 if (!key)
55 throw new OpenStatusApiError({
56 code: "UNAUTHORIZED",
57 message: "Missing 'x-openstatus-key' header",
58 });
59
60 const { error, result } = await validateKey(key);
61
62 if (error) {
63 throw new OpenStatusApiError({
64 code: "UNAUTHORIZED",
65 message: error.message,
66 });
67 }
68
69 if (!result.valid || !result.ownerId) {
70 throw new OpenStatusApiError({
71 code: "UNAUTHORIZED",
72 message: "Invalid API Key",
73 });
74 }
75
76 const ownerId = Number.parseInt(result.ownerId);
77
78 if (Number.isNaN(ownerId)) {
79 throw new OpenStatusApiError({
80 code: "UNAUTHORIZED",
81 message: "API Key is Not a Number",
82 });
83 }
84
85 const workspaceData = await lookupWorkspace(ownerId);
86
87 const event = c.get("event");
88 event.workspace = {
89 id: workspaceData.id,
90 name: workspaceData.name,
91 plan: workspaceData.plan,
92 stripe_id: workspaceData.stripeId,
93 };
94 event.auth_method = result.authMethod;
95 c.set("workspace", workspaceData);
96
97 await next();
98}
99
100export async function validateKey(key: string): Promise<{
101 result: { valid: boolean; ownerId?: string; authMethod?: string };
102 error?: { message: string };
103}> {
104 if (env.NODE_ENV === "production") {
105 /**
106 * Both custom and Unkey API keys use the `os_` prefix for seamless transition.
107 * Custom keys are checked first in the database, then falls back to Unkey.
108 */
109 if (key.startsWith("os_")) {
110 // 1. Try custom DB first
111 const prefix = key.slice(0, 11); // "os_" (3 chars) + 8 hex chars = 11 total
112 const customKey = await db
113 .select()
114 .from(apiKey)
115 .where(eq(apiKey.prefix, prefix))
116 .get();
117
118 if (customKey) {
119 // Verify hash using bcrypt-compatible verification
120 if (!(await verifyApiKeyHash(key, customKey.hashedToken))) {
121 return {
122 result: { valid: false },
123 error: { message: "Invalid API Key" },
124 };
125 }
126 // Check expiration
127 if (customKey.expiresAt && customKey.expiresAt < new Date()) {
128 return {
129 result: { valid: false },
130 error: { message: "API Key expired" },
131 };
132 }
133
134 // Update lastUsedAt (debounced)
135 if (shouldUpdateLastUsed(customKey.lastUsedAt)) {
136 await db
137 .update(apiKey)
138 .set({ lastUsedAt: new Date() })
139 .where(eq(apiKey.id, customKey.id));
140 }
141 return {
142 result: {
143 valid: true,
144 ownerId: String(customKey.workspaceId),
145 authMethod: "custom_key",
146 },
147 };
148 }
149
150 // 2. Fall back to Unkey (transition period)
151 const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN });
152 const res = await keysVerifyKey(unkey, { key });
153 if (!res.ok) {
154 logger.error("Unkey Error {*}", { ...res.error });
155 return {
156 result: { valid: false, ownerId: undefined },
157 error: { message: "Invalid API verification" },
158 };
159 }
160 return {
161 result: {
162 valid: res.value.data.valid,
163 ownerId: res.value.data.identity?.externalId,
164 authMethod: "unkey",
165 },
166 error: undefined,
167 };
168 }
169 // Special bypass for our workspace
170 if (key.startsWith("sa_") && key === env.SUPER_ADMIN_TOKEN) {
171 return {
172 result: { valid: true, ownerId: "1", authMethod: "super_admin" },
173 };
174 }
175 // In production, we only accept Unkey keys
176 throw new OpenStatusApiError({
177 code: "UNAUTHORIZED",
178 message: "Invalid API Key",
179 });
180 }
181
182 // In dev / test mode we can use the key as the ownerId
183 return { result: { valid: true, ownerId: key, authMethod: "dev" } };
184}