@robelest/convex-auth 0.0.2 → 0.0.3-preview

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.
Files changed (69) hide show
  1. package/dist/bin.cjs +1 -1
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +10 -1
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +48 -0
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/index.d.ts +1 -2
  8. package/dist/component/index.d.ts.map +1 -1
  9. package/dist/component/index.js +0 -1
  10. package/dist/component/index.js.map +1 -1
  11. package/dist/component/public.d.ts +160 -0
  12. package/dist/component/public.d.ts.map +1 -1
  13. package/dist/component/public.js +124 -0
  14. package/dist/component/public.js.map +1 -1
  15. package/dist/component/schema.d.ts +79 -0
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +45 -0
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/server/convex-auth.d.ts +66 -13
  20. package/dist/server/convex-auth.d.ts.map +1 -1
  21. package/dist/server/convex-auth.js +154 -39
  22. package/dist/server/convex-auth.js.map +1 -1
  23. package/dist/server/email-templates.d.ts +18 -0
  24. package/dist/server/email-templates.d.ts.map +1 -0
  25. package/dist/server/email-templates.js +74 -0
  26. package/dist/server/email-templates.js.map +1 -0
  27. package/dist/server/implementation/apiKey.d.ts +74 -0
  28. package/dist/server/implementation/apiKey.d.ts.map +1 -0
  29. package/dist/server/implementation/apiKey.js +140 -0
  30. package/dist/server/implementation/apiKey.js.map +1 -0
  31. package/dist/server/implementation/index.d.ts +89 -0
  32. package/dist/server/implementation/index.d.ts.map +1 -1
  33. package/dist/server/implementation/index.js +132 -0
  34. package/dist/server/implementation/index.js.map +1 -1
  35. package/dist/server/implementation/signIn.js +3 -14
  36. package/dist/server/implementation/signIn.js.map +1 -1
  37. package/dist/server/index.d.ts +26 -2
  38. package/dist/server/index.d.ts.map +1 -1
  39. package/dist/server/index.js +63 -16
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/server/provider_utils.d.ts +2 -0
  42. package/dist/server/provider_utils.d.ts.map +1 -1
  43. package/dist/server/types.d.ts +205 -2
  44. package/dist/server/types.d.ts.map +1 -1
  45. package/dist/server/version.d.ts +2 -0
  46. package/dist/server/version.d.ts.map +1 -0
  47. package/dist/server/version.js +3 -0
  48. package/dist/server/version.js.map +1 -0
  49. package/package.json +3 -2
  50. package/src/cli/index.ts +1 -1
  51. package/src/cli/utils.ts +248 -0
  52. package/src/client/index.ts +12 -1
  53. package/src/component/_generated/component.ts +61 -0
  54. package/src/component/index.ts +4 -1
  55. package/src/component/public.ts +142 -0
  56. package/src/component/schema.ts +52 -0
  57. package/src/server/convex-auth.ts +188 -56
  58. package/src/server/email-templates.ts +77 -0
  59. package/src/server/implementation/apiKey.ts +185 -0
  60. package/src/server/implementation/index.ts +192 -0
  61. package/src/server/implementation/signIn.ts +2 -12
  62. package/src/server/index.ts +98 -34
  63. package/src/server/types.ts +219 -2
  64. package/src/server/version.ts +2 -0
  65. package/dist/server/portal.d.ts +0 -116
  66. package/dist/server/portal.d.ts.map +0 -1
  67. package/dist/server/portal.js +0 -294
  68. package/dist/server/portal.js.map +0 -1
  69. package/src/server/portal.ts +0 -375
