@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
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Shared CLI utilities — logging, subprocess execution, file helpers.
3
+ *
4
+ * Eliminates duplication across index.ts, portal-upload.ts, portal-link.ts.
5
+ * All output goes to stderr so stdout can be piped cleanly.
6
+ */
7
+
8
+ import chalk from "chalk";
9
+ import { execFileSync } from "child_process";
10
+ import { existsSync, readFileSync, writeFileSync } from "fs";
11
+ import { extname } from "path";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Logging — unified output to stderr with chalk prefixes
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const write = (msg: string) => process.stderr.write(msg + "\n");
18
+
19
+ export const log = {
20
+ step: (n: number, msg: string) => write(`${chalk.blue.bold(`[${n}]`)} ${chalk.bold(msg)}`),
21
+ success: (msg: string) => write(`${chalk.green("✔")} ${msg}`),
22
+ warn: (msg: string) => write(`${chalk.yellow.bold("!")} ${msg}`),
23
+ error: (msg: string, detail?: string) =>
24
+ write(`${chalk.red("✖")} ${msg}${detail ? `\n ${chalk.grey(`Error: ${detail}`)}` : ""}`),
25
+ info: (msg: string) => write(`${chalk.blue.bold("i")} ${msg}`),
26
+ blank: () => write(""),
27
+ raw: (msg: string) => write(msg),
28
+ indent: (msg: string) => write(` ${msg}`),
29
+ } as const;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Subprocess — safe execFile with argument arrays (no shell injection)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export type DeploymentOptions = {
36
+ prod?: boolean;
37
+ adminKey?: string;
38
+ url?: string;
39
+ previewName?: string;
40
+ deploymentName?: string;
41
+ };
42
+
43
+ /** Build CLI args array for Convex deployment selection. */
44
+ export const deploymentArgs = (opts: DeploymentOptions): string[] => {
45
+ const args: string[] = [];
46
+ if (opts.adminKey) args.push("--admin-key", opts.adminKey);
47
+ if (opts.url) args.push("--url", opts.url);
48
+ else if (opts.prod) args.push("--prod");
49
+ else if (opts.previewName) args.push("--preview-name", opts.previewName);
50
+ else if (opts.deploymentName) args.push("--deployment-name", opts.deploymentName);
51
+ return args;
52
+ };
53
+
54
+ /** Run `npx convex env get <name>` and return the value. */
55
+ export const envGet = (name: string, opts: DeploymentOptions): string =>
56
+ execFileSync("npx", ["convex", "env", "get", ...deploymentArgs(opts), name], {
57
+ encoding: "utf-8",
58
+ stdio: ["pipe", "pipe", "pipe"],
59
+ }).slice(0, -1); // strip trailing newline
60
+
61
+ /** Run `npx convex env set <name> <value>`. */
62
+ export const envSet = (
63
+ name: string,
64
+ value: string,
65
+ opts: DeploymentOptions & { hideValue?: boolean },
66
+ ): void => {
67
+ execFileSync(
68
+ "npx",
69
+ ["convex", "env", "set", ...deploymentArgs(opts), "--", name, value],
70
+ { stdio: opts.hideValue ? "ignore" : "inherit" },
71
+ );
72
+ };
73
+
74
+ /**
75
+ * Run a Convex function via `npx convex run` and return parsed JSON output.
76
+ * Uses execFile with argument arrays — no shell injection.
77
+ */
78
+ export const convexRun = <T = unknown>(
79
+ functionPath: string,
80
+ args: Record<string, unknown>,
81
+ opts: { prod?: boolean } = {},
82
+ ): Promise<T> =>
83
+ new Promise((resolve, reject) => {
84
+ const { execFile } = require("child_process");
85
+ const cmdArgs = [
86
+ "convex", "run", functionPath,
87
+ JSON.stringify(args),
88
+ "--typecheck=disable",
89
+ "--codegen=disable",
90
+ ...(opts.prod ? ["--prod"] : []),
91
+ ];
92
+ execFile("npx", cmdArgs, { encoding: "utf-8" }, (error: any, stdout: string, stderr: string) => {
93
+ if (error) {
94
+ reject(new Error(`convex run ${functionPath} failed: ${stderr || stdout}`));
95
+ return;
96
+ }
97
+ try {
98
+ resolve(JSON.parse(stdout.trim()) as T);
99
+ } catch {
100
+ // If output is not JSON, return raw string as-is
101
+ resolve(stdout.trim() as T);
102
+ }
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // MIME types
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const MIME_TYPES: Record<string, string> = {
111
+ ".html": "text/html; charset=utf-8",
112
+ ".js": "application/javascript; charset=utf-8",
113
+ ".mjs": "application/javascript; charset=utf-8",
114
+ ".css": "text/css; charset=utf-8",
115
+ ".json": "application/json; charset=utf-8",
116
+ ".png": "image/png",
117
+ ".jpg": "image/jpeg",
118
+ ".jpeg": "image/jpeg",
119
+ ".gif": "image/gif",
120
+ ".svg": "image/svg+xml",
121
+ ".ico": "image/x-icon",
122
+ ".webp": "image/webp",
123
+ ".woff": "font/woff",
124
+ ".woff2":"font/woff2",
125
+ ".ttf": "font/ttf",
126
+ ".txt": "text/plain; charset=utf-8",
127
+ ".map": "application/json",
128
+ ".webmanifest": "application/manifest+json",
129
+ ".xml": "application/xml",
130
+ ".br": "application/octet-stream",
131
+ ".gz": "application/gzip",
132
+ };
133
+
134
+ export const getMimeType = (path: string): string =>
135
+ MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // File helpers
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Check for an existing non-empty source file (.ts or .js) at the given
143
+ * base path (without extension). Returns the path if found, null otherwise.
144
+ */
145
+ export const findExistingSource = (basePath: string): string | null =>
146
+ [".ts", ".js"]
147
+ .map((ext) => basePath + ext)
148
+ .find((p) => existsSync(p) && readFileSync(p, "utf-8").trim() !== "")
149
+ ?? null;
150
+
151
+ /**
152
+ * Test whether an existing file already matches a template.
153
+ * Templates use `$$` as wildcards and `;` followed by newline as flexible separators.
154
+ */
155
+ export const matchesTemplate = (existing: string, template: string): boolean =>
156
+ new RegExp(
157
+ template
158
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
159
+ .replace(/\\\$\\\$/g, ".*")
160
+ .replace(/;\n/g, ";.*"),
161
+ "s",
162
+ ).test(existing);
163
+
164
+ /** Strip template markers from a source template. */
165
+ export const stripMarkers = (template: string): string =>
166
+ template.replace(/\$\$/g, "");
167
+
168
+ /**
169
+ * Higher-order function: ensure a file matches a template.
170
+ *
171
+ * - If the file doesn't exist → create it
172
+ * - If it already matches → log success
173
+ * - If it exists but doesn't match → show instructions and prompt
174
+ *
175
+ * Returns a configured function bound to the convex folder path + TS preference.
176
+ */
177
+ export const createFileEnsurer = (
178
+ convexFolderPath: string,
179
+ usesTypeScript: boolean,
180
+ promptFn: (message: string) => Promise<void>,
181
+ ) => {
182
+ const path = require("path");
183
+
184
+ return async (
185
+ baseName: string,
186
+ template: string,
187
+ description: string,
188
+ ): Promise<void> => {
189
+ const source = stripMarkers(template);
190
+ const filePath = path.join(convexFolderPath, baseName);
191
+ const existing = findExistingSource(filePath);
192
+
193
+ if (existing) {
194
+ const content = readFileSync(existing, "utf-8");
195
+ if (matchesTemplate(content, template)) {
196
+ log.success(`${chalk.bold(existing)} already configured.`);
197
+ return;
198
+ }
199
+ log.info(`${chalk.bold(existing)} needs ${description}:`);
200
+ log.raw(`\n${indentBlock(source)}\n`);
201
+ await promptFn("Ready to continue?");
202
+ return;
203
+ }
204
+
205
+ const ext = usesTypeScript ? ".ts" : ".js";
206
+ const newPath = filePath + ext;
207
+ writeFileSync(newPath, source);
208
+ log.success(`Created ${chalk.bold(newPath)}`);
209
+ };
210
+ };
211
+
212
+ /** Indent a multiline string (2 spaces, first line not indented). */
213
+ export const indentBlock = (s: string): string =>
214
+ s.replace(/^/gm, " ").slice(2);
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Crypto helpers (for portal invite links)
218
+ // ---------------------------------------------------------------------------
219
+
220
+ export { randomBytes, createHash } from "crypto";
221
+
222
+ /** Generate a URL-safe random token (32 bytes → 43 chars base64url). */
223
+ export const generateToken = (): string =>
224
+ require("crypto").randomBytes(32).toString("base64url");
225
+
226
+ /** SHA-256 hash a string and return the hex digest. */
227
+ export const hashToken = (token: string): string =>
228
+ require("crypto").createHash("sha256").update(token).digest("hex");
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Package version
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /** Read the auth package version from its own package.json. */
235
+ export const getPackageVersion = (): string => {
236
+ try {
237
+ const pkgPath = require("path").resolve(__dirname, "..", "package.json");
238
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
239
+ } catch {
240
+ // Fallback: if running from dist/bin.cjs, package.json is two levels up
241
+ try {
242
+ const pkgPath = require("path").resolve(__dirname, "..", "..", "package.json");
243
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
244
+ } catch {
245
+ return "unknown";
246
+ }
247
+ }
248
+ };
@@ -243,6 +243,13 @@ export function client(options: ClientOptions) {
243
243
  isLoading = false;
244
244
  const changed = updateSnapshot();
245
245
  if (hadPendingLoad || changed) {
246
+ // Re-sync the Convex client so it picks up the new token immediately.
247
+ // Without this, the initial convex.setAuth(fetchAccessToken) from
248
+ // initialization never re-polls and queries run unauthenticated after
249
+ // magic link code exchange.
250
+ if (!proxy) {
251
+ convex.setAuth(fetchAccessToken);
252
+ }
246
253
  notify();
247
254
  }
248
255
  };
@@ -555,7 +562,11 @@ export function client(options: ClientOptions) {
555
562
  }
556
563
  } else {
557
564
  // SPA mode: hydrate from localStorage, then handle OAuth code flow.
558
- void hydrateFromStorage().then(() => handleCodeFlow());
565
+ void hydrateFromStorage().then(() =>
566
+ handleCodeFlow().catch((error: unknown) => {
567
+ console.error("[convex-auth] Code exchange failed:", error);
568
+ }),
569
+ );
559
570
  }
560
571
  }
561
572
 
@@ -210,6 +210,67 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
210
210
  any,
211
211
  Name
212
212
  >;
213
+ keyDelete: FunctionReference<
214
+ "mutation",
215
+ "internal",
216
+ { keyId: string },
217
+ any,
218
+ Name
219
+ >;
220
+ keyGetByHashedKey: FunctionReference<
221
+ "query",
222
+ "internal",
223
+ { hashedKey: string },
224
+ any,
225
+ Name
226
+ >;
227
+ keyGetById: FunctionReference<
228
+ "query",
229
+ "internal",
230
+ { keyId: string },
231
+ any,
232
+ Name
233
+ >;
234
+ keyInsert: FunctionReference<
235
+ "mutation",
236
+ "internal",
237
+ {
238
+ expiresAt?: number;
239
+ hashedKey: string;
240
+ name: string;
241
+ prefix: string;
242
+ rateLimit?: { maxRequests: number; windowMs: number };
243
+ scopes: Array<{ resource: string; actions: Array<string> }>;
244
+ userId: string;
245
+ },
246
+ any,
247
+ Name
248
+ >;
249
+ keyList: FunctionReference<"query", "internal", {}, any, Name>;
250
+ keyListByUserId: FunctionReference<
251
+ "query",
252
+ "internal",
253
+ { userId: string },
254
+ any,
255
+ Name
256
+ >;
257
+ keyPatch: FunctionReference<
258
+ "mutation",
259
+ "internal",
260
+ {
261
+ data: {
262
+ lastUsedAt?: number;
263
+ name?: string;
264
+ rateLimit?: { maxRequests: number; windowMs: number };
265
+ rateLimitState?: { attemptsLeft: number; lastAttemptTime: number };
266
+ revoked?: boolean;
267
+ scopes?: Array<{ resource: string; actions: Array<string> }>;
268
+ };
269
+ keyId: string;
270
+ },
271
+ any,
272
+ Name
273
+ >;
213
274
  memberAdd: FunctionReference<
214
275
  "mutation",
215
276
  "internal",
@@ -15,7 +15,6 @@ export {
15
15
  SignInAction,
16
16
  SignOutAction,
17
17
  } from "../server/implementation/index.js";
18
- export { Portal as PortalFactory } from "../server/portal.js";
19
18
  export { Auth, Portal } from "../server/convex-auth.js";
20
19
  export type {
21
20
  ConvexAuthConfig,
@@ -28,5 +27,9 @@ export type {
28
27
  GenericActionCtxWithAuthConfig,
29
28
  AuthProviderMaterializedConfig,
30
29
  ConvexAuthMaterializedConfig,
30
+ ApiKeyConfig,
31
+ KeyScope,
32
+ ScopeChecker,
33
+ KeyRecord,
31
34
  } from "../server/types.js";
32
35
  export type { GenericDoc } from "../server/convex_types.js";
@@ -986,4 +986,146 @@ export const inviteRevoke = mutation({
986
986
  },
987
987
  });
988
988
 
989
+ // ============================================================================
990
+ // API Keys
991
+ // ============================================================================
992
+
993
+ /**
994
+ * Insert a new API key record.
995
+ *
996
+ * The caller is responsible for hashing the raw key before passing it here —
997
+ * this function only stores the hash and metadata.
998
+ */
999
+ export const keyInsert = mutation({
1000
+ args: {
1001
+ userId: v.id("user"),
1002
+ prefix: v.string(),
1003
+ hashedKey: v.string(),
1004
+ name: v.string(),
1005
+ scopes: v.array(
1006
+ v.object({
1007
+ resource: v.string(),
1008
+ actions: v.array(v.string()),
1009
+ }),
1010
+ ),
1011
+ rateLimit: v.optional(
1012
+ v.object({
1013
+ maxRequests: v.number(),
1014
+ windowMs: v.number(),
1015
+ }),
1016
+ ),
1017
+ expiresAt: v.optional(v.number()),
1018
+ },
1019
+ handler: async (ctx, args) => {
1020
+ return await ctx.db.insert("key", {
1021
+ ...args,
1022
+ createdAt: Date.now(),
1023
+ revoked: false,
1024
+ });
1025
+ },
1026
+ });
1027
+
1028
+ /**
1029
+ * Look up an API key by its SHA-256 hash.
1030
+ *
1031
+ * Used during Bearer token verification. Returns the full key record
1032
+ * (including rate limit state) or `null` if not found.
1033
+ */
1034
+ export const keyGetByHashedKey = query({
1035
+ args: { hashedKey: v.string() },
1036
+ handler: async (ctx, { hashedKey }) => {
1037
+ return await ctx.db
1038
+ .query("key")
1039
+ .withIndex("hashedKey", (q) => q.eq("hashedKey", hashedKey))
1040
+ .first();
1041
+ },
1042
+ });
1043
+
1044
+ /** List all API keys for a user. */
1045
+ export const keyListByUserId = query({
1046
+ args: { userId: v.id("user") },
1047
+ handler: async (ctx, { userId }) => {
1048
+ return await ctx.db
1049
+ .query("key")
1050
+ .withIndex("userId", (q) => q.eq("userId", userId))
1051
+ .collect();
1052
+ },
1053
+ });
1054
+
1055
+ /** List all API keys across all users (for portal admin). */
1056
+ export const keyList = query({
1057
+ args: {},
1058
+ handler: async (ctx) => {
1059
+ return await ctx.db.query("key").collect();
1060
+ },
1061
+ });
989
1062
 
1063
+ /** Get a single API key by document ID. */
1064
+ export const keyGetById = query({
1065
+ args: { keyId: v.id("key") },
1066
+ handler: async (ctx, { keyId }) => {
1067
+ return await ctx.db.get(keyId);
1068
+ },
1069
+ });
1070
+
1071
+ /**
1072
+ * Patch an API key record. Used for updating name, scopes, rate limit config,
1073
+ * revocation, and lastUsedAt / rate limit state tracking.
1074
+ */
1075
+ export const keyPatch = mutation({
1076
+ args: {
1077
+ keyId: v.id("key"),
1078
+ data: v.object({
1079
+ name: v.optional(v.string()),
1080
+ scopes: v.optional(
1081
+ v.array(
1082
+ v.object({
1083
+ resource: v.string(),
1084
+ actions: v.array(v.string()),
1085
+ }),
1086
+ ),
1087
+ ),
1088
+ rateLimit: v.optional(
1089
+ v.object({
1090
+ maxRequests: v.number(),
1091
+ windowMs: v.number(),
1092
+ }),
1093
+ ),
1094
+ rateLimitState: v.optional(
1095
+ v.object({
1096
+ attemptsLeft: v.number(),
1097
+ lastAttemptTime: v.number(),
1098
+ }),
1099
+ ),
1100
+ revoked: v.optional(v.boolean()),
1101
+ lastUsedAt: v.optional(v.number()),
1102
+ }),
1103
+ },
1104
+ handler: async (ctx, { keyId, data }) => {
1105
+ const key = await ctx.db.get(keyId);
1106
+ if (key === null) {
1107
+ throw new ConvexError({
1108
+ code: "KEY_NOT_FOUND",
1109
+ message: "API key not found",
1110
+ keyId,
1111
+ });
1112
+ }
1113
+ await ctx.db.patch(keyId, data);
1114
+ },
1115
+ });
1116
+
1117
+ /** Hard delete an API key record. */
1118
+ export const keyDelete = mutation({
1119
+ args: { keyId: v.id("key") },
1120
+ handler: async (ctx, { keyId }) => {
1121
+ const key = await ctx.db.get(keyId);
1122
+ if (key === null) {
1123
+ throw new ConvexError({
1124
+ code: "KEY_NOT_FOUND",
1125
+ message: "API key not found",
1126
+ keyId,
1127
+ });
1128
+ }
1129
+ await ctx.db.delete(keyId);
1130
+ },
1131
+ });
@@ -226,4 +226,56 @@ export default defineSchema({
226
226
  "status",
227
227
  "acceptedByUserId",
228
228
  ]),
229
+
230
+ /**
231
+ * API keys for programmatic access. Each key links a user to a set of
232
+ * scoped permissions and optional per-key rate limiting.
233
+ *
234
+ * The raw key is never stored — only a SHA-256 hash. A short prefix
235
+ * (e.g. "sk_live_abc1...") is kept for display in the portal.
236
+ *
237
+ * Keys support:
238
+ * - **Scoped permissions**: resource:action pairs (e.g. users:read)
239
+ * - **Per-key rate limiting**: token-bucket with configurable window
240
+ * - **Expiration**: optional TTL
241
+ * - **Soft revocation**: `revoked` flag preserves audit trail
242
+ */
243
+ key: defineTable({
244
+ userId: v.id("user"),
245
+ /** First chars of the key for display (e.g. "sk_live_abc1..."). */
246
+ prefix: v.string(),
247
+ /** SHA-256 hex hash of the full raw key. */
248
+ hashedKey: v.string(),
249
+ /** User-assigned name (e.g. "CI Pipeline", "Production API"). */
250
+ name: v.string(),
251
+ /** Scoped permissions: [{ resource: "users", actions: ["read", "list"] }]. */
252
+ scopes: v.array(
253
+ v.object({
254
+ resource: v.string(),
255
+ actions: v.array(v.string()),
256
+ }),
257
+ ),
258
+ /** Optional per-key rate limit configuration. */
259
+ rateLimit: v.optional(
260
+ v.object({
261
+ maxRequests: v.number(),
262
+ windowMs: v.number(),
263
+ }),
264
+ ),
265
+ /** Rate limit state tracking (token-bucket). */
266
+ rateLimitState: v.optional(
267
+ v.object({
268
+ attemptsLeft: v.number(),
269
+ lastAttemptTime: v.number(),
270
+ }),
271
+ ),
272
+ /** Expiration timestamp. Null/undefined = never expires. */
273
+ expiresAt: v.optional(v.number()),
274
+ lastUsedAt: v.optional(v.number()),
275
+ createdAt: v.number(),
276
+ /** Soft-revoke flag. Revoked keys are kept for audit trail. */
277
+ revoked: v.boolean(),
278
+ })
279
+ .index("userId", ["userId"])
280
+ .index("hashedKey", ["hashedKey"]),
229
281
  });