@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.
- package/dist/bin.cjs +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +10 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +48 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/index.d.ts +1 -2
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +0 -1
- package/dist/component/index.js.map +1 -1
- package/dist/component/public.d.ts +160 -0
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +124 -0
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +79 -0
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +45 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/server/convex-auth.d.ts +66 -13
- package/dist/server/convex-auth.d.ts.map +1 -1
- package/dist/server/convex-auth.js +154 -39
- package/dist/server/convex-auth.js.map +1 -1
- package/dist/server/email-templates.d.ts +18 -0
- package/dist/server/email-templates.d.ts.map +1 -0
- package/dist/server/email-templates.js +74 -0
- package/dist/server/email-templates.js.map +1 -0
- package/dist/server/implementation/apiKey.d.ts +74 -0
- package/dist/server/implementation/apiKey.d.ts.map +1 -0
- package/dist/server/implementation/apiKey.js +140 -0
- package/dist/server/implementation/apiKey.js.map +1 -0
- package/dist/server/implementation/index.d.ts +89 -0
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +132 -0
- package/dist/server/implementation/index.js.map +1 -1
- package/dist/server/implementation/signIn.js +3 -14
- package/dist/server/implementation/signIn.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +63 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/provider_utils.d.ts +2 -0
- package/dist/server/provider_utils.d.ts.map +1 -1
- package/dist/server/types.d.ts +205 -2
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.d.ts.map +1 -0
- package/dist/server/version.js +3 -0
- package/dist/server/version.js.map +1 -0
- package/package.json +3 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/utils.ts +248 -0
- package/src/client/index.ts +12 -1
- package/src/component/_generated/component.ts +61 -0
- package/src/component/index.ts +4 -1
- package/src/component/public.ts +142 -0
- package/src/component/schema.ts +52 -0
- package/src/server/convex-auth.ts +188 -56
- package/src/server/email-templates.ts +77 -0
- package/src/server/implementation/apiKey.ts +185 -0
- package/src/server/implementation/index.ts +192 -0
- package/src/server/implementation/signIn.ts +2 -12
- package/src/server/index.ts +98 -34
- package/src/server/types.ts +219 -2
- package/src/server/version.ts +2 -0
- package/dist/server/portal.d.ts +0 -116
- package/dist/server/portal.d.ts.map +0 -1
- package/dist/server/portal.js +0 -294
- package/dist/server/portal.js.map +0 -1
- package/src/server/portal.ts +0 -375
package/src/cli/utils.ts
ADDED
|
@@ -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
|
+
};
|
package/src/client/index.ts
CHANGED
|
@@ -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(() =>
|
|
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",
|
package/src/component/index.ts
CHANGED
|
@@ -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";
|
package/src/component/public.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/component/schema.ts
CHANGED
|
@@ -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
|
});
|