49a9764862f373c15fe08b56a088741281af19bf02b0134b8f1dc9a00fc62b791import log from "../../seed/log.js";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import crypto from "crypto";
4import bcrypt from "bcrypt";
5import User from "../../seed/models/user.js";
6import { getUserMeta, batchSetUserMeta } from "../../seed/tree/userMetadata.js";
7
8const MAX_API_KEYS_PER_USER = 10;
9
10function getKeys(user) {
11 const raw = getUserMeta(user, "apiKeys");
12 return Array.isArray(raw) ? raw : [];
13}
14
15function containsHtml(str) {
16 return /<[a-zA-Z\/][^>]*>/.test(str);
17}
18
19export async function generateApiKey() {
20 const rawKey = crypto.randomBytes(32).toString("hex");
21 const keyHash = await bcrypt.hash(rawKey, 10);
22 const keyPrefix = rawKey.slice(0, 8);
23 return { rawKey, keyHash, keyPrefix };
24}
25
26export async function compareApiKey(rawKey, keyHash) {
27 return bcrypt.compare(rawKey, keyHash);
28}
29
30export const createApiKey = async (req, res) => {
31 try {
32 const userId = req.userId;
33 const { name, revokeOld = false } = req.body;
34
35 if (name && typeof name !== "string") {
36 return sendError(res, 400, ERR.INVALID_INPUT, "Invalid key name");
37 }
38
39 const safeName = name?.trim().slice(0, 64) || "API Key";
40
41 if (containsHtml(safeName)) {
42 return sendError(res, 400, ERR.INVALID_INPUT, "Key name cannot contain HTML tags");
43 }
44
45 const user = await User.findById(userId);
46 if (!user) {
47 return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
48 }
49
50 let keys = getUserMeta(user, "apiKeys");
51 if (!Array.isArray(keys)) keys = [];
52
53 if (revokeOld) {
54 keys = keys.map((k) => ({ ...k, revoked: true }));
55 }
56
57 if (keys.filter((k) => !k.revoked).length > MAX_API_KEYS_PER_USER) {
58 return sendError(res, 400, ERR.INVALID_INPUT, "API key limit reached");
59 }
60
61 const { rawKey, keyHash, keyPrefix } = await generateApiKey();
62 keys = [...keys, { _id: crypto.randomUUID(), keyHash, keyPrefix, name: safeName, createdAt: new Date() }];
63 await batchSetUserMeta(userId, "apiKeys", keys);
64
65 return sendOk(res, {
66 apiKey: rawKey,
67 message: "Store this key securely. You will not see it again.",
68 }, 201);
69 } catch (err) {
70 log.error("Api Keys", "[createApiKey]", err);
71 return sendError(res, 500, ERR.INTERNAL, "Failed to create API key");
72 }
73};
74
75export const listApiKeys = async (req, res) => {
76 try {
77 const user = await User.findById(req.userId).select("metadata");
78 if (!user) {
79 return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
80 }
81
82 return sendOk(res,
83 getKeys(user).map((k) => ({
84 id: k._id,
85 name: k.name,
86 createdAt: k.createdAt,
87 lastUsedAt: k.lastUsedAt,
88 usageCount: k.usageCount,
89 revoked: k.revoked,
90 })),
91 );
92 } catch (err) {
93 log.error("Api Keys", "[listApiKeys]", err);
94 return sendError(res, 500, ERR.INTERNAL, "Failed to list API keys");
95 }
96};
97
98export const deleteApiKey = async (req, res) => {
99 try {
100 const { keyId } = req.params;
101 if (!keyId) {
102 return sendError(res, 400, ERR.INVALID_INPUT, "Key ID required");
103 }
104
105 const user = await User.findById(req.userId);
106 if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
107 const keys = getKeys(user);
108 const key = keys.find((k) => k._id === keyId);
109 if (!key) return sendError(res, 404, ERR.NODE_NOT_FOUND, "API key not found");
110 key.revoked = true;
111 await batchSetUserMeta(req.userId, "apiKeys", keys);
112
113 return sendOk(res, { message: "API key revoked" });
114 } catch (err) {
115 log.error("Api Keys", "[deleteApiKey]", err);
116 return sendError(res, 500, ERR.INTERNAL, "Failed to revoke API key");
117 }
118};
119
120const failedAttempts = new Map();
121const FAIL_WINDOW_MS = 5 * 60 * 1000;
122const MAX_FAILURES = 10;
123
124function getClientIp(req) {
125 return req.ip || req.connection?.remoteAddress || "unknown";
126}
127
128setInterval(() => {
129 const now = Date.now();
130 for (const [ip, entry] of failedAttempts) {
131 if (now - entry.start > FAIL_WINDOW_MS * 2) failedAttempts.delete(ip);
132 }
133}, 10 * 60 * 1000);
134
135export async function apiKeyAuthStrategy(req) {
136 const authHeader = req.headers.authorization;
137 const apiKey =
138 req.headers["x-api-key"] ||
139 (authHeader?.startsWith("ApiKey ") ? authHeader.slice(7).trim() : null);
140
141 if (!apiKey) return null;
142
143 const clientIp = getClientIp(req);
144 const entry = failedAttempts.get(clientIp);
145 if (entry && Date.now() - entry.start <= FAIL_WINDOW_MS && entry.count >= MAX_FAILURES) {
146 const err = new Error("Too many failed attempts. Try again later.");
147 err.status = 429;
148 throw err;
149 }
150
151 const prefix = apiKey.slice(0, 8);
152 const candidates = await User.find({
153 "metadata.apiKeys": {
154 $elemMatch: { keyPrefix: prefix, revoked: { $ne: true } },
155 },
156 });
157
158 for (const user of candidates) {
159 const keys = getKeys(user);
160 for (const key of keys) {
161 if (key.revoked) continue;
162 if (key.keyPrefix && key.keyPrefix !== prefix) continue;
163
164 const match = await bcrypt.compare(apiKey, key.keyHash);
165 if (!match) continue;
166
167 failedAttempts.delete(clientIp);
168
169 key.usageCount = (key.usageCount || 0) + 1;
170 key.lastUsedAt = new Date();
171 await batchSetUserMeta(String(user._id), "apiKeys", keys);
172
173 return { userId: user._id, username: user.username, extra: { apiKeyId: key._id } };
174 }
175 }
176
177 if (!entry || Date.now() - entry.start > FAIL_WINDOW_MS) {
178 failedAttempts.set(clientIp, { start: Date.now(), count: 1 });
179 } else {
180 entry.count += 1;
181 }
182
183 return null;
184}
1851import router from "./routes.js";
2import { apiKeyAuthStrategy } from "./core.js";
3import { getExtension } from "../loader.js";
4
5export async function init(core) {
6 core.auth.registerStrategy("apiKey", apiKeyAuthStrategy);
7
8 // Register quick link on user profile
9 try {
10 const treeos = getExtension("treeos-base");
11 treeos?.exports?.registerSlot?.("user-quick-links", "api-keys", ({ userId, queryString }) =>
12 `<li><a href="/api/v1/user/${userId}/api-keys${queryString}">API Keys</a></li>`,
13 { priority: 50 }
14 );
15 } catch {}
16
17 return { router };
18}
191export default {
2 name: "api-keys",
3 version: "1.0.3",
4 builtFor: "TreeOS",
5 description:
6 "Every interaction with TreeOS normally goes through a browser session or a WebSocket " +
7 "connection authenticated by JWT. That works for humans sitting at a keyboard. It does not " +
8 "work for scripts, CI pipelines, external services, or any programmatic client that needs " +
9 "to hit the tree API without logging in through a browser. API keys solve this. " +
10 "\n\n" +
11 "Each user can create up to ten named API keys. A key is a 256-bit random token hashed " +
12 "with bcrypt before storage. The raw key is shown exactly once at creation time. After " +
13 "that, only the hash and an eight-character prefix exist in the database. Keys live in " +
14 "user metadata under the apiKeys namespace. No separate model. No extra collection. " +
15 "\n\n" +
16 "Authentication works by registering a custom auth strategy with the kernel's auth system. " +
17 "Any request with an X-Api-Key header or an Authorization: ApiKey header is intercepted " +
18 "before the normal JWT check. The prefix narrows the candidate set to avoid comparing " +
19 "every key in the database. Each candidate hash is compared with bcrypt. On match, the " +
20 "request proceeds as that user. Usage count and last-used timestamp update on every " +
21 "successful authentication. " +
22 "\n\n" +
23 "Brute force protection is built in. Failed attempts are tracked per client IP with a " +
24 "sliding five-minute window. After ten failures, the IP is locked out until the window " +
25 "expires. The tracking map is pruned on a ten-minute interval to prevent memory growth. " +
26 "\n\n" +
27 "Keys can be revoked individually or in bulk. Revoking sets a flag rather than deleting " +
28 "the record, so the audit trail of which keys existed and when they were used is preserved. " +
29 "If html-rendering is installed, the extension serves a full management UI for creating, " +
30 "listing, and revoking keys directly in the browser.",
31
32 needs: {
33 services: ["auth"],
34 models: ["User"],
35 },
36
37 optional: {
38 extensions: ["html-rendering", "treeos-base"],
39 },
40
41 provides: {
42 models: {},
43 routes: "./routes.js",
44 tools: false,
45 jobs: false,
46 orchestrator: false,
47 energyActions: {},
48 sessionTypes: {},
49 authStrategies: true,
50 },
51};
521import { page } from "../../html-rendering/html/layout.js";
2import { esc } from "../../html-rendering/html/utils.js";
3
4export function renderApiKeyCreated({ userId, safeName, rawKey, token }) {
5 const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
6
7 const css = `
8.container { max-width: 600px; margin: 0 auto; position: relative; z-index: 1; }
9
10.card {
11 position: relative;
12 background: rgba(115,111,230,var(--glass-alpha));
13 backdrop-filter: blur(22px) saturate(140%);
14 -webkit-backdrop-filter: blur(22px) saturate(140%);
15 border-radius: 20px; padding: 40px;
16 box-shadow: 0 20px 60px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.25);
17 border: 1px solid rgba(255,255,255,0.28);
18 color: white; animation: fadeInUp 0.6s ease-out 0.1s both;
19}
20.card-title {
21 font-size: 22px; font-weight: 700; margin-bottom: 6px;
22 letter-spacing: -0.3px;
23}
24.card-name {
25 font-size: 14px; color: rgba(255,255,255,0.6); margin-bottom: 20px;
26}
27.warning {
28 display: flex; align-items: center; gap: 10px;
29 padding: 12px 16px; margin-bottom: 20px;
30 background: rgba(255,179,71,0.15); border: 1px solid rgba(255,179,71,0.3);
31 border-radius: 10px; font-size: 13px; font-weight: 500;
32 color: rgba(255,220,150,0.95); line-height: 1.5;
33}
34.key-block {
35 position: relative;
36 background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.15);
37 border-radius: 10px; padding: 16px 60px 16px 16px;
38 font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
39 font-size: 14px; color: rgba(255,255,255,0.95);
40 word-break: break-all; line-height: 1.6;
41 margin-bottom: 24px;
42}
43.copy-btn {
44 position: absolute; top: 10px; right: 10px;
45 background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25);
46 border-radius: 8px; padding: 8px 14px;
47 color: white; font-size: 12px; font-weight: 600;
48 cursor: pointer; transition: all 0.2s;
49 backdrop-filter: blur(10px);
50}
51.copy-btn:hover {
52 background: rgba(255,255,255,0.25); transform: translateY(-1px);
53}
54.copy-btn.copied {
55 background: rgba(72,187,120,0.3); border-color: rgba(72,187,120,0.4);
56}
57@media (max-width: 640px) {
58 body { padding: 16px; }
59 .card { padding: 28px 20px; }
60
61}`;
62
63 const body = `
64 <div class="container">
65 <div class="back-nav">
66 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link"><- Back to Profile</a>
67 <a href="/api/v1/user/${userId}/api-keys${tokenQS}" class="back-link">API Keys</a>
68 </div>
69
70 <div class="card">
71 <div class="card-title">API Key Created</div>
72 <div class="card-name">${esc(safeName)}</div>
73
74 <div class="warning">
75 This key will only be shown once. Copy it now and store it securely.
76 </div>
77
78 <div class="key-block" id="keyBlock">
79 ${esc(rawKey)}
80 <button class="copy-btn" id="copyBtn" onclick="copyKey()">\uD83D\uDCCB</button>
81 </div>
82 </div>
83 </div>`;
84
85 const js = `
86 function copyKey() {
87 var block = document.getElementById("keyBlock");
88 var btn = document.getElementById("copyBtn");
89 var key = block.textContent.replace(btn.textContent, "").trim();
90 navigator.clipboard.writeText(key).then(function() {
91 var btn = document.getElementById("copyBtn");
92 btn.textContent = "\u2705";
93 btn.classList.add("copied");
94 setTimeout(function() {
95 btn.textContent = "\uD83D\uDCCB";
96 btn.classList.remove("copied");
97 }, 2000);
98 });
99 }`;
100
101 return page({
102 title: `API Key Created`,
103 css,
104 body,
105 js,
106 });
107}
108
109export function renderApiKeysList({ userId, user, apiKeys, token, errorParam }) {
110 const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
111
112 const css = `
113.header-subtitle {
114 margin-bottom: 0;
115}
116
117
118@keyframes waterDrift {
119 0% { transform: translateY(-1px); }
120 100% { transform: translateY(1px); }
121}
122
123/* Create Form Card */
124.create-card {
125 position: relative;
126 overflow: hidden;
127 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
128 backdrop-filter: blur(22px) saturate(140%);
129 -webkit-backdrop-filter: blur(22px) saturate(140%);
130 border-radius: 16px;
131 padding: 24px;
132 margin-bottom: 24px;
133 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
134 inset 0 1px 0 rgba(255, 255, 255, 0.25);
135 border: 1px solid rgba(255, 255, 255, 0.28);
136 color: white;
137}
138
139.create-card::before {
140 content: "";
141 position: absolute;
142 inset: -40%;
143 background: radial-gradient(
144 120% 60% at 0% 0%,
145 rgba(255, 255, 255, 0.35),
146 transparent 60%
147 );
148 opacity: 0;
149 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
150 pointer-events: none;
151}
152
153.create-card:hover::before {
154 opacity: 1;
155 transform: translateX(30%) translateY(10%);
156}
157
158.create-form {
159 display: flex;
160 gap: 10px;
161 flex-wrap: wrap;
162 margin-bottom: 12px;
163}
164
165.create-form input {
166 flex: 1;
167 min-width: 200px;
168 padding: 12px 16px;
169 font-size: 15px;
170 border-radius: 12px;
171 border: 1px solid rgba(255, 255, 255, 0.25);
172 background: rgba(255, 255, 255, 0.15);
173 backdrop-filter: blur(10px);
174 -webkit-backdrop-filter: blur(10px);
175 font-family: inherit;
176 color: white;
177 font-weight: 500;
178 transition: all 0.3s;
179 box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
180}
181
182.create-form input::placeholder {
183 color: rgba(255, 255, 255, 0.5);
184}
185
186.create-form input:focus {
187 outline: none;
188 border-color: rgba(255, 255, 255, 0.4);
189 background: rgba(255, 255, 255, 0.2);
190 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
191 inset 0 1px 0 rgba(255, 255, 255, 0.2);
192}
193
194.create-form button {
195 position: relative;
196 overflow: hidden;
197 padding: 12px 24px;
198 font-size: 15px;
199 font-weight: 600;
200 border-radius: 980px;
201 border: 1px solid rgba(255, 255, 255, 0.3);
202 background: rgba(255, 255, 255, 0.3);
203 backdrop-filter: blur(10px);
204 color: white;
205 cursor: pointer;
206 transition: all 0.3s;
207 font-family: inherit;
208 white-space: nowrap;
209 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
210}
211
212.create-form button::before {
213 content: "";
214 position: absolute;
215 inset: -40%;
216 background: radial-gradient(
217 120% 60% at 0% 0%,
218 rgba(255, 255, 255, 0.35),
219 transparent 60%
220 );
221 opacity: 0;
222 transform: translateX(-30%) translateY(-10%);
223 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
224 pointer-events: none;
225}
226
227.create-form button:hover {
228 background: rgba(255, 255, 255, 0.4);
229 transform: translateY(-2px);
230 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
231}
232
233.create-form button:hover::before {
234 opacity: 1;
235 transform: translateX(30%) translateY(10%);
236}
237
238.create-hint {
239 font-size: 13px;
240 color: rgba(255, 255, 255, 0.75);
241}
242
243/* API Keys List */
244.keys-list {
245 display: flex;
246 flex-direction: column;
247 gap: 16px;
248}
249
250.key-card {
251 position: relative;
252 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
253 backdrop-filter: blur(22px) saturate(140%);
254 -webkit-backdrop-filter: blur(22px) saturate(140%);
255 border-radius: 16px;
256 padding: 24px;
257 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
258 inset 0 1px 0 rgba(255, 255, 255, 0.25);
259 border: 1px solid rgba(255, 255, 255, 0.28);
260 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
261 color: white;
262 overflow: hidden;
263
264 /* Start hidden for lazy loading */
265 opacity: 0;
266 transform: translateY(30px);
267}
268
269/* Active keys get green glass tint */
270.key-card.active {
271 background: rgba(76, 175, 80, 0.2);
272 border-color: rgba(76, 175, 80, 0.35);
273}
274
275.key-card.active::after {
276 content: "";
277 position: absolute;
278 inset: 0;
279 background: radial-gradient(
280 circle at top right,
281 rgba(76, 175, 80, 0.15),
282 transparent 70%
283 );
284 pointer-events: none;
285}
286
287/* When item becomes visible */
288.key-card.visible {
289 animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
290}
291
292.key-card::before {
293 content: "";
294 position: absolute;
295 inset: -40%;
296 background: radial-gradient(
297 120% 60% at 0% 0%,
298 rgba(255, 255, 255, 0.35),
299 transparent 60%
300 );
301 opacity: 0;
302 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
303 pointer-events: none;
304}
305
306.key-card:hover {
307 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
308 transform: translateY(-2px);
309 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
310}
311
312.key-card.active:hover {
313 background: rgba(76, 175, 80, 0.28);
314 box-shadow: 0 12px 32px rgba(76, 175, 80, 0.15);
315}
316
317.key-card:hover::before {
318 opacity: 1;
319 transform: translateX(30%) translateY(10%);
320}
321
322.key-name {
323 font-size: 18px;
324 font-weight: 600;
325 color: white;
326 margin-bottom: 12px;
327 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
328}
329
330.key-meta {
331 display: flex;
332 flex-direction: column;
333 gap: 6px;
334 margin-bottom: 12px;
335}
336
337.meta-item {
338 font-size: 13px;
339 color: rgba(255, 255, 255, 0.85);
340 display: flex;
341 align-items: center;
342 gap: 8px;
343}
344
345.badge {
346 display: inline-flex;
347 align-items: center;
348 padding: 4px 12px;
349 font-size: 12px;
350 border-radius: 980px;
351 font-weight: 600;
352 border: 1px solid rgba(255, 255, 255, 0.3);
353}
354
355.badge.active {
356 background: rgba(76, 175, 80, 0.25);
357 color: white;
358 border-color: rgba(76, 175, 80, 0.4);
359}
360
361.badge.revoked {
362 background: rgba(239, 68, 68, 0.25);
363 color: white;
364 border-color: rgba(239, 68, 68, 0.4);
365}
366
367.key-actions {
368 margin-top: 16px;
369 padding-top: 16px;
370 border-top: 1px solid rgba(255, 255, 255, 0.15);
371}
372
373.revoke-button {
374 padding: 10px 20px;
375 font-size: 14px;
376 font-weight: 600;
377 border-radius: 980px;
378 border: 1px solid rgba(239, 68, 68, 0.4);
379 background: rgba(239, 68, 68, 0.25);
380 color: white;
381 cursor: pointer;
382 transition: all 0.3s;
383 font-family: inherit;
384}
385
386.revoke-button:hover {
387 background: rgba(239, 68, 68, 0.35);
388 border-color: rgba(239, 68, 68, 0.5);
389 transform: translateY(-1px);
390 box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
391}
392
393/* Responsive Design */
394@media (max-width: 640px) {
395 body {
396 padding: 16px;
397 }
398
399 .create-form {
400 flex-direction: column;
401 }
402
403 .create-form input {
404 width: 100%;
405 min-width: 0;
406 }
407
408 .create-form button {
409 width: 100%;
410 }
411
412 .key-card {
413 padding: 20px 16px;
414 }
415
416}`;
417
418 const body = `
419 <div class="container">
420 <!-- Back Navigation -->
421 <div class="back-nav">
422 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
423 \u2190 Back to Profile
424 </a>
425 </div>
426
427 <!-- Header -->
428 <div class="header">
429 <h1>API Keys</h1>
430 <div class="header-subtitle">
431 Manage programmatic access to your account
432 </div>
433 </div>
434
435 <!-- Create API Key -->
436 <div class="create-card">
437 <form class="create-form" method="POST" action="/api/v1/user/${
438 userId
439 }/api-keys?token=${encodeURIComponent(token)}&html">
440 <input type="text" name="name" placeholder="Key name (optional)" />
441 <button type="submit">Create Key</button>
442 </form>
443 <div class="create-hint">
444 You'll only see the key once after creation.
445 </div>
446 </div>
447
448 <!-- API Keys List -->
449 ${
450 apiKeys.length > 0
451 ? `
452 <div class="keys-list">
453 ${apiKeys
454 .map(
455 (k) => `
456 <div class="key-card${!k.revoked ? " active" : ""}">
457 <div class="key-name">${k.name || "Untitled Key"}</div>
458
459 <div class="key-meta">
460 <div class="meta-item">
461 Created ${new Date(k.createdAt).toLocaleDateString()}
462 </div>
463 <div class="meta-item">
464 Used ${k.usageCount} ${k.usageCount === 1 ? "time" : "times"}
465 </div>
466 <div class="meta-item">
467 <span class="badge ${k.revoked ? "revoked" : "active"}">
468 ${k.revoked ? "Revoked" : "Active"}
469 </span>
470 </div>
471 </div>
472
473 ${
474 !k.revoked
475 ? `
476 <div class="key-actions">
477 <button class="revoke-button" data-key-id="${k._id}">
478 Revoke Key
479 </button>
480 </div>
481 `
482 : ""
483 }
484 </div>
485 `,
486 )
487 .join("")}
488 </div>
489 `
490 : `
491 <div class="empty-state">
492 <div class="empty-state-icon">\uD83D\uDD11</div>
493 <div class="empty-state-text">No API keys yet</div>
494 <div class="empty-state-subtext">
495 Create one above to get started
496 </div>
497 </div>
498 `
499 }
500 </div>`;
501
502 const js = `
503 // Intersection Observer for lazy loading animations
504 const observerOptions = {
505 root: null,
506 rootMargin: '50px',
507 threshold: 0.1
508 };
509
510 const observer = new IntersectionObserver((entries) => {
511 entries.forEach((entry, index) => {
512 if (entry.isIntersecting) {
513 setTimeout(() => {
514 entry.target.classList.add('visible');
515 }, index * 50);
516 observer.unobserve(entry.target);
517 }
518 });
519 }, observerOptions);
520
521 // Observe all key cards
522 document.querySelectorAll('.key-card').forEach(card => {
523 observer.observe(card);
524 });
525
526 // Revoke button handler
527 document.addEventListener("click", async (e) => {
528 if (!e.target.classList.contains("revoke-button")) return;
529
530 const keyId = e.target.dataset.keyId;
531
532 if (!confirm("Revoke this API key? This cannot be undone.")) return;
533
534 const token = new URLSearchParams(window.location.search).get("token") || "";
535 const qs = token ? "?token=" + encodeURIComponent(token) : "";
536
537 try {
538 const res = await fetch(
539 "/api/v1/user/${userId}/api-keys/" + keyId + qs,
540 { method: "DELETE" }
541 );
542
543 const data = await res.json();
544 if (data.status === "error") throw new Error((data.error && data.error.message) || "Revoke failed");
545
546 location.reload();
547 } catch (err) {
548 alert("Failed to revoke API key");
549 }
550 });`;
551
552 return page({
553 title: `${user.username} \u2014 API Keys`,
554 css,
555 body,
556 js,
557 });
558}
5591import log from "../../seed/log.js";
2import express from "express";
3import User from "../../seed/models/user.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import { sendOk, sendError, ERR } from "../../seed/protocol.js";
6import {
7 createApiKey,
8 generateApiKey,
9 deleteApiKey,
10} from "./core.js";
11import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
12
13function getKeys(user) {
14 const raw = getUserMeta(user, "apiKeys");
15 return Array.isArray(raw) ? raw : [];
16}
17import { getExtension } from "../loader.js";
18function html() { return getExtension("html-rendering")?.exports || {}; }
19import { renderApiKeyCreated, renderApiKeysList } from "./pages/apiKeys.js";
20
21const router = express.Router();
22
23router.post("/user/:userId/api-keys", authenticate, async (req, res) => {
24 if (req.userId.toString() !== req.params.userId.toString()) {
25 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
26 }
27
28 const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
29 if (!wantHtml || !getExtension("html-rendering")) {
30 return createApiKey(req, res);
31 }
32
33 try {
34 const userId = req.userId;
35 const { name, revokeOld = false } = req.body;
36 const safeName = (name?.trim().slice(0, 64) || "API Key").replace(
37 /<[^>]*>/g,
38 "",
39 );
40
41 const user = await User.findById(userId);
42 if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
43
44 let keys = getKeys(user);
45
46 if (keys.filter((k) => !k.revoked).length >= 10) {
47 const token = req.query.token ?? "";
48 const qs = token ? `?token=${token}&html` : `?html`;
49 return res.redirect(`/api/v1/user/${userId}/api-keys${qs}&error=limit`);
50 }
51
52 if (revokeOld) {
53 keys = keys.map((k) => ({ ...k, revoked: true }));
54 }
55
56 const { rawKey, keyHash, keyPrefix } = await generateApiKey();
57 const crypto = await import("crypto");
58 keys = [...keys, { _id: crypto.randomUUID(), keyHash, keyPrefix, name: safeName, createdAt: new Date() }];
59 setUserMeta(user, "apiKeys", keys);
60 await user.save();
61
62 const token = req.query.token ?? "";
63
64 return res
65 .status(201)
66 .send(renderApiKeyCreated({ userId, safeName, rawKey, token }));
67 } catch (err) {
68 log.error("Api Keys", "API key create (html) error:", err);
69 return res.status(500).send("Failed to create API key");
70 }
71});
72
73router.get("/user/:userId/api-keys", authenticate, async (req, res) => {
74 try {
75 if (req.userId.toString() !== req.params.userId.toString()) {
76 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
77 }
78
79 const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
80 const userId = req.params.userId;
81
82 const user = await User.findById(req.userId)
83 .select("username metadata");
84 if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
85 const apiKeys = getKeys(user);
86
87 if (!wantHtml || !getExtension("html-rendering")) {
88 return sendOk(res, {
89 keys: apiKeys.map((k) => ({
90 id: k._id,
91 name: k.name,
92 createdAt: k.createdAt,
93 lastUsedAt: k.lastUsedAt,
94 usageCount: k.usageCount,
95 revoked: k.revoked,
96 })),
97 });
98 }
99
100 const token = req.query.token ?? "";
101 const errorParam = req.query.error || null;
102
103 return res.send(
104 renderApiKeysList({ userId, user, apiKeys, token, errorParam }),
105 );
106 } catch (err) {
107 log.error("Api Keys", "api keys page error:", err);
108 sendError(res, 500, ERR.INTERNAL, err.message);
109 }
110});
111
112router.delete(
113 "/user/:userId/api-keys/:keyId",
114 authenticate,
115 async (req, res) => {
116 if (req.userId.toString() !== req.params.userId.toString()) {
117 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
118 }
119 return deleteApiKey(req, res);
120 },
121);
122
123export default router;
124
treeos ext star api-keys
Post comments from the CLI: treeos ext comment api-keys "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...