@gaganref/convex-api-keys 0.1.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/LICENSE +201 -0
- package/README.md +419 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/crypto.d.ts +4 -0
- package/dist/client/crypto.d.ts.map +1 -0
- package/dist/client/crypto.js +48 -0
- package/dist/client/crypto.js.map +1 -0
- package/dist/client/errors.d.ts +32 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +43 -0
- package/dist/client/errors.js.map +1 -0
- package/dist/client/index.d.ts +7 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/operations.d.ts +240 -0
- package/dist/client/operations.d.ts.map +1 -0
- package/dist/client/operations.js +700 -0
- package/dist/client/operations.js.map +1 -0
- package/dist/client/options.d.ts +79 -0
- package/dist/client/options.d.ts.map +1 -0
- package/dist/client/options.js +51 -0
- package/dist/client/options.js.map +1 -0
- package/dist/client/types.d.ts +269 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +24 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +40 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +253 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/cleanup.d.ts +29 -0
- package/dist/component/cleanup.d.ts.map +1 -0
- package/dist/component/cleanup.js +70 -0
- package/dist/component/cleanup.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/crons.d.ts +3 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +7 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/lib.d.ts +323 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +659 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +82 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +38 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/sweep.d.ts +27 -0
- package/dist/component/sweep.d.ts.map +1 -0
- package/dist/component/sweep.js +94 -0
- package/dist/component/sweep.js.map +1 -0
- package/dist/shared.d.ts +11 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +11 -0
- package/dist/shared.js.map +1 -0
- package/package.json +116 -0
- package/src/client/__tests__/contracts.test.ts +109 -0
- package/src/client/__tests__/errors.test.ts +133 -0
- package/src/client/__tests__/hooks.test.ts +154 -0
- package/src/client/__tests__/operations.test.ts +742 -0
- package/src/client/__tests__/setup.test.ts +31 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/crypto.ts +64 -0
- package/src/client/errors.ts +67 -0
- package/src/client/index.ts +44 -0
- package/src/client/operations.ts +881 -0
- package/src/client/options.ts +146 -0
- package/src/client/types.ts +313 -0
- package/src/component/__tests__/cleanup.test.ts +472 -0
- package/src/component/__tests__/lib.test.ts +676 -0
- package/src/component/__tests__/setup.test.ts +11 -0
- package/src/component/_generated/api.ts +56 -0
- package/src/component/_generated/component.ts +300 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/cleanup.ts +85 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/crons.ts +20 -0
- package/src/component/lib.ts +843 -0
- package/src/component/schema.ts +49 -0
- package/src/component/sweep.ts +117 -0
- package/src/shared.ts +18 -0
- package/src/test.ts +18 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { mutation, query } from "./_generated/server.js";
|
|
2
|
+
import { v, ConvexError } from "convex/values";
|
|
3
|
+
import { paginationOptsValidator } from "convex/server";
|
|
4
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
5
|
+
import { apiKeyStatusValidator, metadataValidator, permissionsValidator, } from "../shared.js";
|
|
6
|
+
import schema from "./schema.js";
|
|
7
|
+
/**
|
|
8
|
+
* Computes the idle expiry timestamp from `lastUsedAt + maxIdleMs`.
|
|
9
|
+
* Returns `undefined` when idle timeout is not configured.
|
|
10
|
+
*/
|
|
11
|
+
function idleExpiresAt(key) {
|
|
12
|
+
if (key.maxIdleMs === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
return key.lastUsedAt + key.maxIdleMs;
|
|
15
|
+
}
|
|
16
|
+
function effectiveStatus(key, now) {
|
|
17
|
+
if (key.status === "revoked")
|
|
18
|
+
return "revoked";
|
|
19
|
+
if (key.expiresAt !== undefined && now >= key.expiresAt)
|
|
20
|
+
return "expired";
|
|
21
|
+
const idle = idleExpiresAt(key);
|
|
22
|
+
if (idle !== undefined && now >= idle)
|
|
23
|
+
return "idle_timeout";
|
|
24
|
+
return "active";
|
|
25
|
+
}
|
|
26
|
+
function mapEventRow(event) {
|
|
27
|
+
return {
|
|
28
|
+
eventId: event._id,
|
|
29
|
+
keyId: event.keyId,
|
|
30
|
+
namespace: event.namespace,
|
|
31
|
+
type: event.type,
|
|
32
|
+
reason: event.reason,
|
|
33
|
+
metadata: event.metadata,
|
|
34
|
+
createdAt: event._creationTime,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function throwDuplicateTokenHashError() {
|
|
38
|
+
throw new ConvexError({
|
|
39
|
+
code: "invalid_argument",
|
|
40
|
+
message: "token hash already exists",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async function recordEvent(ctx, keyId, namespace, type, reason, metadata) {
|
|
44
|
+
await ctx.db.insert("apiKeyEvents", {
|
|
45
|
+
keyId,
|
|
46
|
+
namespace,
|
|
47
|
+
type,
|
|
48
|
+
reason,
|
|
49
|
+
metadata,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Validators
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
const effectiveStatusValidator = v.union(v.literal("active"), v.literal("revoked"), v.literal("expired"), v.literal("idle_timeout"));
|
|
56
|
+
const failureReasonValidator = v.union(v.literal("not_found"), v.literal("revoked"), v.literal("expired"), v.literal("idle_timeout"));
|
|
57
|
+
const orderValidator = v.optional(v.union(v.literal("asc"), v.literal("desc")));
|
|
58
|
+
const logLevelValidator = v.optional(v.union(v.literal("debug"), v.literal("warn"), v.literal("error"), v.literal("none")));
|
|
59
|
+
const createResultValidator = v.object({
|
|
60
|
+
keyId: v.id("apiKeys"),
|
|
61
|
+
createdAt: v.number(),
|
|
62
|
+
});
|
|
63
|
+
const validateResultValidator = v.union(v.object({
|
|
64
|
+
ok: v.literal(true),
|
|
65
|
+
keyId: v.id("apiKeys"),
|
|
66
|
+
namespace: v.optional(v.string()),
|
|
67
|
+
name: v.optional(v.string()),
|
|
68
|
+
permissions: v.optional(permissionsValidator),
|
|
69
|
+
metadata: v.optional(metadataValidator),
|
|
70
|
+
}), v.object({
|
|
71
|
+
ok: v.literal(false),
|
|
72
|
+
reason: failureReasonValidator,
|
|
73
|
+
}));
|
|
74
|
+
const touchResultValidator = v.union(v.object({
|
|
75
|
+
ok: v.literal(true),
|
|
76
|
+
keyId: v.id("apiKeys"),
|
|
77
|
+
touchedAt: v.number(),
|
|
78
|
+
}), v.object({
|
|
79
|
+
ok: v.literal(false),
|
|
80
|
+
reason: failureReasonValidator,
|
|
81
|
+
}));
|
|
82
|
+
const invalidateResultValidator = v.union(v.object({
|
|
83
|
+
ok: v.literal(true),
|
|
84
|
+
keyId: v.id("apiKeys"),
|
|
85
|
+
revokedAt: v.number(),
|
|
86
|
+
}), v.object({
|
|
87
|
+
ok: v.literal(false),
|
|
88
|
+
reason: v.union(v.literal("not_found"), v.literal("revoked")),
|
|
89
|
+
}));
|
|
90
|
+
const refreshResultValidator = v.union(v.object({
|
|
91
|
+
ok: v.literal(true),
|
|
92
|
+
keyId: v.id("apiKeys"),
|
|
93
|
+
replacedKeyId: v.id("apiKeys"),
|
|
94
|
+
createdAt: v.number(),
|
|
95
|
+
expiresAt: v.optional(v.number()),
|
|
96
|
+
}), v.object({
|
|
97
|
+
ok: v.literal(false),
|
|
98
|
+
reason: failureReasonValidator,
|
|
99
|
+
}));
|
|
100
|
+
const invalidateAllResultValidator = v.object({
|
|
101
|
+
processed: v.number(),
|
|
102
|
+
revoked: v.number(),
|
|
103
|
+
isDone: v.boolean(),
|
|
104
|
+
continueCursor: v.string(),
|
|
105
|
+
});
|
|
106
|
+
const listKeyItemValidator = v.object({
|
|
107
|
+
keyId: v.id("apiKeys"),
|
|
108
|
+
namespace: v.optional(v.string()),
|
|
109
|
+
name: v.optional(v.string()),
|
|
110
|
+
tokenPrefix: v.string(),
|
|
111
|
+
tokenLast4: v.string(),
|
|
112
|
+
permissions: v.optional(permissionsValidator),
|
|
113
|
+
metadata: v.optional(metadataValidator),
|
|
114
|
+
status: apiKeyStatusValidator,
|
|
115
|
+
effectiveStatus: effectiveStatusValidator,
|
|
116
|
+
createdAt: v.number(),
|
|
117
|
+
updatedAt: v.number(),
|
|
118
|
+
lastUsedAt: v.number(),
|
|
119
|
+
expiresAt: v.optional(v.number()),
|
|
120
|
+
maxIdleMs: v.optional(v.number()),
|
|
121
|
+
revokedAt: v.optional(v.number()),
|
|
122
|
+
revocationReason: v.optional(v.string()),
|
|
123
|
+
replaces: v.optional(v.id("apiKeys")),
|
|
124
|
+
});
|
|
125
|
+
const listKeysResultValidator = v.object({
|
|
126
|
+
page: v.array(listKeyItemValidator),
|
|
127
|
+
isDone: v.boolean(),
|
|
128
|
+
continueCursor: v.string(),
|
|
129
|
+
});
|
|
130
|
+
const getKeyResultValidator = v.union(v.object({
|
|
131
|
+
ok: v.literal(true),
|
|
132
|
+
...listKeyItemValidator.fields,
|
|
133
|
+
}), v.object({
|
|
134
|
+
ok: v.literal(false),
|
|
135
|
+
reason: v.literal("not_found"),
|
|
136
|
+
}));
|
|
137
|
+
const listEventItemValidator = v.object({
|
|
138
|
+
eventId: v.id("apiKeyEvents"),
|
|
139
|
+
keyId: v.id("apiKeys"),
|
|
140
|
+
namespace: v.optional(v.string()),
|
|
141
|
+
type: v.union(v.literal("created"), v.literal("revoked"), v.literal("rotated")),
|
|
142
|
+
reason: v.optional(v.string()),
|
|
143
|
+
metadata: v.optional(metadataValidator),
|
|
144
|
+
createdAt: v.number(),
|
|
145
|
+
});
|
|
146
|
+
const listEventsResultValidator = v.object({
|
|
147
|
+
page: v.array(listEventItemValidator),
|
|
148
|
+
isDone: v.boolean(),
|
|
149
|
+
continueCursor: v.string(),
|
|
150
|
+
});
|
|
151
|
+
const updateResultValidator = v.union(v.object({ ok: v.literal(true), keyId: v.id("apiKeys") }), v.object({
|
|
152
|
+
ok: v.literal(false),
|
|
153
|
+
reason: v.union(v.literal("not_found"), v.literal("already_revoked")),
|
|
154
|
+
}));
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Mutations & Queries
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
/**
|
|
159
|
+
* Creates a new key record and emits a creation audit event.
|
|
160
|
+
*/
|
|
161
|
+
export const create = mutation({
|
|
162
|
+
args: {
|
|
163
|
+
tokenHash: v.string(),
|
|
164
|
+
tokenPrefix: v.string(),
|
|
165
|
+
tokenLast4: v.string(),
|
|
166
|
+
namespace: v.optional(v.string()),
|
|
167
|
+
name: v.optional(v.string()),
|
|
168
|
+
permissions: v.optional(permissionsValidator),
|
|
169
|
+
metadata: v.optional(metadataValidator),
|
|
170
|
+
expiresAt: v.optional(v.number()),
|
|
171
|
+
maxIdleMs: v.optional(v.number()),
|
|
172
|
+
logLevel: logLevelValidator,
|
|
173
|
+
},
|
|
174
|
+
returns: createResultValidator,
|
|
175
|
+
handler: async (ctx, args) => {
|
|
176
|
+
const existing = await ctx.db
|
|
177
|
+
.query("apiKeys")
|
|
178
|
+
.withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
|
|
179
|
+
.unique();
|
|
180
|
+
if (existing !== null) {
|
|
181
|
+
throwDuplicateTokenHashError();
|
|
182
|
+
}
|
|
183
|
+
if (typeof args.expiresAt === "number" &&
|
|
184
|
+
(!Number.isInteger(args.expiresAt) || args.expiresAt < 0)) {
|
|
185
|
+
throw new ConvexError({
|
|
186
|
+
code: "invalid_argument",
|
|
187
|
+
message: "expiresAt must be a non-negative integer",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (typeof args.maxIdleMs === "number" &&
|
|
191
|
+
(!Number.isInteger(args.maxIdleMs) || args.maxIdleMs < 0)) {
|
|
192
|
+
throw new ConvexError({
|
|
193
|
+
code: "invalid_argument",
|
|
194
|
+
message: "maxIdleMs must be a non-negative integer",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const keyId = await ctx.db.insert("apiKeys", {
|
|
199
|
+
tokenHash: args.tokenHash,
|
|
200
|
+
tokenPrefix: args.tokenPrefix,
|
|
201
|
+
tokenLast4: args.tokenLast4,
|
|
202
|
+
namespace: args.namespace,
|
|
203
|
+
name: args.name,
|
|
204
|
+
permissions: args.permissions,
|
|
205
|
+
metadata: args.metadata,
|
|
206
|
+
status: "active",
|
|
207
|
+
expiresAt: args.expiresAt,
|
|
208
|
+
maxIdleMs: args.maxIdleMs,
|
|
209
|
+
lastUsedAt: now,
|
|
210
|
+
updatedAt: now,
|
|
211
|
+
});
|
|
212
|
+
await recordEvent(ctx, keyId, args.namespace, "created", undefined, args.metadata);
|
|
213
|
+
if (args.logLevel === "debug") {
|
|
214
|
+
console.log("[api-keys:create]", { keyId, namespace: args.namespace });
|
|
215
|
+
}
|
|
216
|
+
return { keyId, createdAt: now };
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
/**
|
|
220
|
+
* Validates a token hash and returns the matching active key.
|
|
221
|
+
*/
|
|
222
|
+
export const validate = query({
|
|
223
|
+
args: {
|
|
224
|
+
tokenHash: v.string(),
|
|
225
|
+
now: v.number(),
|
|
226
|
+
logLevel: logLevelValidator,
|
|
227
|
+
},
|
|
228
|
+
returns: validateResultValidator,
|
|
229
|
+
handler: async (ctx, args) => {
|
|
230
|
+
const key = await ctx.db
|
|
231
|
+
.query("apiKeys")
|
|
232
|
+
.withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
|
|
233
|
+
.unique();
|
|
234
|
+
if (key === null) {
|
|
235
|
+
if (args.logLevel === "debug") {
|
|
236
|
+
console.log("[api-keys:validate]", { status: "not_found" });
|
|
237
|
+
}
|
|
238
|
+
return { ok: false, reason: "not_found" };
|
|
239
|
+
}
|
|
240
|
+
const status = effectiveStatus(key, args.now);
|
|
241
|
+
if (args.logLevel === "debug") {
|
|
242
|
+
console.log("[api-keys:validate]", { keyId: key._id, status });
|
|
243
|
+
}
|
|
244
|
+
if (status !== "active") {
|
|
245
|
+
return { ok: false, reason: status };
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
keyId: key._id,
|
|
250
|
+
namespace: key.namespace,
|
|
251
|
+
name: key.name,
|
|
252
|
+
permissions: key.permissions,
|
|
253
|
+
metadata: key.metadata,
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
/**
|
|
258
|
+
* Marks a key as recently used and extends idle expiry when configured.
|
|
259
|
+
*/
|
|
260
|
+
export const touch = mutation({
|
|
261
|
+
args: {
|
|
262
|
+
keyId: v.id("apiKeys"),
|
|
263
|
+
now: v.number(),
|
|
264
|
+
},
|
|
265
|
+
returns: touchResultValidator,
|
|
266
|
+
handler: async (ctx, args) => {
|
|
267
|
+
const key = await ctx.db.get(args.keyId);
|
|
268
|
+
if (key === null) {
|
|
269
|
+
return { ok: false, reason: "not_found" };
|
|
270
|
+
}
|
|
271
|
+
const status = effectiveStatus(key, args.now);
|
|
272
|
+
if (status !== "active") {
|
|
273
|
+
return { ok: false, reason: status };
|
|
274
|
+
}
|
|
275
|
+
await ctx.db.patch(key._id, {
|
|
276
|
+
lastUsedAt: args.now,
|
|
277
|
+
updatedAt: args.now,
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
keyId: key._id,
|
|
282
|
+
touchedAt: args.now,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
/**
|
|
287
|
+
* Lists API keys with derived effective status.
|
|
288
|
+
*/
|
|
289
|
+
export const listKeys = query({
|
|
290
|
+
args: {
|
|
291
|
+
paginationOpts: paginationOptsValidator,
|
|
292
|
+
namespace: v.optional(v.string()),
|
|
293
|
+
status: v.optional(apiKeyStatusValidator),
|
|
294
|
+
now: v.number(),
|
|
295
|
+
order: orderValidator,
|
|
296
|
+
},
|
|
297
|
+
returns: listKeysResultValidator,
|
|
298
|
+
handler: async (ctx, args) => {
|
|
299
|
+
const pages = paginator(ctx.db, schema).query("apiKeys");
|
|
300
|
+
const order = args.order ?? "desc";
|
|
301
|
+
let result;
|
|
302
|
+
if (args.namespace !== undefined) {
|
|
303
|
+
result = await pages
|
|
304
|
+
.withIndex("by_namespace_and_status", (q) => {
|
|
305
|
+
const q1 = q.eq("namespace", args.namespace);
|
|
306
|
+
return args.status ? q1.eq("status", args.status) : q1;
|
|
307
|
+
})
|
|
308
|
+
.order(order)
|
|
309
|
+
.paginate(args.paginationOpts);
|
|
310
|
+
}
|
|
311
|
+
else if (args.status !== undefined) {
|
|
312
|
+
result = await pages
|
|
313
|
+
.withIndex("by_status", (q) => q.eq("status", args.status))
|
|
314
|
+
.order(order)
|
|
315
|
+
.paginate(args.paginationOpts);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
result = await pages.order(order).paginate(args.paginationOpts);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
isDone: result.isDone,
|
|
322
|
+
continueCursor: result.continueCursor,
|
|
323
|
+
page: result.page.map((key) => ({
|
|
324
|
+
keyId: key._id,
|
|
325
|
+
namespace: key.namespace,
|
|
326
|
+
name: key.name,
|
|
327
|
+
tokenPrefix: key.tokenPrefix,
|
|
328
|
+
tokenLast4: key.tokenLast4,
|
|
329
|
+
permissions: key.permissions,
|
|
330
|
+
metadata: key.metadata,
|
|
331
|
+
status: key.status,
|
|
332
|
+
effectiveStatus: effectiveStatus(key, args.now),
|
|
333
|
+
createdAt: key._creationTime,
|
|
334
|
+
updatedAt: key.updatedAt,
|
|
335
|
+
lastUsedAt: key.lastUsedAt,
|
|
336
|
+
expiresAt: key.expiresAt,
|
|
337
|
+
maxIdleMs: key.maxIdleMs,
|
|
338
|
+
revokedAt: key.revokedAt,
|
|
339
|
+
revocationReason: key.revocationReason,
|
|
340
|
+
replaces: key.replaces,
|
|
341
|
+
})),
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
/**
|
|
346
|
+
* Fetches a single API key by ID with its derived effective status.
|
|
347
|
+
*/
|
|
348
|
+
export const getKey = query({
|
|
349
|
+
args: {
|
|
350
|
+
keyId: v.id("apiKeys"),
|
|
351
|
+
now: v.number(),
|
|
352
|
+
},
|
|
353
|
+
returns: getKeyResultValidator,
|
|
354
|
+
handler: async (ctx, args) => {
|
|
355
|
+
const key = await ctx.db.get(args.keyId);
|
|
356
|
+
if (key === null) {
|
|
357
|
+
return { ok: false, reason: "not_found" };
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
keyId: key._id,
|
|
362
|
+
namespace: key.namespace,
|
|
363
|
+
name: key.name,
|
|
364
|
+
tokenPrefix: key.tokenPrefix,
|
|
365
|
+
tokenLast4: key.tokenLast4,
|
|
366
|
+
permissions: key.permissions,
|
|
367
|
+
metadata: key.metadata,
|
|
368
|
+
status: key.status,
|
|
369
|
+
effectiveStatus: effectiveStatus(key, args.now),
|
|
370
|
+
createdAt: key._creationTime,
|
|
371
|
+
updatedAt: key.updatedAt,
|
|
372
|
+
lastUsedAt: key.lastUsedAt,
|
|
373
|
+
expiresAt: key.expiresAt,
|
|
374
|
+
maxIdleMs: key.maxIdleMs,
|
|
375
|
+
revokedAt: key.revokedAt,
|
|
376
|
+
revocationReason: key.revocationReason,
|
|
377
|
+
replaces: key.replaces,
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
/**
|
|
382
|
+
* Lists audit events for a single key.
|
|
383
|
+
*/
|
|
384
|
+
export const listKeyEvents = query({
|
|
385
|
+
args: {
|
|
386
|
+
keyId: v.id("apiKeys"),
|
|
387
|
+
paginationOpts: paginationOptsValidator,
|
|
388
|
+
order: orderValidator,
|
|
389
|
+
},
|
|
390
|
+
returns: listEventsResultValidator,
|
|
391
|
+
handler: async (ctx, args) => {
|
|
392
|
+
const result = await paginator(ctx.db, schema)
|
|
393
|
+
.query("apiKeyEvents")
|
|
394
|
+
.withIndex("by_key_id", (q) => q.eq("keyId", args.keyId))
|
|
395
|
+
.order(args.order ?? "desc")
|
|
396
|
+
.paginate(args.paginationOpts);
|
|
397
|
+
return {
|
|
398
|
+
isDone: result.isDone,
|
|
399
|
+
continueCursor: result.continueCursor,
|
|
400
|
+
page: result.page.map(mapEventRow),
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
/**
|
|
405
|
+
* Lists audit events across all keys, optionally scoped by namespace.
|
|
406
|
+
*/
|
|
407
|
+
export const listEvents = query({
|
|
408
|
+
args: {
|
|
409
|
+
paginationOpts: paginationOptsValidator,
|
|
410
|
+
namespace: v.optional(v.string()),
|
|
411
|
+
order: orderValidator,
|
|
412
|
+
},
|
|
413
|
+
returns: listEventsResultValidator,
|
|
414
|
+
handler: async (ctx, args) => {
|
|
415
|
+
const pages = paginator(ctx.db, schema).query("apiKeyEvents");
|
|
416
|
+
const order = args.order ?? "desc";
|
|
417
|
+
const result = args.namespace === undefined
|
|
418
|
+
? await pages.order(order).paginate(args.paginationOpts)
|
|
419
|
+
: await pages
|
|
420
|
+
.withIndex("by_namespace", (q) => q.eq("namespace", args.namespace))
|
|
421
|
+
.order(order)
|
|
422
|
+
.paginate(args.paginationOpts);
|
|
423
|
+
return {
|
|
424
|
+
isDone: result.isDone,
|
|
425
|
+
continueCursor: result.continueCursor,
|
|
426
|
+
page: result.page.map(mapEventRow),
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
/**
|
|
431
|
+
* Revokes a single key and records the revocation event.
|
|
432
|
+
*/
|
|
433
|
+
export const invalidate = mutation({
|
|
434
|
+
args: {
|
|
435
|
+
keyId: v.id("apiKeys"),
|
|
436
|
+
now: v.number(),
|
|
437
|
+
reason: v.optional(v.string()),
|
|
438
|
+
metadata: v.optional(metadataValidator),
|
|
439
|
+
logLevel: logLevelValidator,
|
|
440
|
+
},
|
|
441
|
+
returns: invalidateResultValidator,
|
|
442
|
+
handler: async (ctx, args) => {
|
|
443
|
+
const key = await ctx.db.get(args.keyId);
|
|
444
|
+
if (key === null) {
|
|
445
|
+
return { ok: false, reason: "not_found" };
|
|
446
|
+
}
|
|
447
|
+
if (key.status === "revoked") {
|
|
448
|
+
return { ok: false, reason: "revoked" };
|
|
449
|
+
}
|
|
450
|
+
await ctx.db.patch(key._id, {
|
|
451
|
+
status: "revoked",
|
|
452
|
+
revokedAt: args.now,
|
|
453
|
+
revocationReason: args.reason,
|
|
454
|
+
updatedAt: args.now,
|
|
455
|
+
});
|
|
456
|
+
await recordEvent(ctx, key._id, key.namespace, "revoked", args.reason, args.metadata);
|
|
457
|
+
if (args.logLevel === "debug") {
|
|
458
|
+
console.log("[api-keys:invalidate]", { keyId: key._id });
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, keyId: key._id, revokedAt: args.now };
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
/**
|
|
464
|
+
* Revokes active keys in pages using optional namespace/time filters.
|
|
465
|
+
*/
|
|
466
|
+
export const invalidateAll = mutation({
|
|
467
|
+
args: {
|
|
468
|
+
paginationOpts: paginationOptsValidator,
|
|
469
|
+
namespace: v.optional(v.string()),
|
|
470
|
+
before: v.optional(v.number()),
|
|
471
|
+
after: v.optional(v.number()),
|
|
472
|
+
now: v.number(),
|
|
473
|
+
reason: v.optional(v.string()),
|
|
474
|
+
metadata: v.optional(metadataValidator),
|
|
475
|
+
logLevel: logLevelValidator,
|
|
476
|
+
},
|
|
477
|
+
returns: invalidateAllResultValidator,
|
|
478
|
+
handler: async (ctx, args) => {
|
|
479
|
+
const pages = paginator(ctx.db, schema).query("apiKeys");
|
|
480
|
+
const result = args.namespace !== undefined
|
|
481
|
+
? await pages
|
|
482
|
+
.withIndex("by_namespace_and_status", (q) => q.eq("namespace", args.namespace).eq("status", "active"))
|
|
483
|
+
.order("desc")
|
|
484
|
+
.paginate(args.paginationOpts)
|
|
485
|
+
: await pages
|
|
486
|
+
.withIndex("by_status", (q) => q.eq("status", "active"))
|
|
487
|
+
.order("desc")
|
|
488
|
+
.paginate(args.paginationOpts);
|
|
489
|
+
const toInvalidate = result.page.filter((key) => (args.before === undefined || key._creationTime < args.before) &&
|
|
490
|
+
(args.after === undefined || key._creationTime > args.after));
|
|
491
|
+
for (const key of toInvalidate) {
|
|
492
|
+
await ctx.db.patch(key._id, {
|
|
493
|
+
status: "revoked",
|
|
494
|
+
revokedAt: args.now,
|
|
495
|
+
revocationReason: args.reason,
|
|
496
|
+
updatedAt: args.now,
|
|
497
|
+
});
|
|
498
|
+
await recordEvent(ctx, key._id, key.namespace, "revoked", args.reason, args.metadata);
|
|
499
|
+
}
|
|
500
|
+
const processed = result.page.length;
|
|
501
|
+
const revoked = toInvalidate.length;
|
|
502
|
+
if (args.logLevel === "debug") {
|
|
503
|
+
console.log("[api-keys:invalidateAll]", {
|
|
504
|
+
processed,
|
|
505
|
+
revoked,
|
|
506
|
+
isDone: result.isDone,
|
|
507
|
+
namespace: args.namespace,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
processed,
|
|
512
|
+
revoked,
|
|
513
|
+
isDone: result.isDone,
|
|
514
|
+
continueCursor: result.continueCursor,
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
/**
|
|
519
|
+
* Updates mutable key properties (name, metadata, expiry).
|
|
520
|
+
* Passing `expiresAt: null` removes the expiry field entirely.
|
|
521
|
+
*/
|
|
522
|
+
export const update = mutation({
|
|
523
|
+
args: {
|
|
524
|
+
keyId: v.id("apiKeys"),
|
|
525
|
+
name: v.optional(v.string()),
|
|
526
|
+
metadata: v.optional(metadataValidator),
|
|
527
|
+
expiresAt: v.optional(v.union(v.number(), v.null())),
|
|
528
|
+
maxIdleMs: v.optional(v.union(v.number(), v.null())),
|
|
529
|
+
logLevel: logLevelValidator,
|
|
530
|
+
},
|
|
531
|
+
returns: updateResultValidator,
|
|
532
|
+
handler: async (ctx, args) => {
|
|
533
|
+
const key = await ctx.db.get(args.keyId);
|
|
534
|
+
if (key === null) {
|
|
535
|
+
return { ok: false, reason: "not_found" };
|
|
536
|
+
}
|
|
537
|
+
if (key.status === "revoked") {
|
|
538
|
+
return { ok: false, reason: "already_revoked" };
|
|
539
|
+
}
|
|
540
|
+
if (typeof args.expiresAt === "number" &&
|
|
541
|
+
(!Number.isInteger(args.expiresAt) || args.expiresAt < 0)) {
|
|
542
|
+
throw new ConvexError({
|
|
543
|
+
code: "invalid_argument",
|
|
544
|
+
message: "expiresAt must be a non-negative integer or null",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (typeof args.maxIdleMs === "number" &&
|
|
548
|
+
(!Number.isInteger(args.maxIdleMs) || args.maxIdleMs < 0)) {
|
|
549
|
+
throw new ConvexError({
|
|
550
|
+
code: "invalid_argument",
|
|
551
|
+
message: "maxIdleMs must be a non-negative integer or null",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
const removeExpiresAt = args.expiresAt === null;
|
|
556
|
+
const removeMaxIdleMs = args.maxIdleMs === null;
|
|
557
|
+
if (removeExpiresAt || removeMaxIdleMs) {
|
|
558
|
+
// Removing optional fields requires replace (patch can't unset).
|
|
559
|
+
const { _id, _creationTime, ...rest } = key;
|
|
560
|
+
const updated = { ...rest, updatedAt: now };
|
|
561
|
+
if (removeExpiresAt)
|
|
562
|
+
delete updated.expiresAt;
|
|
563
|
+
if (removeMaxIdleMs)
|
|
564
|
+
delete updated.maxIdleMs;
|
|
565
|
+
if (args.name !== undefined)
|
|
566
|
+
updated.name = args.name;
|
|
567
|
+
if (args.metadata !== undefined)
|
|
568
|
+
updated.metadata = args.metadata;
|
|
569
|
+
if (typeof args.expiresAt === "number")
|
|
570
|
+
updated.expiresAt = args.expiresAt;
|
|
571
|
+
if (typeof args.maxIdleMs === "number")
|
|
572
|
+
updated.maxIdleMs = args.maxIdleMs;
|
|
573
|
+
await ctx.db.replace(_id, updated);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const patch = { updatedAt: now };
|
|
577
|
+
if (args.name !== undefined)
|
|
578
|
+
patch.name = args.name;
|
|
579
|
+
if (args.metadata !== undefined)
|
|
580
|
+
patch.metadata = args.metadata;
|
|
581
|
+
if (typeof args.expiresAt === "number")
|
|
582
|
+
patch.expiresAt = args.expiresAt;
|
|
583
|
+
if (typeof args.maxIdleMs === "number")
|
|
584
|
+
patch.maxIdleMs = args.maxIdleMs;
|
|
585
|
+
await ctx.db.patch(key._id, patch);
|
|
586
|
+
}
|
|
587
|
+
if (args.logLevel === "debug") {
|
|
588
|
+
console.log("[api-keys:update]", { keyId: key._id });
|
|
589
|
+
}
|
|
590
|
+
return { ok: true, keyId: key._id };
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
/**
|
|
594
|
+
* Rotates a key by revoking the old key and creating a replacement.
|
|
595
|
+
*/
|
|
596
|
+
export const refresh = mutation({
|
|
597
|
+
args: {
|
|
598
|
+
keyId: v.id("apiKeys"),
|
|
599
|
+
tokenHash: v.string(),
|
|
600
|
+
tokenPrefix: v.string(),
|
|
601
|
+
tokenLast4: v.string(),
|
|
602
|
+
now: v.number(),
|
|
603
|
+
reason: v.optional(v.string()),
|
|
604
|
+
metadata: v.optional(metadataValidator),
|
|
605
|
+
logLevel: logLevelValidator,
|
|
606
|
+
},
|
|
607
|
+
returns: refreshResultValidator,
|
|
608
|
+
handler: async (ctx, args) => {
|
|
609
|
+
const key = await ctx.db.get(args.keyId);
|
|
610
|
+
if (key === null) {
|
|
611
|
+
return { ok: false, reason: "not_found" };
|
|
612
|
+
}
|
|
613
|
+
const status = effectiveStatus(key, args.now);
|
|
614
|
+
if (status !== "active") {
|
|
615
|
+
return { ok: false, reason: status };
|
|
616
|
+
}
|
|
617
|
+
const existing = await ctx.db
|
|
618
|
+
.query("apiKeys")
|
|
619
|
+
.withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
|
|
620
|
+
.unique();
|
|
621
|
+
if (existing !== null) {
|
|
622
|
+
throwDuplicateTokenHashError();
|
|
623
|
+
}
|
|
624
|
+
const newKeyId = await ctx.db.insert("apiKeys", {
|
|
625
|
+
tokenHash: args.tokenHash,
|
|
626
|
+
tokenPrefix: args.tokenPrefix,
|
|
627
|
+
tokenLast4: args.tokenLast4,
|
|
628
|
+
namespace: key.namespace,
|
|
629
|
+
name: key.name,
|
|
630
|
+
permissions: key.permissions,
|
|
631
|
+
metadata: key.metadata,
|
|
632
|
+
status: "active",
|
|
633
|
+
expiresAt: key.expiresAt,
|
|
634
|
+
maxIdleMs: key.maxIdleMs,
|
|
635
|
+
lastUsedAt: args.now,
|
|
636
|
+
replaces: key._id,
|
|
637
|
+
updatedAt: args.now,
|
|
638
|
+
});
|
|
639
|
+
await ctx.db.patch(key._id, {
|
|
640
|
+
status: "revoked",
|
|
641
|
+
revokedAt: args.now,
|
|
642
|
+
revocationReason: args.reason,
|
|
643
|
+
updatedAt: args.now,
|
|
644
|
+
});
|
|
645
|
+
await recordEvent(ctx, key._id, key.namespace, "rotated", args.reason, args.metadata);
|
|
646
|
+
await recordEvent(ctx, newKeyId, key.namespace, "created", undefined, key.metadata);
|
|
647
|
+
if (args.logLevel === "debug") {
|
|
648
|
+
console.log("[api-keys:refresh]", { oldKeyId: key._id, newKeyId });
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
ok: true,
|
|
652
|
+
keyId: newKeyId,
|
|
653
|
+
replacedKeyId: key._id,
|
|
654
|
+
createdAt: args.now,
|
|
655
|
+
expiresAt: key.expiresAt,
|
|
656
|
+
};
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
//# sourceMappingURL=lib.js.map
|