@cybermem/cli 0.13.16 → 0.14.4

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.
@@ -61,15 +61,106 @@
61
61
  group: "{{ ansible_user }}"
62
62
  mode: "0755"
63
63
 
64
- - name: Write API Key Secret File
64
+ - name: Check if API key secret file exists
65
+ stat:
66
+ path: "{{ project_dir }}/secrets/om_api_key"
67
+ register: api_key_file
68
+
69
+ - name: Read existing API key if it exists
70
+ command: cat "{{ project_dir }}/secrets/om_api_key"
71
+ register: existing_key_content
72
+ when: api_key_file.stat.exists
73
+ changed_when: false
74
+ no_log: true
75
+ ignore_errors: true
76
+
77
+ - name: Determine if token is missing or placeholder
78
+ set_fact:
79
+ token_needs_generation: "{{ not api_key_file.stat.exists or (existing_key_content.stdout | default('') == 'sk-not-generated-yet') }}"
80
+
81
+ - name: Generate new API token if not provided and not exists (or is placeholder)
82
+ set_fact:
83
+ auto_generated_token: "sk-{{ lookup('ansible.builtin.password', '/dev/null length=32 chars=hexdigits') }}"
84
+ auto_generated_id: "{{ lookup('ansible.builtin.password', '/dev/null length=16 chars=hexdigits') }}"
85
+ auto_generated_name: "ansible-auto-generated"
86
+ no_log: true
87
+ when: (auth_token_value is not defined or auth_token_value == '' or auth_token_value == 'sk-not-generated-yet') and token_needs_generation
88
+
89
+ - name: Generate token hash for auto-generated token
90
+ shell: |
91
+ python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
92
+ args:
93
+ stdin: "{{ auto_generated_token }}"
94
+ register: auto_generated_hash_result
95
+ when: auto_generated_token is defined
96
+ changed_when: false
97
+ no_log: true
98
+
99
+ - name: Set auto-generated token hash
100
+ set_fact:
101
+ auto_generated_hash: "{{ auto_generated_hash_result.stdout }}"
102
+ when: auto_generated_hash_result is not skipped
103
+
104
+ - name: Use auto-generated token if available
105
+ set_fact:
106
+ final_token_value: "{{ (auth_token_value | default(auto_generated_token, true)) if (auth_token_value | default('', true) != 'sk-not-generated-yet') else auto_generated_token }}"
107
+ final_token_hash: "{{ auth_token_hash | default(auto_generated_hash, true) }}"
108
+ final_token_id: "{{ auth_token_id | default(auto_generated_id, true) }}"
109
+ final_token_name: "{{ auth_token_name | default(auto_generated_name, true) }}"
110
+ no_log: true
111
+ when: (auth_token_value is defined and auth_token_value != '') or auto_generated_token is defined
112
+
113
+ - name: Generate token hash when only token value is provided
114
+ shell: |
115
+ python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
116
+ args:
117
+ stdin: "{{ final_token_value }}"
118
+ register: computed_token_hash_result
119
+ when:
120
+ - final_token_value is defined and final_token_value != ""
121
+ - final_token_hash is not defined or final_token_hash == ""
122
+ changed_when: false
123
+ no_log: true
124
+
125
+ - name: Set computed token hash
126
+ set_fact:
127
+ final_token_hash: "{{ computed_token_hash_result.stdout }}"
128
+ when: computed_token_hash_result is not skipped
129
+ no_log: true
130
+
131
+ - name: Write API Key Secret File (only if no token exists or explicitly provided/rotated)
65
132
  copy:
66
- content: "{{ auth_token_value }}"
133
+ content: "{{ final_token_value }}"
67
134
  dest: "{{ project_dir }}/secrets/om_api_key"
68
135
  owner: "{{ ansible_user }}"
69
136
  group: "{{ ansible_user }}"
70
137
  mode: "0600"
71
- when: auth_token_value is defined
72
- ignore_errors: true
138
+ no_log: true
139
+ when: final_token_value is defined and (token_needs_generation or (auth_token_value is defined and auth_token_value != '') or (force_rotate_token | default(false)))
140
+
141
+ - name: Display auto-generated token info
142
+ debug:
143
+ msg: |
144
+ 🔐 AUTO-GENERATED SECURITY TOKEN:
145
+ Token: {{ auto_generated_token[:7] }}...{{ auto_generated_token[-4:] }}
146
+
147
+ Full token saved to: {{ project_dir }}/secrets/om_api_key
148
+ Retrieve with: cat {{ project_dir }}/secrets/om_api_key
149
+ Also visible in Dashboard Settings after deployment.
150
+ when:
151
+ - auto_generated_token is defined
152
+ - show_token_output | default(false)
153
+
154
+ - name: Display token retrieval instructions
155
+ debug:
156
+ msg: |
157
+ 🔐 SECURITY TOKEN generated.
158
+ Full token saved to: {{ project_dir }}/secrets/om_api_key
159
+ Retrieve with: cat {{ project_dir }}/secrets/om_api_key
160
+ Masked token visible in Dashboard Settings after deployment.
161
+ when:
162
+ - auto_generated_token is defined
163
+ - not (show_token_output | default(false))
73
164
 
74
165
  - name: Ensure data directory exists
75
166
  file:
@@ -178,19 +269,22 @@
178
269
  state: present
179
270
 
180
271
  - name: Inject Authentication Token