@@ -6,11 +6,24 @@
6
6
  * ```ts
7
7
  * // convex/auth.ts
8
8
  * import { Auth, Portal } from "@robelest/convex-auth/component";
9
- * import github from "@auth/core/providers/github";
9
+ * import google from "@auth/core/providers/google";
10
10
  * import { components } from "./_generated/api";
11
11
  *
12
12
  * export const auth = new Auth(components.auth, {
13
- * providers: [github],
13
+ * providers: [google],
14
+ * email: {
15
+ * from: "My App <noreply@example.com>",
16
+ * send: async (_ctx, { from, to, subject, html }) => {
17
+ * await fetch("https://api.resend.com/emails", {
18
+ * method: "POST",
19
+ * headers: {
20
+ * Authorization: `Bearer ${process.env.AUTH_RESEND_KEY}`,
21
+ * "Content-Type": "application/json",
22
+ * },
23
+ * body: JSON.stringify({ from, to, subject, html }),
24
+ * });
25
+ * },
26
+ * },
14
27
  * });
15
28
  * export const { signIn, signOut, store } = auth;
16
29
  * export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
@@ -23,27 +36,35 @@ import {
23
36
  queryGeneric,
24
37
  mutationGeneric,
25
38
  internalMutationGeneric,
39
+ httpActionGeneric,
26
40
  } from "convex/server";
27
41
  import type { HttpRouter } from "convex/server";
28
42
  import { v } from "convex/values";
29
43
  import type { ComponentApi as AuthComponentApi } from "../component/_generated/component.js";
30
44
  import { Auth as AuthFactory } from "./implementation/index.js";
31
- import type { ConvexAuthConfig } from "./types.js";
45
+ import type { ConvexAuthConfig, EmailTransport } from "./types.js";
32
46
  import { registerStaticRoutes } from "@convex-dev/self-hosting";
33
47
  import { portalMagicLinkEmail } from "./portal-email.js";
34
- import email from "../providers/email.js";
48
+ import { defaultMagicLinkEmail } from "./email-templates.js";
49
+ import emailProvider from "../providers/email.js";
50
+ import { AUTH_VERSION } from "./version.js";
35
51
 
36
52
  // ============================================================================
37
53
  // Types
38
54
  // ============================================================================
39
55
 
40
56
  /**
41
- * Config for the ConvexAuth class. Extends the standard auth config.
57
+ * Config for the Auth class. Extends the standard auth config
58
+ * minus `component` (which is passed as the first constructor argument).
42
59
  *
43
- * Portal functionality (admin dashboard, magic link provider, static hosting)
44
- * is always available no configuration flag needed. The portal UI works
45
- * when you export `portalQuery`, `portalMutation`, `portalInternal` from
46
- * your `convex/auth.ts` and upload the portal static files via CLI.
60
+ * When `email` is configured, the library auto-registers:
61
+ * - A magic link provider (`id: "email"`) for user-facing sign-in
62
+ * - A portal provider (`id: "portal"`) for admin dashboard sign-in
63
+ *
64
+ * Portal functionality is always available — no configuration flag
65
+ * needed. The portal UI works when you export `portalQuery`,
66
+ * `portalMutation`, `portalInternal` from your `convex/auth.ts`
67
+ * and upload the portal static files via CLI.
47
68
  */
48
69
  export type AuthClassConfig = Omit<ConvexAuthConfig, "component">;
49
70
 
@@ -84,7 +105,11 @@ async function requirePortalAdmin(
84
105
  *
85
106
  * ```ts
86
107
  * export const auth = new Auth(components.auth, {
87
- * providers: [github, resend({ ... })],
108
+ * providers: [google, password],
109
+ * email: {
110
+ * from: "My App <noreply@example.com>",
111
+ * send: (ctx, params) => resend.sendEmail(ctx, params),
112
+ * },
88
113
  * });
89
114
  * export const { signIn, signOut, store } = auth;
90
115
  * export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
