@rebasepro/server-core 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/graphql/graphql-schema-generator.d.ts +9 -0
- package/dist/api/rest/api-generator.d.ts +2 -14
- package/dist/api/rest/query-parser.d.ts +2 -5
- package/dist/api/types.d.ts +8 -2
- package/dist/auth/adapter-middleware.d.ts +7 -1
- package/dist/auth/admin-roles-route.d.ts +18 -0
- package/dist/auth/admin-users-route.d.ts +28 -0
- package/dist/auth/api-keys/api-key-types.d.ts +8 -0
- package/dist/auth/auth-hooks.d.ts +17 -0
- package/dist/auth/builtin-auth-adapter.d.ts +3 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/interfaces.d.ts +32 -2
- package/dist/auth/magic-link-routes.d.ts +30 -0
- package/dist/auth/mfa-crypto.d.ts +23 -0
- package/dist/auth/mfa-routes.d.ts +2 -1
- package/dist/auth/middleware.d.ts +9 -2
- package/dist/auth/routes.d.ts +3 -1
- package/dist/auth/session-routes.d.ts +2 -0
- package/dist/backend-CIxN4FVm.js.map +1 -1
- package/dist/base64-js-C_frYBkI.js +1687 -0
- package/dist/base64-js-C_frYBkI.js.map +1 -0
- package/dist/{dist-CZKP-Xz4.js → dist-DMO-zF6D.js} +2 -2
- package/dist/{dist-CZKP-Xz4.js.map → dist-DMO-zF6D.js.map} +1 -1
- package/dist/email/index.d.ts +2 -2
- package/dist/email/templates.d.ts +8 -0
- package/dist/email/types.d.ts +18 -0
- package/dist/functions/define-function.d.ts +50 -0
- package/dist/functions/index.d.ts +2 -0
- package/dist/index.d.ts +16 -18
- package/dist/index.es.js +1947 -2356
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +40615 -10203
- package/dist/index.umd.js.map +1 -1
- package/dist/init.d.ts +68 -12
- package/dist/jws-BqRRaK11.js +616 -0
- package/dist/jws-BqRRaK11.js.map +1 -0
- package/dist/{jwt-DHcQRGC3.js → jwt-BETC8a1J.js} +3 -611
- package/dist/jwt-BETC8a1J.js.map +1 -0
- package/dist/services/routed-realtime-service.d.ts +43 -0
- package/dist/{src-COaj0G3P.js → src-CB2PIpBe.js} +2 -2
- package/dist/{src-COaj0G3P.js.map → src-CB2PIpBe.js.map} +1 -1
- package/dist/src-DjzOT1kG.js +29746 -0
- package/dist/src-DjzOT1kG.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +43 -0
- package/dist/storage/index.d.ts +5 -2
- package/dist/storage/routes.d.ts +33 -1
- package/dist/storage/tus-handler.d.ts +3 -1
- package/dist/storage/types.d.ts +28 -3
- package/dist/utils/dev-port.d.ts +2 -0
- package/package.json +13 -5
- package/src/api/errors.ts +16 -2
- package/src/api/graphql/graphql-schema-generator.ts +50 -15
- package/src/api/openapi-generator.ts +2 -2
- package/src/api/rest/api-generator.ts +44 -123
- package/src/api/rest/query-parser.ts +6 -23
- package/src/api/server.ts +10 -2
- package/src/api/types.ts +8 -2
- package/src/auth/adapter-middleware.ts +10 -2
- package/src/auth/admin-roles-route.ts +36 -0
- package/src/auth/admin-users-route.ts +302 -0
- package/src/auth/api-keys/api-key-middleware.ts +4 -3
- package/src/auth/api-keys/api-key-routes.ts +12 -2
- package/src/auth/api-keys/api-key-store.ts +83 -66
- package/src/auth/api-keys/api-key-types.ts +8 -0
- package/src/auth/apple-oauth.ts +2 -1
- package/src/auth/auth-hooks.ts +21 -0
- package/src/auth/bitbucket-oauth.ts +2 -1
- package/src/auth/builtin-auth-adapter.ts +29 -6
- package/src/auth/custom-auth-adapter.ts +2 -0
- package/src/auth/discord-oauth.ts +2 -1
- package/src/auth/facebook-oauth.ts +2 -1
- package/src/auth/github-oauth.ts +2 -1
- package/src/auth/gitlab-oauth.ts +2 -1
- package/src/auth/google-oauth.ts +8 -4
- package/src/auth/index.ts +2 -0
- package/src/auth/interfaces.ts +38 -2
- package/src/auth/linkedin-oauth.ts +2 -1
- package/src/auth/magic-link-routes.ts +167 -0
- package/src/auth/mfa-crypto.ts +91 -0
- package/src/auth/mfa-routes.ts +34 -10
- package/src/auth/microsoft-oauth.ts +2 -1
- package/src/auth/middleware.ts +14 -5
- package/src/auth/reset-password-admin.ts +17 -1
- package/src/auth/routes.ts +78 -10
- package/src/auth/session-routes.ts +15 -3
- package/src/auth/slack-oauth.ts +2 -1
- package/src/auth/spotify-oauth.ts +2 -1
- package/src/auth/twitter-oauth.ts +8 -1
- package/src/cron/cron-store.ts +25 -23
- package/src/email/index.ts +3 -2
- package/src/email/templates.ts +82 -0
- package/src/email/types.ts +16 -0
- package/src/functions/define-function.ts +59 -0
- package/src/functions/function-loader.ts +16 -0
- package/src/functions/index.ts +2 -0
- package/src/index.ts +70 -37
- package/src/init.ts +214 -25
- package/src/services/routed-realtime-service.ts +113 -0
- package/src/storage/GCSStorageController.ts +334 -0
- package/src/storage/index.ts +9 -3
- package/src/storage/routes.ts +191 -23
- package/src/storage/tus-handler.ts +16 -4
- package/src/storage/types.ts +25 -3
- package/src/utils/dev-port.ts +13 -7
- package/test/api-generator.test.ts +1 -1
- package/test/auth-config-types.test.ts +40 -0
- package/test/auth-routes.test.ts +54 -4
- package/test/custom-auth-adapter.test.ts +20 -1
- package/test/define-function.test.ts +45 -0
- package/test/env.test.ts +9 -19
- package/test/multi-datasource-routing.test.ts +113 -0
- package/test/routed-realtime-service.test.ts +86 -0
- package/test/storage-routes.test.ts +160 -0
- package/test/transform-auth-response.test.ts +305 -0
- package/dist/jwt-DHcQRGC3.js.map +0 -1
- package/test/backend-hooks-data.test.ts +0 -477
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone admin endpoint for user management.
|
|
3
|
+
*
|
|
4
|
+
* Mounts:
|
|
5
|
+
* GET /users
|
|
6
|
+
* GET /users/:userId
|
|
7
|
+
* POST /users
|
|
8
|
+
* PUT /users/:userId
|
|
9
|
+
* DELETE /users/:userId
|
|
10
|
+
* POST /bootstrap
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Hono } from "hono";
|
|
14
|
+
import { ApiError, errorHandler } from "../api/errors";
|
|
15
|
+
import type { AuthRepository } from "./interfaces";
|
|
16
|
+
import { createRequireAuth, requireAdmin } from "./middleware";
|
|
17
|
+
import type { AuthHooks } from "./auth-hooks";
|
|
18
|
+
import { resolveAuthHooks } from "./auth-hooks";
|
|
19
|
+
import { prepareAdminUserValues, finalizeAdminUserCreation } from "./admin-user-ops";
|
|
20
|
+
import type { EmailService, EmailConfig } from "../email";
|
|
21
|
+
import type { HonoEnv } from "../api/types";
|
|
22
|
+
import type { AdminUser, AuthCollectionConfig } from "@rebasepro/types";
|
|
23
|
+
import { logger } from "../utils/logger";
|
|
24
|
+
|
|
25
|
+
export interface AdminUsersRouteConfig {
|
|
26
|
+
authRepo: AuthRepository;
|
|
27
|
+
emailService?: EmailService;
|
|
28
|
+
emailConfig?: EmailConfig;
|
|
29
|
+
serviceKey?: string;
|
|
30
|
+
authHooks?: AuthHooks;
|
|
31
|
+
collectionAuthConfig?: AuthCollectionConfig;
|
|
32
|
+
isBootstrapCompleted?: () => Promise<boolean>;
|
|
33
|
+
setBootstrapCompleted?: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createAdminUsersRoute(config: AdminUsersRouteConfig): Hono<HonoEnv> {
|
|
37
|
+
const router = new Hono<HonoEnv>();
|
|
38
|
+
const authRepo = config.authRepo;
|
|
39
|
+
const { emailService, emailConfig, collectionAuthConfig } = config;
|
|
40
|
+
const ops = resolveAuthHooks(config.authHooks);
|
|
41
|
+
|
|
42
|
+
function toAdminUser(
|
|
43
|
+
u: {
|
|
44
|
+
id: string;
|
|
45
|
+
email: string;
|
|
46
|
+
displayName?: string | null;
|
|
47
|
+
photoUrl?: string | null;
|
|
48
|
+
createdAt?: Date | string;
|
|
49
|
+
updatedAt?: Date | string;
|
|
50
|
+
},
|
|
51
|
+
roles: string[]
|
|
52
|
+
): AdminUser {
|
|
53
|
+
return {
|
|
54
|
+
uid: u.id,
|
|
55
|
+
email: u.email,
|
|
56
|
+
displayName: u.displayName ?? null,
|
|
57
|
+
photoURL: u.photoUrl ?? null,
|
|
58
|
+
provider: "custom",
|
|
59
|
+
roles,
|
|
60
|
+
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : (u.createdAt ?? new Date().toISOString()),
|
|
61
|
+
updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : (u.updatedAt ?? new Date().toISOString())
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
router.onError(errorHandler);
|
|
66
|
+
router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
|
|
67
|
+
|
|
68
|
+
router.post("/bootstrap", async (c) => {
|
|
69
|
+
const user = c.get("user");
|
|
70
|
+
if (!user || typeof user !== "object") {
|
|
71
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (config.isBootstrapCompleted) {
|
|
75
|
+
const alreadyDone = await config.isBootstrapCompleted();
|
|
76
|
+
if (alreadyDone) {
|
|
77
|
+
throw ApiError.forbidden("Bootstrap has already been completed.", "BOOTSTRAP_COMPLETED");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const users = await authRepo.listUsers();
|
|
82
|
+
let hasAdmin = false;
|
|
83
|
+
|
|
84
|
+
for (const u of users) {
|
|
85
|
+
const roles = await authRepo.getUserRoleIds(u.id);
|
|
86
|
+
if (roles.includes("admin")) {
|
|
87
|
+
hasAdmin = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (hasAdmin) {
|
|
93
|
+
throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const userId = "userId" in user ? (user as { userId: string }).userId : ("uid" in user ? (user as { uid: string }).uid : undefined);
|
|
97
|
+
if (!userId) {
|
|
98
|
+
throw ApiError.unauthorized("User ID not found in auth context");
|
|
99
|
+
}
|
|
100
|
+
const caller = await authRepo.getUserById(userId);
|
|
101
|
+
if (!caller) {
|
|
102
|
+
throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
|
|
103
|
+
}
|
|
104
|
+
await authRepo.setUserRoles(userId, ["admin"]);
|
|
105
|
+
|
|
106
|
+
if (config.setBootstrapCompleted) {
|
|
107
|
+
await config.setBootstrapCompleted();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return c.json({
|
|
111
|
+
success: true,
|
|
112
|
+
message: "You are now an admin",
|
|
113
|
+
user: {
|
|
114
|
+
uid: userId,
|
|
115
|
+
roles: ["admin"]
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
router.get("/users", requireAdmin, async (c) => {
|
|
121
|
+
const limitParam = c.req.query("limit");
|
|
122
|
+
const offsetParam = c.req.query("offset");
|
|
123
|
+
const search = c.req.query("search");
|
|
124
|
+
const orderBy = c.req.query("orderBy");
|
|
125
|
+
const orderDir = c.req.query("orderDir") as "asc" | "desc" | undefined;
|
|
126
|
+
|
|
127
|
+
const limit = limitParam ? parseInt(limitParam, 10) : 25;
|
|
128
|
+
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
129
|
+
|
|
130
|
+
const result = await authRepo.listUsersPaginated({
|
|
131
|
+
limit,
|
|
132
|
+
offset,
|
|
133
|
+
search: search || undefined,
|
|
134
|
+
orderBy: orderBy || undefined,
|
|
135
|
+
orderDir: orderDir || undefined,
|
|
136
|
+
roleId: c.req.query("role") || undefined
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const usersWithRoles = await Promise.all(
|
|
140
|
+
result.users.map(async (u) => {
|
|
141
|
+
const roles = await authRepo.getUserRoleIds(u.id);
|
|
142
|
+
return toAdminUser(u, roles);
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return c.json({
|
|
147
|
+
users: usersWithRoles,
|
|
148
|
+
total: result.total,
|
|
149
|
+
limit: result.limit,
|
|
150
|
+
offset: result.offset
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
router.get("/users/:userId", requireAdmin, async (c) => {
|
|
155
|
+
const userId = c.req.param("userId");
|
|
156
|
+
const result = await authRepo.getUserWithRoles(userId);
|
|
157
|
+
|
|
158
|
+
if (!result) {
|
|
159
|
+
throw ApiError.notFound("User not found");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
|
|
163
|
+
return c.json({ user: adminUser });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
router.post("/users", requireAdmin, async (c) => {
|
|
167
|
+
const body = await c.req.json();
|
|
168
|
+
const { email, roles } = body;
|
|
169
|
+
if (!email) {
|
|
170
|
+
throw ApiError.badRequest("Email is required");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const existing = await authRepo.getUserByEmail(email.toLowerCase());
|
|
174
|
+
if (existing) {
|
|
175
|
+
throw ApiError.conflict("A user with this email already exists");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const prepResult = await prepareAdminUserValues(body, {
|
|
179
|
+
authRepo,
|
|
180
|
+
emailService,
|
|
181
|
+
emailConfig,
|
|
182
|
+
resolvedHooks: ops,
|
|
183
|
+
collectionAuthConfig
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const user = await authRepo.createUser({
|
|
187
|
+
email: prepResult.values.email as string,
|
|
188
|
+
passwordHash: prepResult.values.passwordHash as string | undefined,
|
|
189
|
+
displayName: prepResult.values.displayName as string | undefined,
|
|
190
|
+
photoUrl: prepResult.values.photoUrl as string | undefined,
|
|
191
|
+
metadata: prepResult.values.metadata as Record<string, unknown> | undefined
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (roles && Array.isArray(roles)) {
|
|
195
|
+
await authRepo.setUserRoles(user.id, roles);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const finalizeResult = await finalizeAdminUserCreation(
|
|
199
|
+
{ id: user.id, values: prepResult.values },
|
|
200
|
+
prepResult.clearPassword,
|
|
201
|
+
{
|
|
202
|
+
authRepo,
|
|
203
|
+
emailService,
|
|
204
|
+
emailConfig,
|
|
205
|
+
resolvedHooks: ops,
|
|
206
|
+
collectionAuthConfig
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const userRoles = await authRepo.getUserRoleIds(user.id);
|
|
211
|
+
const adminUser = toAdminUser(user, userRoles);
|
|
212
|
+
|
|
213
|
+
return c.json(
|
|
214
|
+
{
|
|
215
|
+
user: adminUser,
|
|
216
|
+
invitationSent: finalizeResult.invitationSent,
|
|
217
|
+
...(finalizeResult.temporaryPassword ? { temporaryPassword: finalizeResult.temporaryPassword } : {})
|
|
218
|
+
},
|
|
219
|
+
201
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
router.put("/users/:userId", requireAdmin, async (c) => {
|
|
224
|
+
const userId = c.req.param("userId");
|
|
225
|
+
const body = await c.req.json();
|
|
226
|
+
const { password, email, displayName, roles } = body;
|
|
227
|
+
|
|
228
|
+
const existing = await authRepo.getUserById(userId);
|
|
229
|
+
if (!existing) {
|
|
230
|
+
throw ApiError.notFound("User not found");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const updates: Record<string, unknown> = {};
|
|
234
|
+
if (email !== undefined) updates.email = email.toLowerCase();
|
|
235
|
+
if (displayName !== undefined) updates.displayName = displayName;
|
|
236
|
+
|
|
237
|
+
if (password) {
|
|
238
|
+
const validation = ops.validatePasswordStrength(password);
|
|
239
|
+
if (!validation.valid) {
|
|
240
|
+
throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
|
|
241
|
+
}
|
|
242
|
+
updates.passwordHash = await ops.hashPassword(password);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (Object.keys(updates).length > 0) {
|
|
246
|
+
await authRepo.updateUser(userId, updates);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (roles !== undefined && Array.isArray(roles)) {
|
|
250
|
+
const currentRoles = await authRepo.getUserRoleIds(userId);
|
|
251
|
+
const wasAdmin = currentRoles.includes("admin");
|
|
252
|
+
const willBeAdmin = roles.includes("admin");
|
|
253
|
+
|
|
254
|
+
if (wasAdmin && !willBeAdmin) {
|
|
255
|
+
const adminUsers = await authRepo.listUsersPaginated({
|
|
256
|
+
roleId: "admin",
|
|
257
|
+
limit: 1
|
|
258
|
+
});
|
|
259
|
+
if (adminUsers.total <= 1) {
|
|
260
|
+
throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
await authRepo.setUserRoles(userId, roles);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const result = await authRepo.getUserWithRoles(userId);
|
|
267
|
+
const adminUser = toAdminUser(result!.user, result!.roles.map((r) => r.id));
|
|
268
|
+
|
|
269
|
+
return c.json({ user: adminUser });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
router.delete("/users/:userId", requireAdmin, async (c) => {
|
|
273
|
+
const userId = c.req.param("userId");
|
|
274
|
+
const authUser = c.get("user") as { uid?: string; userId?: string } | undefined;
|
|
275
|
+
const currentUserId = authUser?.uid || authUser?.userId;
|
|
276
|
+
|
|
277
|
+
if (currentUserId === userId) {
|
|
278
|
+
throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const existing = await authRepo.getUserById(userId);
|
|
282
|
+
if (!existing) {
|
|
283
|
+
throw ApiError.notFound("User not found");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const roles = await authRepo.getUserRoleIds(userId);
|
|
287
|
+
if (roles.includes("admin")) {
|
|
288
|
+
const adminUsers = await authRepo.listUsersPaginated({
|
|
289
|
+
roleId: "admin",
|
|
290
|
+
limit: 1
|
|
291
|
+
});
|
|
292
|
+
if (adminUsers.total <= 1) {
|
|
293
|
+
throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await authRepo.deleteUser(userId);
|
|
298
|
+
return c.json({ success: true });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return router;
|
|
302
|
+
}
|
|
@@ -88,8 +88,8 @@ code: "UNAUTHORIZED" }
|
|
|
88
88
|
|
|
89
89
|
// Set user identity — API keys represent service accounts
|
|
90
90
|
const userId = `api-key:${apiKey.id}`;
|
|
91
|
-
|
|
92
|
-
roles
|
|
91
|
+
const roles: string[] = apiKey.admin ? ["admin", "service"] : ["service"];
|
|
92
|
+
c.set("user", { userId, roles });
|
|
93
93
|
|
|
94
94
|
// Expose masked key metadata for downstream permission checks
|
|
95
95
|
const masked: ApiKeyMasked = {
|
|
@@ -97,6 +97,7 @@ roles: [] });
|
|
|
97
97
|
name: apiKey.name,
|
|
98
98
|
key_prefix: apiKey.key_prefix,
|
|
99
99
|
permissions: apiKey.permissions,
|
|
100
|
+
admin: apiKey.admin,
|
|
100
101
|
rate_limit: apiKey.rate_limit,
|
|
101
102
|
created_by: apiKey.created_by,
|
|
102
103
|
created_at: apiKey.created_at,
|
|
@@ -112,7 +113,7 @@ roles: [] });
|
|
|
112
113
|
try {
|
|
113
114
|
const scopedDriver = await scopeDataDriver(driver, {
|
|
114
115
|
uid: userId,
|
|
115
|
-
roles
|
|
116
|
+
roles
|
|
116
117
|
});
|
|
117
118
|
c.set("driver", scopedDriver);
|
|
118
119
|
} catch (error) {
|
|
@@ -59,7 +59,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
|
|
|
59
59
|
// POST / — Create a new API key
|
|
60
60
|
router.post("/", async (c) => {
|
|
61
61
|
const body = await c.req.json() as Record<string, unknown>;
|
|
62
|
-
const { name, permissions, rate_limit, expires_at } = body;
|
|
62
|
+
const { name, permissions, admin, rate_limit, expires_at } = body;
|
|
63
63
|
|
|
64
64
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
|
65
65
|
throw ApiError.badRequest("Name is required", "INVALID_INPUT");
|
|
@@ -72,6 +72,10 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
|
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
if (admin !== undefined && typeof admin !== "boolean") {
|
|
76
|
+
throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
|
|
77
|
+
}
|
|
78
|
+
|
|
75
79
|
if (rate_limit !== undefined && rate_limit !== null) {
|
|
76
80
|
if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) {
|
|
77
81
|
throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
|
|
@@ -96,6 +100,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
|
|
|
96
100
|
const request: CreateApiKeyRequest = {
|
|
97
101
|
name: name.trim(),
|
|
98
102
|
permissions: permissions as ApiKeyPermission[],
|
|
103
|
+
admin: (admin as boolean) ?? false,
|
|
99
104
|
rate_limit: (rate_limit as number | null) ?? null,
|
|
100
105
|
expires_at: (expires_at as string | null) ?? null
|
|
101
106
|
};
|
|
@@ -121,7 +126,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
|
|
|
121
126
|
router.put("/:id", async (c) => {
|
|
122
127
|
const id = c.req.param("id");
|
|
123
128
|
const body = await c.req.json() as Record<string, unknown>;
|
|
124
|
-
const { name, permissions, rate_limit, expires_at } = body;
|
|
129
|
+
const { name, permissions, admin, rate_limit, expires_at } = body;
|
|
125
130
|
|
|
126
131
|
// Validate fields if provided
|
|
127
132
|
if (name !== undefined) {
|
|
@@ -139,6 +144,10 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
|
|
|
139
144
|
}
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
if (admin !== undefined && typeof admin !== "boolean") {
|
|
148
|
+
throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
if (rate_limit !== undefined && rate_limit !== null) {
|
|
143
152
|
if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) {
|
|
144
153
|
throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
|
|
@@ -155,6 +164,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
|
|
|
155
164
|
const updates: UpdateApiKeyRequest = {};
|
|
156
165
|
if (name !== undefined) updates.name = (name as string).trim();
|
|
157
166
|
if (permissions !== undefined) updates.permissions = permissions as ApiKeyPermission[];
|
|
167
|
+
if (admin !== undefined) updates.admin = admin as boolean;
|
|
158
168
|
if (rate_limit !== undefined) updates.rate_limit = rate_limit as number | null;
|
|
159
169
|
if (expires_at !== undefined) updates.expires_at = expires_at as string | null;
|
|
160
170
|
|
|
@@ -59,6 +59,7 @@ function toMasked(row: ApiKey): ApiKeyMasked {
|
|
|
59
59
|
name: row.name,
|
|
60
60
|
key_prefix: row.key_prefix,
|
|
61
61
|
permissions: row.permissions,
|
|
62
|
+
admin: row.admin,
|
|
62
63
|
rate_limit: row.rate_limit,
|
|
63
64
|
created_by: row.created_by,
|
|
64
65
|
created_at: row.created_at,
|
|
@@ -88,6 +89,7 @@ function rowToApiKey(row: Record<string, unknown>): ApiKey {
|
|
|
88
89
|
key_prefix: row.key_prefix as string,
|
|
89
90
|
key_hash: row.key_hash as string,
|
|
90
91
|
permissions,
|
|
92
|
+
admin: Boolean(row.admin),
|
|
91
93
|
rate_limit: row.rate_limit !== null && row.rate_limit !== undefined
|
|
92
94
|
? Number(row.rate_limit)
|
|
93
95
|
: null,
|
|
@@ -100,13 +102,6 @@ function rowToApiKey(row: Record<string, unknown>): ApiKey {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
/**
|
|
104
|
-
* Escape a string for safe inline SQL usage.
|
|
105
|
-
*/
|
|
106
|
-
function esc(value: string): string {
|
|
107
|
-
return value.replace(/'/g, "''");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
105
|
// ─── Public API ──────────────────────────────────────────────────────
|
|
111
106
|
|
|
112
107
|
export interface ApiKeyStore {
|
|
@@ -147,7 +142,8 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
147
142
|
return undefined;
|
|
148
143
|
}
|
|
149
144
|
|
|
150
|
-
const exec =
|
|
145
|
+
const exec = (sqlText: string, options?: { params?: unknown[] }) =>
|
|
146
|
+
admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);
|
|
151
147
|
|
|
152
148
|
return {
|
|
153
149
|
// ── Schema bootstrap ────────────────────────────────────────
|
|
@@ -161,6 +157,7 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
161
157
|
key_prefix TEXT NOT NULL,
|
|
162
158
|
key_hash TEXT NOT NULL UNIQUE,
|
|
163
159
|
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
160
|
+
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
164
161
|
rate_limit INTEGER,
|
|
165
162
|
created_by TEXT NOT NULL,
|
|
166
163
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
@@ -181,6 +178,12 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
181
178
|
ON ${TABLE}(key_prefix)
|
|
182
179
|
`);
|
|
183
180
|
|
|
181
|
+
// Migration: add admin column to existing tables
|
|
182
|
+
await exec(`
|
|
183
|
+
ALTER TABLE ${TABLE}
|
|
184
|
+
ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
|
|
185
|
+
`);
|
|
186
|
+
|
|
184
187
|
logger.info("✅ API keys table ready");
|
|
185
188
|
} catch (err) {
|
|
186
189
|
logger.error("❌ Failed to create API keys table", { error: err });
|
|
@@ -194,26 +197,22 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
194
197
|
const hash = hashKey(plaintext);
|
|
195
198
|
const prefix = keyPrefix(plaintext);
|
|
196
199
|
const permissionsJson = JSON.stringify(request.permissions);
|
|
197
|
-
const rateLimitSql = request.rate_limit !== null && request.rate_limit !== undefined
|
|
198
|
-
? String(request.rate_limit)
|
|
199
|
-
: "NULL";
|
|
200
|
-
const expiresAtSql = request.expires_at
|
|
201
|
-
? `'${esc(request.expires_at)}'`
|
|
202
|
-
: "NULL";
|
|
203
200
|
|
|
204
|
-
const rows = await exec(
|
|
205
|
-
INSERT INTO ${TABLE} (name, key_prefix, key_hash, permissions, rate_limit, created_by, expires_at)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
201
|
+
const rows = await exec(
|
|
202
|
+
`INSERT INTO ${TABLE} (name, key_prefix, key_hash, permissions, admin, rate_limit, created_by, expires_at)
|
|
203
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
|
|
204
|
+
RETURNING *`,
|
|
205
|
+
{ params: [
|
|
206
|
+
request.name,
|
|
207
|
+
prefix,
|
|
208
|
+
hash,
|
|
209
|
+
permissionsJson,
|
|
210
|
+
request.admin ?? false,
|
|
211
|
+
request.rate_limit ?? null,
|
|
212
|
+
createdBy,
|
|
213
|
+
request.expires_at ?? null
|
|
214
|
+
]}
|
|
215
|
+
);
|
|
217
216
|
|
|
218
217
|
const apiKey = rowToApiKey(rows[0]);
|
|
219
218
|
return {
|
|
@@ -224,11 +223,12 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
224
223
|
|
|
225
224
|
// ── Lookup by hash ──────────────────────────────────────────
|
|
226
225
|
async findByKeyHash(hash: string): Promise<ApiKey | null> {
|
|
227
|
-
const rows = await exec(
|
|
228
|
-
SELECT * FROM ${TABLE}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
const rows = await exec(
|
|
227
|
+
`SELECT * FROM ${TABLE}
|
|
228
|
+
WHERE key_hash = $1
|
|
229
|
+
LIMIT 1`,
|
|
230
|
+
{ params: [hash] }
|
|
231
|
+
);
|
|
232
232
|
if (rows.length === 0) return null;
|
|
233
233
|
return rowToApiKey(rows[0]);
|
|
234
234
|
},
|
|
@@ -244,11 +244,12 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
244
244
|
|
|
245
245
|
// ── Get by ID (masked) ──────────────────────────────────────
|
|
246
246
|
async getApiKeyById(id: string): Promise<ApiKeyMasked | null> {
|
|
247
|
-
const rows = await exec(
|
|
248
|
-
SELECT * FROM ${TABLE}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
247
|
+
const rows = await exec(
|
|
248
|
+
`SELECT * FROM ${TABLE}
|
|
249
|
+
WHERE id = $1
|
|
250
|
+
LIMIT 1`,
|
|
251
|
+
{ params: [id] }
|
|
252
|
+
);
|
|
252
253
|
if (rows.length === 0) return null;
|
|
253
254
|
return toMasked(rowToApiKey(rows[0]));
|
|
254
255
|
},
|
|
@@ -256,26 +257,36 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
256
257
|
// ── Update ──────────────────────────────────────────────────
|
|
257
258
|
async updateApiKey(id: string, updates: UpdateApiKeyRequest): Promise<ApiKeyMasked | null> {
|
|
258
259
|
const setClauses: string[] = [];
|
|
260
|
+
const params: unknown[] = [];
|
|
261
|
+
let paramIdx = 1;
|
|
259
262
|
|
|
260
263
|
if (updates.name !== undefined) {
|
|
261
|
-
setClauses.push(`name =
|
|
264
|
+
setClauses.push(`name = $${paramIdx++}`);
|
|
265
|
+
params.push(updates.name);
|
|
262
266
|
}
|
|
263
267
|
if (updates.permissions !== undefined) {
|
|
264
|
-
setClauses.push(`permissions =
|
|
268
|
+
setClauses.push(`permissions = $${paramIdx++}::jsonb`);
|
|
269
|
+
params.push(JSON.stringify(updates.permissions));
|
|
270
|
+
}
|
|
271
|
+
if (updates.admin !== undefined) {
|
|
272
|
+
setClauses.push(`admin = $${paramIdx++}`);
|
|
273
|
+
params.push(updates.admin);
|
|
265
274
|
}
|
|
266
275
|
if (updates.rate_limit !== undefined) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
276
|
+
if (updates.rate_limit !== null) {
|
|
277
|
+
setClauses.push(`rate_limit = $${paramIdx++}`);
|
|
278
|
+
params.push(updates.rate_limit);
|
|
279
|
+
} else {
|
|
280
|
+
setClauses.push("rate_limit = NULL");
|
|
281
|
+
}
|
|
272
282
|
}
|
|
273
283
|
if (updates.expires_at !== undefined) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
284
|
+
if (updates.expires_at !== null) {
|
|
285
|
+
setClauses.push(`expires_at = $${paramIdx++}`);
|
|
286
|
+
params.push(updates.expires_at);
|
|
287
|
+
} else {
|
|
288
|
+
setClauses.push("expires_at = NULL");
|
|
289
|
+
}
|
|
279
290
|
}
|
|
280
291
|
|
|
281
292
|
if (setClauses.length === 0) {
|
|
@@ -284,12 +295,16 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
284
295
|
|
|
285
296
|
setClauses.push("updated_at = NOW()");
|
|
286
297
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
298
|
+
// The WHERE id = $N uses the next available param index
|
|
299
|
+
params.push(id);
|
|
300
|
+
|
|
301
|
+
const rows = await exec(
|
|
302
|
+
`UPDATE ${TABLE}
|
|
303
|
+
SET ${setClauses.join(", ")}
|
|
304
|
+
WHERE id = $${paramIdx}
|
|
305
|
+
RETURNING *`,
|
|
306
|
+
{ params }
|
|
307
|
+
);
|
|
293
308
|
|
|
294
309
|
if (rows.length === 0) return null;
|
|
295
310
|
return toMasked(rowToApiKey(rows[0]));
|
|
@@ -297,23 +312,25 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
|
|
|
297
312
|
|
|
298
313
|
// ── Revoke (soft-delete) ────────────────────────────────────
|
|
299
314
|
async revokeApiKey(id: string): Promise<boolean> {
|
|
300
|
-
const rows = await exec(
|
|
301
|
-
UPDATE ${TABLE}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
315
|
+
const rows = await exec(
|
|
316
|
+
`UPDATE ${TABLE}
|
|
317
|
+
SET revoked_at = NOW(), updated_at = NOW()
|
|
318
|
+
WHERE id = $1 AND revoked_at IS NULL
|
|
319
|
+
RETURNING id`,
|
|
320
|
+
{ params: [id] }
|
|
321
|
+
);
|
|
306
322
|
return rows.length > 0;
|
|
307
323
|
},
|
|
308
324
|
|
|
309
325
|
// ── Touch last_used_at ──────────────────────────────────────
|
|
310
326
|
async updateLastUsed(id: string): Promise<void> {
|
|
311
327
|
try {
|
|
312
|
-
await exec(
|
|
313
|
-
UPDATE ${TABLE}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
await exec(
|
|
329
|
+
`UPDATE ${TABLE}
|
|
330
|
+
SET last_used_at = NOW()
|
|
331
|
+
WHERE id = $1`,
|
|
332
|
+
{ params: [id] }
|
|
333
|
+
);
|
|
317
334
|
} catch (err) {
|
|
318
335
|
// Non-blocking — don't fail requests because of a usage timestamp update
|
|
319
336
|
logger.error("[api-key-store] Failed to update last_used_at", { error: err });
|
|
@@ -32,6 +32,8 @@ export interface ApiKey {
|
|
|
32
32
|
/** SHA-256 hash of the full plaintext key. */
|
|
33
33
|
key_hash: string;
|
|
34
34
|
permissions: ApiKeyPermission[];
|
|
35
|
+
/** When true, the key is granted the `admin` role (access to admin routes). */
|
|
36
|
+
admin: boolean;
|
|
35
37
|
/** Requests per 15-minute window. `null` means unlimited. */
|
|
36
38
|
rate_limit: number | null;
|
|
37
39
|
created_by: string;
|
|
@@ -51,6 +53,8 @@ export interface ApiKeyMasked {
|
|
|
51
53
|
name: string;
|
|
52
54
|
key_prefix: string;
|
|
53
55
|
permissions: ApiKeyPermission[];
|
|
56
|
+
/** When true, the key is granted the `admin` role (access to admin routes). */
|
|
57
|
+
admin: boolean;
|
|
54
58
|
rate_limit: number | null;
|
|
55
59
|
created_by: string;
|
|
56
60
|
created_at: string;
|
|
@@ -66,6 +70,8 @@ export interface ApiKeyMasked {
|
|
|
66
70
|
export interface CreateApiKeyRequest {
|
|
67
71
|
name: string;
|
|
68
72
|
permissions: ApiKeyPermission[];
|
|
73
|
+
/** When true, grants the `admin` role — allows access to admin routes. */
|
|
74
|
+
admin?: boolean;
|
|
69
75
|
/** Requests per 15-minute window. Omit or `null` for unlimited. */
|
|
70
76
|
rate_limit?: number | null;
|
|
71
77
|
/** ISO-8601 expiration timestamp. Omit for no expiration. */
|
|
@@ -79,6 +85,8 @@ export interface CreateApiKeyRequest {
|
|
|
79
85
|
export interface UpdateApiKeyRequest {
|
|
80
86
|
name?: string;
|
|
81
87
|
permissions?: ApiKeyPermission[];
|
|
88
|
+
/** When true, grants the `admin` role — allows access to admin routes. */
|
|
89
|
+
admin?: boolean;
|
|
82
90
|
rate_limit?: number | null;
|
|
83
91
|
expires_at?: string | null;
|
|
84
92
|
}
|
package/src/auth/apple-oauth.ts
CHANGED
|
@@ -110,7 +110,8 @@ export function createAppleProvider(config: {
|
|
|
110
110
|
providerId: decoded.sub,
|
|
111
111
|
email,
|
|
112
112
|
displayName,
|
|
113
|
-
photoUrl: null // Apple does not provide a profile photo
|
|
113
|
+
photoUrl: null, // Apple does not provide a profile photo
|
|
114
|
+
emailVerified: decoded.email_verified === true || decoded.email_verified === "true"
|
|
114
115
|
};
|
|
115
116
|
} catch (error) {
|
|
116
117
|
logger.error("Apple OAuth error", { error: error });
|