181
- shell: |
182
- sqlite3 {{ project_dir }}/data/openmemory.sqlite "CREATE TABLE IF NOT EXISTS access_keys (
183
- id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
184
- key_hash TEXT NOT NULL,
185
- name TEXT DEFAULT 'default',
186
- user_id TEXT DEFAULT 'default',
187
- created_at TEXT DEFAULT (datetime('now')),
188
- last_used_at TEXT,
189
- is_active INTEGER DEFAULT 1
190
- );"
191
- sqlite3 {{ project_dir }}/data/openmemory.sqlite "DELETE FROM access_keys WHERE name='{{ auth_token_name | default('ansible-generated') }}';"
192
- sqlite3 {{ project_dir }}/data/openmemory.sqlite "INSERT INTO access_keys (id, key_hash, name, user_id) VALUES ('{{ auth_token_id }}', '{{ auth_token_hash }}', '{{ auth_token_name | default('ansible-generated') }}', 'admin');"
193
- when: auth_token_hash is defined
272
+ shell: "sqlite3 {{ project_dir }}/data/openmemory.sqlite"
273
+ args:
274
+ stdin: |
275
+ CREATE TABLE IF NOT EXISTS access_keys (
276
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
277
+ key_hash TEXT NOT NULL,
278
+ name TEXT DEFAULT 'default',
279
+ user_id TEXT DEFAULT 'default',
280
+ created_at TEXT DEFAULT (datetime('now')),
281
+ last_used_at TEXT,
282
+ is_active INTEGER DEFAULT 1
283
+ );
284
+ DELETE FROM access_keys WHERE name='{{ final_token_name }}';
285
+ INSERT INTO access_keys (id, key_hash, name, user_id) VALUES ('{{ final_token_id }}', '{{ final_token_hash }}', '{{ final_token_name }}', 'admin');
286
+ when: final_token_hash is defined
287
+ no_log: true
194
288
  ignore_errors: true
195
289
 
196
290
  - name: Wait for Traefik (Proxy) to be ready
@@ -1,6 +1,7 @@
1
1
  FROM node:20-alpine
2
2
  WORKDIR /app
3
- RUN apk add --no-cache libc6-compat
3
+ # Install build dependencies for native modules (sqlite3)
4
+ RUN apk add --no-cache libc6-compat python3 make g++
4
5
  COPY package.json ./
5
6
  RUN npm install
6
7
  COPY server.js .
@@ -3,5 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "description": "Auth sidecar for CyberMem",
5
5
  "main": "server.js",
6
- "dependencies": {}
6
+ "dependencies": {
7
+ "sqlite3": "^5.1.7"
8
+ }
7
9
  }
@@ -5,7 +5,7 @@
5
5
  * 1. Bearer tokens (sk-xxx) against Docker Secret file (SSoT)
6
6
  * 2. Local requests bypass (localhost, *.local domains)
7
7
  *
8
- * NO EXTERNAL DEPENDENCIES - uses built-in crypto and fs.
8
+ * Dependencies: sqlite3 (for auto-token persistence)
9
9
  */
10
10
 
11
11
  const http = require("http");
@@ -13,12 +13,175 @@ const crypto = require("crypto");
13
13
  const path = require("path");
14
14
  const fs = require("fs");
15
15
  const SECRET_PATH = process.env.API_KEY_FILE || "/run/secrets/om_api_key";
16
+ const DB_PATH = process.env.OM_DB_PATH || "/data/openmemory.sqlite";
17
+ const FALLBACK_TOKEN_PATH = "/data/.cybermem_token";
16
18
  let cachedToken = null;
19
+ let isAutoGenerated = false;
17
20
 
18
21
  const PORT = process.env.PORT || 3001;
19
22
 
23
+ // Generate a new secure token
24
+ function generateToken() {
25
+ return `sk-${crypto.randomBytes(16).toString("hex")}`;
26
+ }
27
+
28
+ // Hash token using PBKDF2 (same algorithm as CLI)
29
+ function hashToken(token) {
30
+ return new Promise((resolve, reject) => {
31
+ const salt = crypto
32
+ .createHash("sha256")
33
+ .update("cybermem-salt-v1")
34
+ .digest("hex")
35
+ .slice(0, 16);
36
+ crypto.pbkdf2(token, salt, 100000, 64, "sha512", (err, key) => {
37
+ if (err) reject(err);
38
+ else resolve(key.toString("hex"));
39
+ });
40
+ });
41
+ }
42
+
43
+ // Store token in database
44
+ async function storeTokenInDatabase(token) {
45
+ // Check if sqlite3 module is available
46
+ let sqlite3;
47
+ try {
48
+ sqlite3 = require("sqlite3");
49
+ } catch (e) {
50
+ console.warn("sqlite3 module not available, skipping database storage");
51
+ return false;
52
+ }
53
+
54
+ const db = new sqlite3.Database(DB_PATH);
55
+
56
+ // Configure busy timeout to reduce SQLITE_BUSY errors on concurrent access
57
+ if (typeof db.configure === "function") {
58
+ db.configure("busyTimeout", 5000);
59
+ }
60
+
61
+ const tokenHash = await hashToken(token);
62
+ const tokenId = crypto.randomBytes(8).toString("hex");
63
+
64
+ return new Promise((resolve, reject) => {
65
+ db.serialize(() => {
66
+ const expectedColumns = [
67
+ "id",
68
+ "key_hash",
69
+ "name",
70
+ "user_id",
71
+ "created_at",
72
+ "last_used_at",
73
+ "is_active",
74
+ ];
75
+
76
+ const proceedWithSchema = () => {
77
+ // Create table if not exists (after optional drop)
78
+ db.run(
79
+ `CREATE TABLE IF NOT EXISTS access_keys (
80
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
81
+ key_hash TEXT NOT NULL,
82
+ name TEXT DEFAULT 'default',
83
+ user_id TEXT DEFAULT 'default',
84
+ created_at TEXT DEFAULT (datetime('now')),
85
+ last_used_at TEXT,
86
+ is_active INTEGER DEFAULT 1
87
+ )`,
88
+ (createErr) => {
89
+ if (createErr) {
90
+ console.error(
91
+ "Failed to ensure access_keys table exists:",
92
+ createErr.message,
93
+ );
94
+ db.close();
95
+ // Resolve false instead of rejecting to distinguish from fatal file persistence error
96
+ resolve(false);
97
+ return;
98
+ }
99
+
100
+ // Delete any existing auto-generated tokens
101
+ db.run(
102
+ "DELETE FROM access_keys WHERE name='auto-generated'",
103
+ (deleteErr) => {
104
+ if (deleteErr) {
105
+ console.warn(
106
+ "Warning cleaning old tokens:",
107
+ deleteErr.message,
108
+ );
109
+ }
110
+
111
+ // Insert new token
112
+ db.run(
113
+ "INSERT INTO access_keys (id, key_hash, name, user_id) VALUES (?, ?, 'auto-generated', 'admin')",
114
+ [tokenId, tokenHash],
115
+ (insertErr) => {
116
+ if (insertErr) {
117
+ console.error(
118
+ "Failed to store token in database:",
119
+ insertErr.message,
120
+ );
121
+ db.close();
122
+ // Resolve false instead of rejecting to distinguish from fatal file persistence error
123
+ resolve(false);
124
+ } else {
125
+ console.log("✅ Auto-generated token stored in database");
126
+ db.close();
127
+ resolve(true);
128
+ }
129
+ },
130
+ );
131
+ },
132
+ );
133
+ },
134
+ );
135
+ };
136
+
137
+ // Validate existing access_keys schema and drop if malformed
138
+ db.all("PRAGMA table_info(access_keys)", (pragmaErr, columns) => {
139
+ if (pragmaErr) {
140
+ console.warn(
141
+ "Unable to inspect access_keys schema, proceeding with creation:",
142
+ pragmaErr.message,
143
+ );
144
+ proceedWithSchema();
145
+ return;
146
+ }
147
+
148
+ let needsDrop = false;
149
+ if (Array.isArray(columns) && columns.length > 0) {
150
+ const existingColumns = columns.map((c) => c.name);
151
+ const hasAllExpected = expectedColumns.every((col) =>
152
+ existingColumns.includes(col),
153
+ );
154
+ const sameLength = existingColumns.length === expectedColumns.length;
155
+ if (!hasAllExpected || !sameLength) {
156
+ needsDrop = true;
157
+ }
158
+ }
159
+
160
+ if (!needsDrop) {
161
+ proceedWithSchema();
162
+ return;
163
+ }
164
+
165
+ console.log("⚠️ Malformed access_keys schema detected, recreating...");
166
+ db.run("DROP TABLE IF EXISTS access_keys", (dropErr) => {
167
+ if (dropErr) {
168
+ console.error(
169
+ "Failed to drop malformed access_keys table:",
170
+ dropErr.message,
171
+ );
172
+ db.close();
173
+ resolve(false);
174
+ return;
175
+ }
176
+ proceedWithSchema();
177
+ });
178
+ });
179
+ });
180
+ });
181
+ }
182
+
20
183
  // Load token from secret file once at startup (SSoT)