@@ -122,6 +147,8 @@ export class Auth {
122
147
  get passkey() { return this._auth.passkey; }
123
148
  /** TOTP helpers */
124
149
  get totp() { return this._auth.totp; }
150
+ /** API key helpers: `.create(ctx, ...)`, `.verify(ctx, ...)`, `.list(ctx, ...)`, `.revoke(ctx, ...)` */
151
+ get key() { return this._auth.key; }
125
152
 
126
153
  constructor(component: AuthComponentApi, config: AuthClassConfig) {
127
154
  this.component = component;
@@ -131,47 +158,63 @@ export class Auth {
131
158
  ? `${process.env.CONVEX_SITE_URL.replace(/\/$/, "")}/auth`
132
159
  : "/auth";
133
160
 
134
- // Auto-register the `portal` email provider for magic link sign-in
161
+ const emailTransport = config.email;
135
162
  const providers = [...config.providers];
163
+
164
+ // Auto-register user-facing magic link provider when email is configured.
165
+ // Skipped if the user already registered their own provider with id "email".
166
+ const hasUserEmailProvider = providers.some(
167
+ (p) => typeof p === "object" && "id" in p && p.id === "email",
168
+ );
169
+ if (emailTransport && !hasUserEmailProvider) {
170
+ providers.push(
171
+ emailProvider({
172
+ id: "email",
173
+ maxAge: 60 * 60 * 24, // 24 hours
174
+ authorize: undefined, // Magic link — no OTP email check needed
175
+ async sendVerificationRequest({ identifier, url }, ctx) {
176
+ if (!ctx) {
177
+ throw new Error("Action context is required for email delivery");
178
+ }
179
+ const { host } = new URL(url);
180
+ await emailTransport.send(ctx, {
181
+ from: emailTransport.from,
182
+ to: identifier,
183
+ subject: `Sign in to ${host}`,
184
+ html: defaultMagicLinkEmail(url, host),
185
+ });
186
+ },
187
+ }),
188
+ );
189
+ }
190
+
191
+ // Auto-register portal admin magic link provider.
192
+ // Uses its own styled dark-theme email template.
136
193
  providers.push(
137
- email({
194
+ emailProvider({
138
195
  id: "portal",
139
196
  maxAge: 60 * 60 * 24, // 24 hours
140
- authorize: undefined, // Magic link — no email check needed
141
- async sendVerificationRequest({ identifier, url, expires }) {
197
+ authorize: undefined, // Magic link — no OTP email check needed
198
+ async sendVerificationRequest({ identifier, url, expires }, ctx) {
199
+ if (!emailTransport) {
200
+ throw new Error(
201
+ "Auth email config is required for the portal. " +
202
+ "Configure email: { from, send } in your Auth constructor.",
203
+ );
204
+ }
205
+ if (!ctx) {
206
+ throw new Error("Action context is required for email delivery");
207
+ }
142
208
  const hours = Math.max(
143
209
  1,
144
210
  Math.floor((+expires - Date.now()) / (60 * 60 * 1000)),
145
211
  );
146
- const html = portalMagicLinkEmail(url, hours);
147
- const siteUrl = process.env.CONVEX_SITE_URL;
148
- if (!siteUrl) {
149
- throw new Error(
150
- "CONVEX_SITE_URL is required to send portal magic link email",
151
- );
152
- }
153
- const response = await fetch(`${siteUrl}/auth-email-dispatch`, {
154
- method: "POST",
155
- headers: {
156
- "Content-Type": "application/json",
157
- ...(process.env.AUTH_EMAIL_DISPATCH_SECRET
158
- ? {
159
- "x-auth-email-dispatch-secret":
160
- process.env.AUTH_EMAIL_DISPATCH_SECRET,
161
- }
162
- : {}),
163
- },
164
- body: JSON.stringify({
165
- to: identifier,
166
- subject: "Sign in to Convex Auth Portal",
167
- html,
168
- }),
212
+ await emailTransport.send(ctx, {
213
+ from: emailTransport.from,
214
+ to: identifier,
215
+ subject: "Sign in to Auth Portal",
216
+ html: portalMagicLinkEmail(url, hours),
169
217
  });
170
- if (!response.ok) {
171
- throw new Error(
172
- `Could not send portal magic link email: ${response.status}`,
173
- );
174
- }
175
218
  },
176
219
  }),
177
220
  );
@@ -211,9 +254,36 @@ export class Auth {
211
254
  // Core auth routes (OAuth, JWKS, etc.)
212
255
  this._auth.addHttpRoutes(http);
213
256
 
214
- // Portal static file serving
215
257
  const prefix = opts?.pathPrefix ?? "/auth";
216
258
 
259
+ // Portal configuration endpoint — serves Convex URLs + version info.
260
+ // The portal SPA fetches this at startup to discover its Convex backend,
261
+ // which is critical for custom domain deployments where the hostname
262
+ // alone doesn't reveal the Convex cloud URL.
263
+ // Registered as an exact path match before the static file prefix catch-all.
264
+ http.route({
265
+ path: `${prefix}/.well-known/portal-config`,
266
+ method: "GET",
267
+ handler: httpActionGeneric(async () => {
268
+ return new Response(
269
+ JSON.stringify({
270
+ convexUrl: process.env.CONVEX_CLOUD_URL,
271
+ siteUrl: process.env.CONVEX_SITE_URL,
272
+ version: AUTH_VERSION,
273
+ }),
274
+ {
275
+ status: 200,
276
+ headers: {
277
+ "Content-Type": "application/json",
278
+ "Cache-Control":
279
+ "public, max-age=60, stale-while-revalidate=60",
280
+ "Access-Control-Allow-Origin": "*",
281
+ },
282
+ },
283
+ );
284
+ }),
285
+ });
286
+
217
287
  // Create a shim that maps the self-hosting ComponentApi shape
218
288
  // to the auth component's portalBridge functions
219
289
  const selfHostingShim = {
@@ -328,6 +398,20 @@ export function Portal(auth: Auth) {
328
398
  authComponent.portalBridge.getCurrentDeployment,
329
399
  );
330
400
 
401
+ // ---- API Keys (portal admin) ----
402
+ case "listKeys":
403
+ return await ctx.runQuery(authComponent.public.keyList);
404
+
405
+ case "getUserKeys":
406
+ return await ctx.runQuery(authComponent.public.keyListByUserId, {
407
+ userId: userId!,
408
+ });
409
+
410
+ case "getKey":
411
+ return await ctx.runQuery(authComponent.public.keyGetById, {
412
+ keyId: userId!, // userId param repurposed as keyId
413
+ });
414
+
331
415
  default:
332
416
  throw new Error(`Unknown portal query action: ${action}`);
333
417
  }
@@ -339,23 +423,35 @@ export function Portal(auth: Auth) {
339
423
  action: v.string(),
340
424
  sessionId: v.optional(v.string()),
341
425
  tokenHash: v.optional(v.string()),
426
+ // API key fields
427
+ keyId: v.optional(v.string()),
428
+ keyUserId: v.optional(v.string()),
429
+ keyName: v.optional(v.string()),
430
+ keyScopes: v.optional(
431
+ v.array(
432
+ v.object({
433
+ resource: v.string(),
434
+ actions: v.array(v.string()),
435
+ }),
436
+ ),
437
+ ),
438
+ keyRateLimit: v.optional(
439
+ v.object({
440
+ maxRequests: v.number(),
441
+ windowMs: v.number(),
442
+ }),
443
+ ),
444
+ keyExpiresAt: v.optional(v.number()),
342
445
  },
343
- handler: async (
344
- ctx: any,
345
- {
346
- action,
347
- sessionId,
348
- tokenHash,
349
- }: { action: string; sessionId?: string; tokenHash?: string },
350
- ) => {
446
+ handler: async (ctx: any, args: any) => {
351
447
  const currentUserId = await authHelper.user.require(ctx);
352
448
 
353
- switch (action) {
449
+ switch (args.action) {
354
450
  case "acceptInvite": {
355
- if (!tokenHash) throw new Error("tokenHash required");
451
+ if (!args.tokenHash) throw new Error("tokenHash required");
356
452
  const invite = await ctx.runQuery(
357
453
  authComponent.public.inviteGetByTokenHash,
358
- { tokenHash },
454
+ { tokenHash: args.tokenHash },
359
455
  );
360
456
  if (!invite) throw new Error("Invalid invite token");
361
457
  if (invite.status !== "pending") {
@@ -374,13 +470,49 @@ export function Portal(auth: Auth) {
374
470
  case "revokeSession": {
375
471
  await requirePortalAdmin(ctx, authComponent, currentUserId);
376
472
  await ctx.runMutation(authComponent.public.sessionDelete, {
377
- sessionId: sessionId!,
473
+ sessionId: args.sessionId!,
378
474
  });
379
475
  return;
380
476
  }
381
477
 
478
+ // ---- API Keys (portal admin) ----
479
+ case "createKey": {
480
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
481
+ const result = await authHelper.key.create(ctx, {
482
+ userId: args.keyUserId!,
483
+ name: args.keyName!,
484
+ scopes: args.keyScopes ?? [],
485
+ rateLimit: args.keyRateLimit,
486
+ expiresAt: args.keyExpiresAt,
487
+ });
488
+ // Return the raw key — portal will show it once
489
+ return result;
490
+ }
491
+
492
+ case "revokeKey": {
493
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
494
+ await authHelper.key.revoke(ctx, args.keyId!);
495
+ return;
496
+ }
497
+
498
+ case "deleteKey": {
499
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
500
+ await authHelper.key.remove(ctx, args.keyId!);
501
+ return;
502
+ }
503
+
504
+ case "updateKey": {
505
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
506
+ const data: Record<string, any> = {};
507
+ if (args.keyName) data.name = args.keyName;
508
+ if (args.keyScopes) data.scopes = args.keyScopes;
509
+ if (args.keyRateLimit) data.rateLimit = args.keyRateLimit;
510
+ await authHelper.key.update(ctx, args.keyId!, data);
511
+ return;
512
+ }
513
+
382
514
  default:
383
- throw new Error(`Unknown portal mutation action: ${action}`);
515
+ throw new Error(`Unknown portal mutation action: ${args.action}`);
384
516
  }
385
517
  },
386
518
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Default email templates generated by the Auth library.
3
+ *
4
+ * These are used when the library sends emails on behalf of the developer
5
+ * (magic links, portal admin sign-in). The developer provides the transport
6
+ * via `email.send`; the library provides the content.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ /**
12
+ * Default magic link email template.
13
+ *
14
+ * Clean, minimal design that works across email clients.
15
+ * Used by the auto-registered `email` provider when `email` is
16
+ * configured in the Auth constructor.
17
+ */
18
+ export function defaultMagicLinkEmail(url: string, host: string): string {
19
+ const escapedHost = host.replace(/[&<>"']/g, (c) =>
20
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
21
+ );
22
+
23
+ return `<!DOCTYPE html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
+ <title>Sign in to ${escapedHost}</title>
29
+ </head>
30
+ <body style="margin:0;padding:0;background-color:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
31
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;padding:40px 16px;">
32
+ <tr>
33
+ <td align="center">
34
+ <table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
35
+ <tr>
36
+ <td style="padding:32px 32px 0 32px;text-align:center;">
37
+ <h1 style="margin:0 0 8px 0;font-size:20px;font-weight:600;color:#111827;line-height:1.3;">
38
+ Sign in to ${escapedHost}
39
+ </h1>
40
+ </td>
41
+ </tr>
42
+ <tr>
43
+ <td style="padding:24px 32px;">
44
+ <p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#4b5563;text-align:center;">
45
+ Click the button below to sign in. This link will expire shortly.
46
+ </p>
47
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
48
+ <tr>
49
+ <td align="center" style="padding:0 0 24px 0;">
50
+ <a href="${url}" target="_blank" style="display:inline-block;background-color:#111827;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:12px 32px;border-radius:6px;line-height:1;">
51
+ Sign in
52
+ </a>
53
+ </td>
54
+ </tr>
55
+ </table>
56
+ <p style="margin:0 0 12px 0;font-size:13px;line-height:1.6;color:#9ca3af;">
57
+ If the button doesn't work, copy and paste this URL into your browser:
58
+ </p>
59
+ <p style="margin:0;font-size:13px;line-height:1.5;color:#6b7280;word-break:break-all;">
60
+ ${url}
61
+ </p>
62
+ </td>
63
+ </tr>
64
+ <tr>
65
+ <td style="padding:20px 32px;border-top:1px solid #e5e7eb;">
66
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;text-align:center;">
67
+ If you didn't request this email, you can safely ignore it.
68
+ </p>
69
+ </td>
70
+ </tr>
71
+ </table>
72
+ </td>
73
+ </tr>
74
+ </table>
75
+ </body>
76
+ </html>`;
77
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * API Key crypto utilities.
3
+ *
4
+ * Uses `@oslojs/crypto` primitives for key generation and hashing:
5
+ * - SHA-256 for hashing keys (API keys have high entropy, no need for bcrypt)
6
+ * - Cryptographically secure random generation for key material
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { sha256, generateRandomString } from "./utils.js";
12
+ import type { KeyScope, ScopeChecker } from "../types.js";
13
+
14
+ // ============================================================================
15
+ // Constants
16
+ // ============================================================================
17
+
18
+ const DEFAULT_KEY_PREFIX = "sk_live_";
19
+ const KEY_RANDOM_LENGTH = 32;
20
+ const KEY_RANDOM_ALPHABET =
21
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
22
+
23
+ /**
24
+ * How many characters of the full key to store as the visible prefix.
25
+ * Includes the prefix string (e.g. "sk_live_") plus a few random chars.
26
+ */
27
+ const VISIBLE_PREFIX_EXTRA_CHARS = 4;
28
+
29
+ // ============================================================================
30
+ // Key generation
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Generate a new API key.
35
+ *
36
+ * Returns the raw key (to be shown once to the user) and metadata for storage.
37
+ * The raw key is `{prefix}{32 random alphanumeric chars}`.
38
+ *
39
+ * @param prefix - Key prefix, defaults to "sk_live_"
40
+ * @returns `{ raw, hashedKey, displayPrefix }`
41
+ */
42
+ export async function generateApiKey(prefix: string = DEFAULT_KEY_PREFIX): Promise<{
43
+ /** The full raw key — show to user once, never store. */
44
+ raw: string;
45
+ /** SHA-256 hex hash of the raw key — store this. */
46
+ hashedKey: string;
47
+ /** Truncated prefix for display (e.g. "sk_live_aBc1..."). */
48
+ displayPrefix: string;
49
+ }> {
50
+ const randomPart = generateRandomString(KEY_RANDOM_LENGTH, KEY_RANDOM_ALPHABET);
51
+ const raw = `${prefix}${randomPart}`;
52
+ const hashedKey = await sha256(raw);
53
+ const displayPrefix = `${raw.substring(0, prefix.length + VISIBLE_PREFIX_EXTRA_CHARS)}...`;
54
+
55
+ return { raw, hashedKey, displayPrefix };
56
+ }
57
+
58
+ /**
59
+ * Hash a raw API key for lookup.
60
+ *
61
+ * Used during Bearer token verification to find the stored key record.
62
+ */
63
+ export async function hashApiKey(rawKey: string): Promise<string> {
64
+ return sha256(rawKey);
65
+ }
66
+
67
+ // ============================================================================
68
+ // Scope checker
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Build a `ScopeChecker` from an array of `KeyScope` entries.
73
+ *
74
+ * The checker provides a `.can(resource, action)` method that returns `true`
75
+ * if any scope entry grants the requested permission.
76
+ *
77
+ * A wildcard action `"*"` grants all actions on that resource.
78
+ * A wildcard resource `"*"` grants the action on all resources.
79
+ */
80
+ export function buildScopeChecker(scopes: KeyScope[]): ScopeChecker {
81
+ return {
82
+ scopes,
83
+ can(resource: string, action: string): boolean {
84
+ return scopes.some(
85
+ (scope) =>
86
+ (scope.resource === resource || scope.resource === "*") &&
87
+ (scope.actions.includes(action) || scope.actions.includes("*")),
88
+ );
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Validate that requested scopes are a subset of the allowed scopes
95
+ * defined in the API key config.
96
+ *
97
+ * @param requested - Scopes the user wants on the new key.
98
+ * @param allowed - The scope definition from `apiKeys.scopes` config.
99
+ * @throws Error if any requested scope is not in the allowed set.
100
+ */
101
+ export function validateScopes(
102
+ requested: KeyScope[],
103
+ allowed: Record<string, string[]> | undefined,
104
+ ): void {
105
+ if (!allowed) {
106
+ // No scope restrictions configured — allow anything.
107
+ return;
108
+ }
109
+
110
+ for (const scope of requested) {
111
+ const allowedActions = allowed[scope.resource];
112
+ if (!allowedActions) {
113
+ throw new Error(
114
+ `Unknown resource "${scope.resource}" in API key scopes. ` +
115
+ `Allowed resources: ${Object.keys(allowed).join(", ")}`,
116
+ );
117
+ }
118
+ for (const action of scope.actions) {
119
+ if (action !== "*" && !allowedActions.includes(action)) {
120
+ throw new Error(
121
+ `Unknown action "${action}" for resource "${scope.resource}". ` +
122
+ `Allowed actions: ${allowedActions.join(", ")}`,
123
+ );
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // ============================================================================
130
+ // Per-key rate limiting (token-bucket)
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Check whether a key is rate-limited based on its stored state.
135
+ *
136
+ * Uses the same token-bucket algorithm as sign-in rate limiting:
137
+ * tokens refill linearly over the configured window.
138
+ *
139
+ * @returns `{ limited: boolean; newState: { attemptsLeft, lastAttemptTime } }`
140
+ */
141
+ export function checkKeyRateLimit(
142
+ rateLimit: { maxRequests: number; windowMs: number },
143
+ state: { attemptsLeft: number; lastAttemptTime: number } | undefined,
144
+ ): {
145
+ limited: boolean;
146
+ newState: { attemptsLeft: number; lastAttemptTime: number };
147
+ } {
148
+ const now = Date.now();
149
+
150
+ if (!state) {
151
+ // First request — create initial state with one token consumed.
152
+ return {
153
+ limited: false,
154
+ newState: {
155
+ attemptsLeft: rateLimit.maxRequests - 1,
156
+ lastAttemptTime: now,
157
+ },
158
+ };
159
+ }
160
+
161
+ const elapsed = now - state.lastAttemptTime;
162
+ const refillRate = rateLimit.maxRequests / rateLimit.windowMs;
163
+ const refilled = Math.min(
164
+ rateLimit.maxRequests,
165
+ state.attemptsLeft + elapsed * refillRate,
166
+ );
167
+
168
+ if (refilled < 1) {
169
+ return {
170
+ limited: true,
171
+ newState: {
172
+ attemptsLeft: refilled,
173
+ lastAttemptTime: now,
174
+ },
175
+ };
176
+ }
177
+
178
+ return {
179
+ limited: false,
180
+ newState: {
181
+ attemptsLeft: refilled - 1,
182
+ lastAttemptTime: now,
183
+ },
184
+ };
185
+ }