EXTENSION for TreeOS
api-keys
Every interaction with TreeOS normally goes through a browser session or a WebSocket connection authenticated by JWT. That works for humans sitting at a keyboard. It does not work for scripts, CI pipelines, external services, or any programmatic client that needs to hit the tree API without logging in through a browser. API keys solve this. Each user can create up to ten named API keys. A key is a 256-bit random token hashed with bcrypt before storage. The raw key is shown exactly once at creation time. After that, only the hash and an eight-character prefix exist in the database. Keys live in user metadata under the apiKeys namespace. No separate model. No extra collection. Authentication works by registering a custom auth strategy with the kernel's auth system. Any request with an X-Api-Key header or an Authorization: ApiKey header is intercepted before the normal JWT check. The prefix narrows the candidate set to avoid comparing every key in the database. Each candidate hash is compared with bcrypt. On match, the request proceeds as that user. Usage count and last-used timestamp update on every successful authentication. Brute force protection is built in. Failed attempts are tracked per client IP with a sliding five-minute window. After ten failures, the IP is locked out until the window expires. The tracking map is pruned on a ten-minute interval to prevent memory growth. Keys can be revoked individually or in bulk. Revoking sets a flag rather than deleting the record, so the audit trail of which keys existed and when they were used is preserved. If html-rendering is installed, the extension serves a full management UI for creating, listing, and revoking keys directly in the browser.
v1.0.3 by TreeOS Site 0 downloads 5 files 939 lines 25.6 KB published 38d ago
treeos ext install api-keys
View changelog

Manifest

Provides

  • routes

Requires

  • services: auth
  • models: User

Optional

  • extensions: html-rendering, treeos-base
SHA256: 49a9764862f373c15fe08b56a088741281af19bf02b0134b8f1dc9a00fc62b79

Source Code

1import 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}
185
1import 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}
19
1export 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};
52
1import { 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">&lt;- 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}
559
1import 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

Versions

Version Published Downloads
1.0.3 38d ago 0
1.0.2 47d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star api-keys

Comments

Loading comments...

Post comments from the CLI: treeos ext comment api-keys "your comment"
Max 3 comments per extension. One star and one flag per user.