21
- function loadSecret() {
184
+ async function loadSecret() {
22
185
  try {
23
186
  // 1. Check environment variable first (Direct)
24
187
  if (process.env.OM_API_KEY) {
@@ -34,30 +197,103 @@ function loadSecret() {
34
197
  // Check if it's a shell-formatted env file (OM_API_KEY=sk-...)
35
198
  const envMatch = content.match(/OM_API_KEY=["']?(sk-[a-zA-Z0-9]+)["']?/);
36
199
  if (envMatch) {
37
- cachedToken = envMatch[1];
38
- console.log(`SSoT Token extracted from env file: ${SECRET_PATH}`);
200
+ const extractedToken = envMatch[1];
201
+ // Reject known insecure placeholder
202
+ if (extractedToken === "sk-not-generated-yet") {
203
+ console.warn(
204
+ `⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
205
+ );
206
+ } else {
207
+ cachedToken = extractedToken;
208
+ console.log(`SSoT Token extracted from env file: ${SECRET_PATH}`);
209
+ }
210
+ } else if (content && content.startsWith("sk-")) {
211
+ // Reject known insecure placeholder
212
+ if (content === "sk-not-generated-yet") {
213
+ console.warn(
214
+ `⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
215
+ );
216
+ } else {
217
+ // Assume raw token file
218
+ cachedToken = content;
219
+ console.log(`SSoT Token loaded from raw file: ${SECRET_PATH}`);
220
+ }
221
+ } else if (content) {
222
+ console.warn(
223
+ `SECRET WARNING: ${SECRET_PATH} exists but doesn't contain a valid token format`,
224
+ );
225
+ }
226
+ }
227
+
228
+ // 3. Check fallback token file (auto-generated location)
229
+ if (!cachedToken && fs.existsSync(FALLBACK_TOKEN_PATH)) {
230
+ const fallbackContent = fs
231
+ .readFileSync(FALLBACK_TOKEN_PATH, "utf8")
232
+ .trim();
233
+ if (fallbackContent.startsWith("sk-")) {
234
+ cachedToken = fallbackContent;
235
+ console.log(`SSoT Token loaded from fallback: ${FALLBACK_TOKEN_PATH}`);
236
+ isAutoGenerated = true;
237
+ return;
39
238
  } else {
40
- // Assume raw token file
41
- cachedToken = content;
42
- console.log(`SSoT Token loaded from raw file: ${SECRET_PATH}`);
239
+ console.warn(
240
+ `SECRET WARNING: ${FALLBACK_TOKEN_PATH} exists but doesn't contain a valid token format (sk-*)`,
241
+ );
43
242
  }
44
- } else {
45
- console.warn(
46
- `SECRET WARNING: ${SECRET_PATH} not found. Remote auth will fail.`,
47
- );
243
+ }
244
+
245
+ // 4. Auto-generate if no token found anywhere
246
+ if (!cachedToken) {
247
+ console.warn("⚠️ NO TOKEN FOUND - Auto-generating security token...");
248
+ cachedToken = generateToken();
249
+ isAutoGenerated = true;
250
+
251
+ // Try to persist to fallback location
252
+ try {
253
+ fs.writeFileSync(FALLBACK_TOKEN_PATH, cachedToken, {
254
+ mode: 0o600,
255
+ });
256
+ console.log(`✅ Auto-generated token saved to ${FALLBACK_TOKEN_PATH}`);
257
+
258
+ // Also try to store in database
259
+ const dbSuccess = await storeTokenInDatabase(cachedToken);
260
+ if (!dbSuccess) {
261
+ console.warn("⚠️ Token saved to file but database storage failed");
262
+ console.warn(
263
+ " You may need to manually add the token to the database",
264
+ );
265
+ }
266
+ } catch (err) {
267
+ console.error("Failed to persist auto-generated token:", err.message);
268
+ console.error(
269
+ "⚠️ Token is in-memory only and will be lost on restart!",
270
+ );
271
+ }
272
+ const masked =
273
+ cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4);
274
+
275
+ console.log("\n" + "=".repeat(70));
276
+ console.log("🔐 AUTO-GENERATED SECURITY TOKEN:");
277
+ console.log(` ${masked}`);
278
+ console.log("");
279
+ console.log("Token saved to: " + FALLBACK_TOKEN_PATH);
280
+ console.log("Retrieve the full token from:");
281
+ console.log(` cat ${FALLBACK_TOKEN_PATH}`);
282
+ console.log("=".repeat(70) + "\n");
48
283
  }
49
284
  } catch (err) {
50
285
  console.error("SECRET LOAD ERROR:", err.message);
51
286
  }
52
287
  }
53
288
 
54
- loadSecret();
289
+ // Initialization promise to avoid race conditions
290
+ const initPromise = loadSecret();
55
291
 
56
292
  // Verify token against SSoT (file-based)
57
293
  async function verifyToken(token) {
58
294
  if (!cachedToken) {
59
295
  // Try one more time if not loaded (e.g. race condition/debug)
60
- loadSecret();
296
+ await loadSecret();
61
297
  }
62
298
 
63
299
  if (cachedToken && token === cachedToken) {
@@ -76,11 +312,6 @@ function isLocalRequest(req) {
76
312
  forwarded?.split(",")[0]?.trim() || realIp || req.socket.remoteAddress;
77
313
 
78
314
  // 1. First reject Tailscale/Remote requests (.ts.net, 100.x.x.x)
79
- // CRITICAL: Tailscale requests (via Funnel) must NEVER be treated as local.
80
- // If host contains .ts.net, it's external.
81
- // NOTE: Legacy CYBERMEM_TAILSCALE env-flag logic was removed on purpose.
82
- // We now auto-detect Tailscale by host/IP (".ts.net" / "100.x.x.x"),
83
- // so .local bypass works regardless of CYBERMEM_TAILSCALE being set.
84
315
  if (
85
316
  host.includes(".ts.net") ||
86
317
  (typeof ip === "string" && ip.startsWith("100."))
@@ -94,9 +325,6 @@ function isLocalRequest(req) {
94
325
  return true;
95
326
  }
96
327
 
97
- // Host-based check REMOVED for security (CVE-2026-001)
98
- // We only trust loopback IP if not on Tailscale.
99
-
100
328
  // Allow localhost bypass ONLY for local Dev environment (Docker Desktop)
101
329
  const isDev = process.env.CYBERMEM_INSTANCE === "local";
102
330
  if (isDev && (host.startsWith("localhost") || host.startsWith("127.0.0.1"))) {
@@ -112,12 +340,82 @@ function isLocalRequest(req) {
112
340
  return isLocalIp;
113
341
  }
114
342
 
343
+ // Strictly localhost for sensitive metadata endpoints
344
+ function isStrictlyLocalRequest(req) {
345
+ const ip = req.socket.remoteAddress;
346
+
347
+ // 1. Direct loopback check
348
+ const isLoopback =
349
+ ip === "127.0.0.1" ||
350
+ ip === "::1" ||
351
+ ip === "::ffff:127.0.0.1" ||
352
+ ip === "localhost";
353
+
354
+ if (isLoopback) return true;
355
+
356
+ // 2. Docker bridge check (e.g. 172.17.0.1 or 172.18.x.x)
357
+ // This allows host-to-container calls for diagnostics
358
+ if (typeof ip === "string" && ip.startsWith("172.")) {
359
+ const parts = ip.split(".");
360
+ const second = parseInt(parts[1], 10);
361
+ if (second >= 16 && second <= 31) {
362
+ return true;
363
+ }
364
+ }
365
+
366
+ return false;
367
+ }
368
+
115
369
  // ForwardAuth handler
116
370
  const server = http.createServer(async (req, res) => {
371
+ // Wait for secret to be loaded before processing any requests
372
+ await initPromise;
117
373
  // Health check
118
374
  if (req.url === "/health") {
119
375
  res.writeHead(200, { "Content-Type": "application/json" });
120
- res.end(JSON.stringify({ status: "ok", mode: "token-auth" }));
376
+ res.end(
377
+ JSON.stringify({
378
+ status: "ok",
379
+ mode: "token-auth",
380
+ tokenSource: isAutoGenerated ? "auto-generated" : "configured",
381
+ }),
382
+ );
383
+ return;
384
+ }
385
+
386
+ // Token info endpoint (metadata only — never expose raw token over HTTP)
387
+ if (req.url === "/token-info") {
388
+ if (!isStrictlyLocalRequest(req)) {
389
+ res.writeHead(403, { "Content-Type": "application/json" });
390
+ res.end(JSON.stringify({ error: "Forbidden - localhost only" }));
391
+ return;
392
+ }
393
+
394
+ if (!isAutoGenerated) {
395
+ res.writeHead(404, { "Content-Type": "application/json" });
396
+ res.end(
397
+ JSON.stringify({
398
+ error: "Not Found",
399
+ message: "Token info only available for auto-generated tokens",
400
+ }),
401
+ );
402
+ return;
403
+ }
404
+
405
+ const masked = cachedToken
406
+ ? cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4)
407
+ : null;
408
+ res.writeHead(200, { "Content-Type": "application/json" });
409
+ res.end(
410
+ JSON.stringify({
411
+ tokenSource: isAutoGenerated ? "auto-generated" : "configured",
412
+ tokenPrefix: masked,
413
+ fallbackPath: FALLBACK_TOKEN_PATH,
414
+ message: isAutoGenerated
415
+ ? `Token was auto-generated. Read it from: cat ${FALLBACK_TOKEN_PATH}`
416
+ : "Token is configured from deployment.",
417
+ }),
418
+ );
121
419
  return;
122
420
  }
123
421
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  services:
7
7
  traefik:
8
- image: traefik:v3.0
8
+ image: traefik:v3.6
9
9
  # container_name removed for simultaneity
10
10
  command:
11
11
  - --entryPoints.web.address=:${TRAEFIK_PORT:-8626}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cybermem/cli",
3
- "version": "0.13.16",
3
+ "version": "0.14.4",
4
4
  "description": "CyberMem — Universal Long-Term Memory for AI Agents",
5
5
  "homepage": "https://cybermem.dev",
6
6
  "repository": {
@@ -61,15 +61,106 @@
61
61
  group: "{{ ansible_user }}"
62
62
  mode: "0755"
63
63
 
64
- - name: Write API Key Secret File
64
+ - name: Check if API key secret file exists
65
+ stat:
66
+ path: "{{ project_dir }}/secrets/om_api_key"
67
+ register: api_key_file
68
+
69
+ - name: Read existing API key if it exists
70
+ command: cat "{{ project_dir }}/secrets/om_api_key"
71
+ register: existing_key_content
72
+ when: api_key_file.stat.exists
73
+ changed_when: false
74
+ no_log: true
75
+ ignore_errors: true
76
+
77
+ - name: Determine if token is missing or placeholder
78
+ set_fact:
79
+ token_needs_generation: "{{ not api_key_file.stat.exists or (existing_key_content.stdout | default('') == 'sk-not-generated-yet') }}"
80
+
81
+ - name: Generate new API token if not provided and not exists (or is placeholder)
82
+ set_fact:
83
+ auto_generated_token: "sk-{{ lookup('ansible.builtin.password', '/dev/null length=32 chars=hexdigits') }}"
84
+ auto_generated_id: "{{ lookup('ansible.builtin.password', '/dev/null length=16 chars=hexdigits') }}"
85
+ auto_generated_name: "ansible-auto-generated"
86
+ no_log: true
87
+ when: (auth_token_value is not defined or auth_token_value == '' or auth_token_value == 'sk-not-generated-yet') and token_needs_generation
88
+
89
+ - name: Generate token hash for auto-generated token
90
+ shell: |
91
+ python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
92
+ args:
93
+ stdin: "{{ auto_generated_token }}"
94
+ register: auto_generated_hash_result
95
+ when: auto_generated_token is defined
96
+ changed_when: false
97
+ no_log: true
98
+
99
+ - name: Set auto-generated token hash
100
+ set_fact:
101
+ auto_generated_hash: "{{ auto_generated_hash_result.stdout }}"
102
+ when: auto_generated_hash_result is not skipped
103
+
104
+ - name: Use auto-generated token if available
105
+ set_fact:
106
+ final_token_value: "{{ (auth_token_value | default(auto_generated_token, true)) if (auth_token_value | default('', true) != 'sk-not-generated-yet') else auto_generated_token }}"
107
+ final_token_hash: "{{ auth_token_hash | default(auto_generated_hash, true) }}"
108
+ final_token_id: "{{ auth_token_id | default(auto_generated_id, true) }}"
109
+ final_token_name: "{{ auth_token_name | default(auto_generated_name, true) }}"
110
+ no_log: true
111
+ when: (auth_token_value is defined and auth_token_value != '') or auto_generated_token is defined
112
+
113
+ - name: Generate token hash when only token value is provided
114
+ shell: |
115
+ python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
116
+ args:
117
+ stdin: "{{ final_token_value }}"
118
+ register: computed_token_hash_result
119
+ when:
120
+ - final_token_value is defined and final_token_value != ""
121
+ - final_token_hash is not defined or final_token_hash == ""
122
+ changed_when: false
123
+ no_log: true
124
+
125
+ - name: Set computed token hash
126
+ set_fact:
127
+ final_token_hash: "{{ computed_token_hash_result.stdout }}"
128
+ when: computed_token_hash_result is not skipped
129
+ no_log: true
130
+
131
+ - name: Write API Key Secret File (only if no token exists or explicitly provided/rotated)
65
132
  copy:
66
- content: "{{ auth_token_value }}"
133
+ content: "{{ final_token_value }}"
67
134
  dest: "{{ project_dir }}/secrets/om_api_key"
68
135
  owner: "{{ ansible_user }}"
69
136
  group: "{{ ansible_user }}"
70
137
  mode: "0600"
71
- when: auth_token_value is defined
72
- ignore_errors: true
138
+ no_log: true
139
+ when: final_token_value is defined and (token_needs_generation or (auth_token_value is defined and auth_token_value != '') or (force_rotate_token | default(false)))
140
+
141
+ - name: Display auto-generated token info
142
+ debug:
143
+ msg: |
144
+ 🔐 AUTO-GENERATED SECURITY TOKEN:
145
+ Token: {{ auto_generated_token[:7] }}...{{ auto_generated_token[-4:] }}
146
+
147
+ Full token saved to: {{ project_dir }}/secrets/om_api_key
148
+ Retrieve with: cat {{ project_dir }}/secrets/om_api_key
149
+ Also visible in Dashboard Settings after deployment.
150
+ when:
151
+ - auto_generated_token is defined
152
+ - show_token_output | default(false)
153
+
154
+ - name: Display token retrieval instructions
155
+ debug:
156
+ msg: |
157
+ 🔐 SECURITY TOKEN generated.
158
+ Full token saved to: {{ project_dir }}/secrets/om_api_key
159
+ Retrieve with: cat {{ project_dir }}/secrets/om_api_key
160
+ Masked token visible in Dashboard Settings after deployment.
161
+ when:
162
+ - auto_generated_token is defined
163
+ - not (show_token_output | default(false))
73
164
 
74
165
  - name: Ensure data directory exists
75
166
  file:
@@ -178,19 +269,22 @@
178
269
  state: present
179
270
 
180
271
  - name: Inject Authentication Token
181
- shell: |
182
- sqlite3 {{ project_dir }}/data/openmemory.sqlite "CREATE TABLE IF NOT EXISTS access_keys (
183
- id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
184
- key_hash TEXT NOT NULL,
185
- name TEXT DEFAULT 'default',
186
- user_id TEXT DEFAULT 'default',
187
- created_at TEXT DEFAULT (datetime('now')),
188
- last_used_at TEXT,
189
- is_active INTEGER DEFAULT 1
190
- );"
191
- sqlite3 {{ project_dir }}/data/openmemory.sqlite "DELETE FROM access_keys WHERE name='{{ auth_token_name | default('ansible-generated') }}';"
192
- sqlite3 {{ project_dir }}/data/openmemory.sqlite "INSERT INTO access_keys (id, key_hash, name, user_id) VALUES ('{{ auth_token_id }}', '{{ auth_token_hash }}', '{{ auth_token_name | default('ansible-generated') }}', 'admin');"
193
- when: auth_token_hash is defined
272
+ shell: "sqlite3 {{ project_dir }}/data/openmemory.sqlite"
273
+ args:
274
+ stdin: |
275
+ CREATE TABLE IF NOT EXISTS access_keys (
276
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
277
+ key_hash TEXT NOT NULL,
278
+ name TEXT DEFAULT 'default',
279
+ user_id TEXT DEFAULT 'default',
280
+ created_at TEXT DEFAULT (datetime('now')),
281
+ last_used_at TEXT,
282
+ is_active INTEGER DEFAULT 1
283
+ );
284
+ DELETE FROM access_keys WHERE name='{{ final_token_name }}';
285
+ INSERT INTO access_keys (id, key_hash, name, user_id) VALUES ('{{ final_token_id }}', '{{ final_token_hash }}', '{{ final_token_name }}', 'admin');
286
+ when: final_token_hash is defined
287
+ no_log: true
194
288
  ignore_errors: true
195
289
 
196
290
  - name: Wait for Traefik (Proxy) to be ready
@@ -1,6 +1,7 @@
1
1
  FROM node:20-alpine
2
2
  WORKDIR /app
3
- RUN apk add --no-cache libc6-compat
3
+ # Install build dependencies for native modules (sqlite3)
4
+ RUN apk add --no-cache libc6-compat python3 make g++
4
5
  COPY package.json ./
5
6
  RUN npm install
6
7
  COPY server.js .
@@ -3,5 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "description": "Auth sidecar for CyberMem",
5
5
  "main": "server.js",
6
- "dependencies": {}
6
+ "dependencies": {
7
+ "sqlite3": "^5.1.7"
8
+ }
7
9
  }
@@ -5,7 +5,7 @@
5
5
  * 1. Bearer tokens (sk-xxx) against Docker Secret file (SSoT)
6
6
  * 2. Local requests bypass (localhost, *.local domains)
7
7
  *
8
- * NO EXTERNAL DEPENDENCIES - uses built-in crypto and fs.
8
+ * Dependencies: sqlite3 (for auto-token persistence)
9
9
  */
10
10
 
11
11
  const http = require("http");
@@ -13,12 +13,175 @@ const crypto = require("crypto");
13
13
  const path = require("path");
14
14
  const fs = require("fs");
15
15
  const SECRET_PATH = process.env.API_KEY_FILE || "/run/secrets/om_api_key";
16
+ const DB_PATH = process.env.OM_DB_PATH || "/data/openmemory.sqlite";
17
+ const FALLBACK_TOKEN_PATH = "/data/.cybermem_token";
16
18
  let cachedToken = null;
19
+ let isAutoGenerated = false;
17
20
 
18
21
  const PORT = process.env.PORT || 3001;
19
22
 
23
+ // Generate a new secure token
24
+ function generateToken() {
25
+ return `sk-${crypto.randomBytes(16).toString("hex")}`;
26
+ }
27
+
28
+ // Hash token using PBKDF2 (same algorithm as CLI)
29
+ function hashToken(token) {
30
+ return new Promise((resolve, reject) => {
31
+ const salt = crypto
32
+ .createHash("sha256")
33
+ .update("cybermem-salt-v1")
34
+ .digest("hex")
35
+ .slice(0, 16);
36
+ crypto.pbkdf2(token, salt, 100000, 64, "sha512", (err, key) => {
37
+ if (err) reject(err);
38
+ else resolve(key.toString("hex"));
39
+ });
40
+ });
41
+ }
42
+
43
+ // Store token in database
44
+ async function storeTokenInDatabase(token) {
45
+ // Check if sqlite3 module is available
46
+ let sqlite3;
47
+ try {
48
+ sqlite3 = require("sqlite3");
49
+ } catch (e) {
50
+ console.warn("sqlite3 module not available, skipping database storage");
51
+ return false;
52
+ }
53
+
54
+ const db = new sqlite3.Database(DB_PATH);
55
+
56
+ // Configure busy timeout to reduce SQLITE_BUSY errors on concurrent access
57
+ if (typeof db.configure === "function") {
58
+ db.configure("busyTimeout", 5000);
59
+ }
60
+
61
+ const tokenHash = await hashToken(token);
62
+ const tokenId = crypto.randomBytes(8).toString("hex");
63
+
64
+ return new Promise((resolve, reject) => {
65
+ db.serialize(() => {
66
+ const expectedColumns = [
67
+ "id",
68
+ "key_hash",
69
+ "name",
70
+ "user_id",
71
+ "created_at",
72
+ "last_used_at",
73
+ "is_active",
74
+ ];
75
+
76
+ const proceedWithSchema = () => {
77
+ // Create table if not exists (after optional drop)
78
+ db.run(
79
+ `CREATE TABLE IF NOT EXISTS access_keys (
80
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
81
+ key_hash TEXT NOT NULL,
82
+ name TEXT DEFAULT 'default',
83
+ user_id TEXT DEFAULT 'default',
84
+ created_at TEXT DEFAULT (datetime('now')),
85
+ last_used_at TEXT,
86
+ is_active INTEGER DEFAULT 1
87
+ )`,
88
+ (createErr) => {
89
+ if (createErr) {
90
+ console.error(
91
+ "Failed to ensure access_keys table exists:",
92
+ createErr.message,
93
+ );
94
+ db.close();
95
+ // Resolve false instead of rejecting to distinguish from fatal file persistence error
96
+ resolve(false);
97
+ return;
98
+ }
99
+
100
+ // Delete any existing auto-generated tokens
101
+ db.run(
102
+ "DELETE FROM access_keys WHERE name='auto-generated'",
103
+ (deleteErr) => {
104
+ if (deleteErr) {
105
+ console.warn(
106
+ "Warning cleaning old tokens:",
107
+ deleteErr.message,
108
+ );
109
+ }
110
+
111
+ // Insert new token
112
+ db.run(
113
+ "INSERT INTO access_keys (id, key_hash, name, user_id) VALUES (?, ?, 'auto-generated', 'admin')",
114
+ [tokenId, tokenHash],
115
+ (insertErr) => {
116
+ if (insertErr) {
117
+ console.error(
118
+ "Failed to store token in database:",
119
+ insertErr.message,
120
+ );
121
+ db.close();
122
+ // Resolve false instead of rejecting to distinguish from fatal file persistence error
123
+ resolve(false);
124
+ } else {
125
+ console.log("✅ Auto-generated token stored in database");
126
+ db.close();
127
+ resolve(true);
128
+ }
129
+ },
130
+ );
131
+ },
132
+ );
133
+ },
134
+ );
135
+ };
136
+
137
+ // Validate existing access_keys schema and drop if malformed
138
+ db.all("PRAGMA table_info(access_keys)", (pragmaErr, columns) => {
139
+ if (pragmaErr) {
140
+ console.warn(
141
+ "Unable to inspect access_keys schema, proceeding with creation:",
142
+ pragmaErr.message,
143
+ );
144
+ proceedWithSchema();
145
+ return;
146
+ }
147
+
148
+ let needsDrop = false;
149
+ if (Array.isArray(columns) && columns.length > 0) {
150
+ const existingColumns = columns.map((c) => c.name);
151
+ const hasAllExpected = expectedColumns.every((col) =>
152
+ existingColumns.includes(col),
153
+ );
154
+ const sameLength = existingColumns.length === expectedColumns.length;
155
+ if (!hasAllExpected || !sameLength) {
156
+ needsDrop = true;
157
+ }
158
+ }
159
+
160
+ if (!needsDrop) {
161
+ proceedWithSchema();
162
+ return;
163
+ }
164
+
165
+ console.log("⚠️ Malformed access_keys schema detected, recreating...");
166
+ db.run("DROP TABLE IF EXISTS access_keys", (dropErr) => {
167
+ if (dropErr) {
168
+ console.error(
169
+ "Failed to drop malformed access_keys table:",
170
+ dropErr.message,
171
+ );
172
+ db.close();
173
+ resolve(false);
174
+ return;
175
+ }
176
+ proceedWithSchema();
177
+ });
178
+ });
179
+ });
180
+ });
181
+ }
182
+
20
183
  // Load token from secret file once at startup (SSoT)
21
- function loadSecret() {
184
+ async function loadSecret() {
22
185
  try {
23
186
  // 1. Check environment variable first (Direct)
24
187
  if (process.env.OM_API_KEY) {
@@ -34,30 +197,103 @@ function loadSecret() {
34
197
  // Check if it's a shell-formatted env file (OM_API_KEY=sk-...)
35
198
  const envMatch = content.match(/OM_API_KEY=["']?(sk-[a-zA-Z0-9]+)["']?/);
36
199
  if (envMatch) {
37
- cachedToken = envMatch[1];
38
- console.log(`SSoT Token extracted from env file: ${SECRET_PATH}`);
200
+ const extractedToken = envMatch[1];
201
+ // Reject known insecure placeholder
202
+ if (extractedToken === "sk-not-generated-yet") {
203
+ console.warn(
204
+ `⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
205
+ );
206
+ } else {
207
+ cachedToken = extractedToken;
208
+ console.log(`SSoT Token extracted from env file: ${SECRET_PATH}`);
209
+ }
210
+ } else if (content && content.startsWith("sk-")) {
211
+ // Reject known insecure placeholder
212
+ if (content === "sk-not-generated-yet") {
213
+ console.warn(
214
+ `⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
215
+ );
216
+ } else {
217
+ // Assume raw token file
218
+ cachedToken = content;
219
+ console.log(`SSoT Token loaded from raw file: ${SECRET_PATH}`);
220
+ }
221
+ } else if (content) {
222
+ console.warn(
223
+ `SECRET WARNING: ${SECRET_PATH} exists but doesn't contain a valid token format`,
224
+ );
225
+ }
226
+ }
227
+
228
+ // 3. Check fallback token file (auto-generated location)
229
+ if (!cachedToken && fs.existsSync(FALLBACK_TOKEN_PATH)) {
230
+ const fallbackContent = fs
231
+ .readFileSync(FALLBACK_TOKEN_PATH, "utf8")
232
+ .trim();
233
+ if (fallbackContent.startsWith("sk-")) {
234
+ cachedToken = fallbackContent;
235
+ console.log(`SSoT Token loaded from fallback: ${FALLBACK_TOKEN_PATH}`);
236
+ isAutoGenerated = true;
237
+ return;
39
238
  } else {
40
- // Assume raw token file
41
- cachedToken = content;
42
- console.log(`SSoT Token loaded from raw file: ${SECRET_PATH}`);
239
+ console.warn(
240
+ `SECRET WARNING: ${FALLBACK_TOKEN_PATH} exists but doesn't contain a valid token format (sk-*)`,
241
+ );
43
242
  }
44
- } else {
45
- console.warn(
46
- `SECRET WARNING: ${SECRET_PATH} not found. Remote auth will fail.`,
47
- );
243
+ }
244
+
245
+ // 4. Auto-generate if no token found anywhere
246
+ if (!cachedToken) {
247
+ console.warn("⚠️ NO TOKEN FOUND - Auto-generating security token...");
248
+ cachedToken = generateToken();
249
+ isAutoGenerated = true;
250
+
251
+ // Try to persist to fallback location
252
+ try {
253
+ fs.writeFileSync(FALLBACK_TOKEN_PATH, cachedToken, {
254
+ mode: 0o600,
255
+ });
256
+ console.log(`✅ Auto-generated token saved to ${FALLBACK_TOKEN_PATH}`);
257
+
258
+ // Also try to store in database
259
+ const dbSuccess = await storeTokenInDatabase(cachedToken);
260
+ if (!dbSuccess) {
261
+ console.warn("⚠️ Token saved to file but database storage failed");
262
+ console.warn(
263
+ " You may need to manually add the token to the database",
264
+ );
265
+ }
266
+ } catch (err) {
267
+ console.error("Failed to persist auto-generated token:", err.message);
268
+ console.error(
269
+ "⚠️ Token is in-memory only and will be lost on restart!",
270
+ );
271
+ }
272
+ const masked =
273
+ cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4);
274
+
275
+ console.log("\n" + "=".repeat(70));
276
+ console.log("🔐 AUTO-GENERATED SECURITY TOKEN:");
277
+ console.log(` ${masked}`);
278
+ console.log("");
279
+ console.log("Token saved to: " + FALLBACK_TOKEN_PATH);
280
+ console.log("Retrieve the full token from:");
281
+ console.log(` cat ${FALLBACK_TOKEN_PATH}`);
282
+ console.log("=".repeat(70) + "\n");
48
283
  }
49
284
  } catch (err) {
50
285
  console.error("SECRET LOAD ERROR:", err.message);
51
286
  }
52
287
  }
53
288
 
54
- loadSecret();
289
+ // Initialization promise to avoid race conditions
290
+ const initPromise = loadSecret();
55
291
 
56
292
  // Verify token against SSoT (file-based)
57
293
  async function verifyToken(token) {
58
294
  if (!cachedToken) {
59
295
  // Try one more time if not loaded (e.g. race condition/debug)
60
- loadSecret();
296
+ await loadSecret();
61
297
  }
62
298
 
63
299
  if (cachedToken && token === cachedToken) {
@@ -76,11 +312,6 @@ function isLocalRequest(req) {
76
312
  forwarded?.split(",")[0]?.trim() || realIp || req.socket.remoteAddress;
77
313
 
78
314
  // 1. First reject Tailscale/Remote requests (.ts.net, 100.x.x.x)
79
- // CRITICAL: Tailscale requests (via Funnel) must NEVER be treated as local.
80
- // If host contains .ts.net, it's external.
81
- // NOTE: Legacy CYBERMEM_TAILSCALE env-flag logic was removed on purpose.
82
- // We now auto-detect Tailscale by host/IP (".ts.net" / "100.x.x.x"),
83
- // so .local bypass works regardless of CYBERMEM_TAILSCALE being set.
84
315
  if (
85
316
  host.includes(".ts.net") ||
86
317
  (typeof ip === "string" && ip.startsWith("100."))
@@ -94,9 +325,6 @@ function isLocalRequest(req) {
94
325
  return true;
95
326
  }
96
327
 
97
- // Host-based check REMOVED for security (CVE-2026-001)
98
- // We only trust loopback IP if not on Tailscale.
99
-
100
328
  // Allow localhost bypass ONLY for local Dev environment (Docker Desktop)
101
329
  const isDev = process.env.CYBERMEM_INSTANCE === "local";
102
330
  if (isDev && (host.startsWith("localhost") || host.startsWith("127.0.0.1"))) {
@@ -112,12 +340,82 @@ function isLocalRequest(req) {
112
340
  return isLocalIp;
113
341
  }
114
342
 
343
+ // Strictly localhost for sensitive metadata endpoints
344
+ function isStrictlyLocalRequest(req) {
345
+ const ip = req.socket.remoteAddress;
346
+
347
+ // 1. Direct loopback check
348
+ const isLoopback =
349
+ ip === "127.0.0.1" ||
350
+ ip === "::1" ||
351
+ ip === "::ffff:127.0.0.1" ||
352
+ ip === "localhost";
353
+
354
+ if (isLoopback) return true;
355
+
356
+ // 2. Docker bridge check (e.g. 172.17.0.1 or 172.18.x.x)
357
+ // This allows host-to-container calls for diagnostics
358
+ if (typeof ip === "string" && ip.startsWith("172.")) {
359
+ const parts = ip.split(".");
360
+ const second = parseInt(parts[1], 10);
361
+ if (second >= 16 && second <= 31) {
362
+ return true;
363
+ }
364
+ }
365
+
366
+ return false;
367
+ }
368
+
115
369
  // ForwardAuth handler
116
370
  const server = http.createServer(async (req, res) => {
371
+ // Wait for secret to be loaded before processing any requests
372
+ await initPromise;
117
373
  // Health check
118
374
  if (req.url === "/health") {
119
375
  res.writeHead(200, { "Content-Type": "application/json" });
120
- res.end(JSON.stringify({ status: "ok", mode: "token-auth" }));
376
+ res.end(
377
+ JSON.stringify({
378
+ status: "ok",
379
+ mode: "token-auth",
380
+ tokenSource: isAutoGenerated ? "auto-generated" : "configured",
381
+ }),
382
+ );
383
+ return;
384
+ }
385
+
386
+ // Token info endpoint (metadata only — never expose raw token over HTTP)
387
+ if (req.url === "/token-info") {
388
+ if (!isStrictlyLocalRequest(req)) {
389
+ res.writeHead(403, { "Content-Type": "application/json" });
390
+ res.end(JSON.stringify({ error: "Forbidden - localhost only" }));
391
+ return;
392
+ }
393
+
394
+ if (!isAutoGenerated) {
395
+ res.writeHead(404, { "Content-Type": "application/json" });
396
+ res.end(
397
+ JSON.stringify({
398
+ error: "Not Found",
399
+ message: "Token info only available for auto-generated tokens",
400
+ }),
401
+ );
402
+ return;
403
+ }
404
+
405
+ const masked = cachedToken
406
+ ? cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4)
407
+ : null;
408
+ res.writeHead(200, { "Content-Type": "application/json" });
409
+ res.end(
410
+ JSON.stringify({
411
+ tokenSource: isAutoGenerated ? "auto-generated" : "configured",
412
+ tokenPrefix: masked,
413
+ fallbackPath: FALLBACK_TOKEN_PATH,
414
+ message: isAutoGenerated
415
+ ? `Token was auto-generated. Read it from: cat ${FALLBACK_TOKEN_PATH}`
416
+ : "Token is configured from deployment.",
417
+ }),
418
+ );
121
419
  return;
122
420
  }
123
421
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  services:
7
7
  traefik:
8
- image: traefik:v3.0
8
+ image: traefik:v3.6
9
9
  # container_name removed for simultaneity
10
10
  command:
11
11
  - --entryPoints.web.address=:${TRAEFIK_PORT:-8626}