engram-sdk 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hosted.js CHANGED
@@ -4,8 +4,68 @@ import { mkdirSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { Vault } from './vault.js';
6
6
  import { GeminiEmbeddings } from './embeddings.js';
7
- import { AccountStore, PLAN_LIMITS } from './accounts.js';
8
- import { handleWebhook, createCheckoutSession, createCustomerPortalSession } from './stripe.js';
7
+ import { AccountStore, PLAN_LIMITS, isValidEmail } from './accounts.js';
8
+ import { handleWebhook, createCheckoutSession, createCustomerPortalSession, retrieveCheckoutSession, stripeRequest, priceToPlan } from './stripe.js';
9
+ import { generateMagicToken, verifyMagicToken, sendMagicLinkEmail } from './magic-link.js';
10
+ // ============================================================
11
+ // Security Constants
12
+ // ============================================================
13
+ const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB
14
+ const ALLOWED_ORIGINS = new Set([
15
+ 'https://engram.fyi',
16
+ 'https://www.engram.fyi',
17
+ ]);
18
+ class IPRateLimiter {
19
+ windows = new Map();
20
+ limit;
21
+ windowMs;
22
+ constructor(limit, windowMs) {
23
+ this.limit = limit;
24
+ this.windowMs = windowMs;
25
+ // Cleanup stale entries every 60s
26
+ setInterval(() => {
27
+ const now = Date.now();
28
+ for (const [key, entry] of this.windows) {
29
+ if (now - entry.windowStart > this.windowMs * 2) {
30
+ this.windows.delete(key);
31
+ }
32
+ }
33
+ }, 60_000);
34
+ }
35
+ /** Check AND increment. Used for signup/checkout where every attempt counts. */
36
+ check(ip) {
37
+ const now = Date.now();
38
+ const entry = this.windows.get(ip);
39
+ if (!entry || now - entry.windowStart > this.windowMs) {
40
+ this.windows.set(ip, { count: 1, windowStart: now });
41
+ return true;
42
+ }
43
+ entry.count++;
44
+ return entry.count <= this.limit;
45
+ }
46
+ /** Check WITHOUT incrementing. Used with record() for failure-only counting. */
47
+ isBlocked(ip) {
48
+ const now = Date.now();
49
+ const entry = this.windows.get(ip);
50
+ if (!entry || now - entry.windowStart > this.windowMs)
51
+ return false;
52
+ return entry.count >= this.limit;
53
+ }
54
+ /** Record a failure. Only call this on actual auth failures. */
55
+ record(ip) {
56
+ const now = Date.now();
57
+ const entry = this.windows.get(ip);
58
+ if (!entry || now - entry.windowStart > this.windowMs) {
59
+ this.windows.set(ip, { count: 1, windowStart: now });
60
+ }
61
+ else {
62
+ entry.count++;
63
+ }
64
+ }
65
+ }
66
+ const signupLimiter = new IPRateLimiter(5, 60_000); // 5 signups per IP per minute
67
+ const authFailLimiter = new IPRateLimiter(20, 60_000); // 20 failed auths per IP per minute
68
+ const checkoutLimiter = new IPRateLimiter(10, 60_000); // 10 checkouts per IP per minute
9
69
  // ============================================================
10
70
  // Engram Hosted — Multi-Tenant API Server
11
71
  // ============================================================
@@ -15,18 +75,21 @@ const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? '';
15
75
  const ADMIN_KEY = process.env.ADMIN_KEY ?? '';
16
76
  const VAULTS_DIR = process.env.VAULTS_DIR ?? './vaults';
17
77
  const ACCOUNTS_DB = process.env.ACCOUNTS_DB ?? './accounts.db';
78
+ const RESEND_API_KEY = process.env.RESEND_API_KEY ?? '';
18
79
  const vaultCache = new Map();
19
80
  const EVICT_MS = 30 * 60 * 1000;
20
81
  function getVault(accountId) {
21
- const cached = vaultCache.get(accountId);
82
+ // Route to org vault if account belongs to an org, otherwise personal vault
83
+ const vaultId = accountStore.getVaultId(accountId);
84
+ const cached = vaultCache.get(vaultId);
22
85
  if (cached) {
23
86
  cached.lastAccess = Date.now();
24
87
  return cached.vault;
25
88
  }
26
- const dbPath = path.join(VAULTS_DIR, `${accountId}.db`);
89
+ const dbPath = path.join(VAULTS_DIR, `${vaultId}.db`);
27
90
  const embedder = GEMINI_API_KEY ? new GeminiEmbeddings(GEMINI_API_KEY) : undefined;
28
- const vault = new Vault({ owner: accountId, dbPath }, embedder);
29
- vaultCache.set(accountId, { vault, lastAccess: Date.now() });
91
+ const vault = new Vault({ owner: vaultId, dbPath }, embedder);
92
+ vaultCache.set(vaultId, { vault, lastAccess: Date.now() });
30
93
  return vault;
31
94
  }
32
95
  // Evict idle vaults every 5 minutes
@@ -42,13 +105,26 @@ setInterval(async () => {
42
105
  // ============================================================
43
106
  // Helpers
44
107
  // ============================================================
45
- async function readBody(req) {
108
+ async function readBody(req, maxBytes = MAX_BODY_BYTES) {
46
109
  const chunks = [];
110
+ let totalBytes = 0;
47
111
  for await (const chunk of req) {
48
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
112
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
113
+ totalBytes += buf.length;
114
+ if (totalBytes > maxBytes) {
115
+ req.destroy();
116
+ throw new PayloadTooLargeError(`Request body exceeds ${Math.round(maxBytes / 1024)}KB limit`);
117
+ }
118
+ chunks.push(buf);
49
119
  }
50
120
  return Buffer.concat(chunks).toString('utf-8');
51
121
  }
122
+ class PayloadTooLargeError extends Error {
123
+ constructor(message) {
124
+ super(message);
125
+ this.name = 'PayloadTooLargeError';
126
+ }
127
+ }
52
128
  function json(res, status, data) {
53
129
  res.writeHead(status, { 'Content-Type': 'application/json' });
54
130
  res.end(JSON.stringify(data));
@@ -56,6 +132,21 @@ function json(res, status, data) {
56
132
  function errorResponse(res, status, message) {
57
133
  json(res, status, { error: message });
58
134
  }
135
+ function parseJSONSafe(text) {
136
+ try {
137
+ return JSON.parse(text);
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ function getClientIP(req) {
144
+ // Fly.io sets Fly-Client-IP; fall back to x-forwarded-for, then socket
145
+ return req.headers['fly-client-ip']
146
+ ?? req.headers['x-forwarded-for']?.split(',')[0]?.trim()
147
+ ?? req.socket.remoteAddress
148
+ ?? 'unknown';
149
+ }
59
150
  function rateLimitResponse(res, type, limit, used, resetAt) {
60
151
  res.writeHead(429, { 'Content-Type': 'application/json' });
61
152
  res.end(JSON.stringify({
@@ -90,7 +181,24 @@ route('POST', '/v1/memories', async (req, res, { account, vault }) => {
90
181
  const check = accountStore.checkLimit(account, 'memory');
91
182
  if (!check.allowed)
92
183
  return rateLimitResponse(res, 'memory', check.limit, check.used, check.resetAt);
93
- const body = JSON.parse(await readBody(req));
184
+ const body = parseJSONSafe(await readBody(req));
185
+ if (!body)
186
+ return errorResponse(res, 400, 'Invalid JSON');
187
+ // Reject local-only memories on hosted API
188
+ if (body.scope === 'local')
189
+ return errorResponse(res, 400, 'Cannot store local-scoped memories on hosted API. Use scope "hosted" or "both".');
190
+ // Stamp user attribution on every memory (critical for shared org vaults)
191
+ if (!body.source)
192
+ body.source = {};
193
+ if (!body.source.agentId)
194
+ body.source.agentId = account.email;
195
+ // Auto-add user as an entity so Engram tracks per-person context
196
+ if (!body.entities)
197
+ body.entities = [];
198
+ const userName = account.email.split('@')[0];
199
+ if (!body.entities.some((e) => e.toLowerCase() === userName.toLowerCase() || e.toLowerCase() === account.email.toLowerCase())) {
200
+ body.entities.push(userName);
201
+ }
94
202
  const memory = vault.remember(body);
95
203
  accountStore.trackUsage(account.id, 'memory');
96
204
  json(res, 201, memory);
@@ -117,7 +225,9 @@ route('POST', '/v1/memories/recall', async (req, res, { account, vault }) => {
117
225
  const check = accountStore.checkLimit(account, 'recall');
118
226
  if (!check.allowed)
119
227
  return rateLimitResponse(res, 'recall', check.limit, check.used, check.resetAt);
120
- const body = JSON.parse(await readBody(req));
228
+ const body = parseJSONSafe(await readBody(req));
229
+ if (!body)
230
+ return errorResponse(res, 400, 'Invalid JSON');
121
231
  const memories = await vault.recall(body);
122
232
  accountStore.trackUsage(account.id, 'recall');
123
233
  json(res, 200, { memories, count: memories.length });
@@ -125,6 +235,10 @@ route('POST', '/v1/memories/recall', async (req, res, { account, vault }) => {
125
235
  route('DELETE', '/v1/memories/:id', (req, res, { account, vault, params }) => {
126
236
  const url = new URL(req.url, `http://${req.headers.host}`);
127
237
  const hard = url.searchParams.get('hard') === 'true';
238
+ // Hard delete restricted to org admins (or solo accounts)
239
+ if (hard && account.orgId && account.role !== 'admin') {
240
+ return errorResponse(res, 403, 'Only organization admins can hard-delete memories. Omit ?hard=true for soft delete.');
241
+ }
128
242
  vault.forget(params.id, hard);
129
243
  accountStore.decrementMemories(account.id);
130
244
  json(res, 200, { deleted: params.id, hard });
@@ -179,60 +293,214 @@ publicRoute('POST', '/stripe/webhook', async (req, res) => {
179
293
  json(res, result.status, result.body);
180
294
  });
181
295
  publicRoute('POST', '/checkout', async (req, res) => {
182
- const body = JSON.parse(await readBody(req));
183
- if (!body.email)
184
- return errorResponse(res, 400, 'email is required');
296
+ const ip = getClientIP(req);
297
+ if (!checkoutLimiter.check(ip)) {
298
+ return errorResponse(res, 429, 'Too many checkout attempts. Try again in a minute.');
299
+ }
300
+ const body = parseJSONSafe(await readBody(req));
301
+ if (!body)
302
+ return errorResponse(res, 400, 'Invalid JSON');
303
+ if (body.email && !isValidEmail(body.email))
304
+ return errorResponse(res, 400, 'Invalid email format');
185
305
  if (!body.plan || !['developer', 'team', 'business'].includes(body.plan)) {
186
306
  return errorResponse(res, 400, 'plan must be developer, team, or business');
187
307
  }
188
308
  try {
189
- const result = await createCheckoutSession(body.email, body.plan);
309
+ const result = await createCheckoutSession(body.email ?? null, body.plan);
190
310
  json(res, 200, result);
191
311
  }
192
312
  catch (e) {
193
- errorResponse(res, 500, e.message);
313
+ errorResponse(res, 500, 'Checkout failed');
194
314
  }
195
315
  });
196
- publicRoute('POST', '/billing/portal', async (req, res) => {
197
- const body = JSON.parse(await readBody(req));
198
- if (!body.customerId)
199
- return errorResponse(res, 400, 'customerId is required');
316
+ route('POST', '/billing/portal', async (req, res, { account }) => {
317
+ // Authenticated: only the account owner can access their own billing portal
318
+ if (!account.stripeCustomerId) {
319
+ return errorResponse(res, 400, 'No billing account found. Subscribe to a paid plan first.');
320
+ }
200
321
  try {
201
- const result = await createCustomerPortalSession(body.customerId);
322
+ const result = await createCustomerPortalSession(account.stripeCustomerId);
202
323
  json(res, 200, result);
203
324
  }
204
325
  catch (e) {
205
- errorResponse(res, 500, e.message);
326
+ errorResponse(res, 500, 'Failed to create billing session');
206
327
  }
207
328
  });
208
329
  // ============================================================
209
330
  // Public Signup (free tier)
210
331
  // ============================================================
211
332
  publicRoute('POST', '/signup', async (req, res) => {
212
- const body = JSON.parse(await readBody(req));
333
+ const ip = getClientIP(req);
334
+ if (!signupLimiter.check(ip)) {
335
+ return errorResponse(res, 429, 'Too many signup attempts. Try again in a minute.');
336
+ }
337
+ const body = parseJSONSafe(await readBody(req));
338
+ if (!body)
339
+ return errorResponse(res, 400, 'Invalid JSON');
213
340
  if (!body.email)
214
341
  return errorResponse(res, 400, 'email is required');
342
+ if (!isValidEmail(body.email))
343
+ return errorResponse(res, 400, 'Invalid email format');
215
344
  try {
216
345
  const existing = accountStore.getAccountByEmail(body.email);
217
346
  if (existing) {
218
- return json(res, 200, { apiKey: existing.apiKey, plan: existing.plan, message: 'Account already exists' });
347
+ // Never leak the existing API key -- tell them to check their email
348
+ return json(res, 200, { message: 'If this email is registered, the API key was sent at signup. Contact support if you need a new key.' });
219
349
  }
220
350
  const account = accountStore.createAccount(body.email, 'free');
221
351
  json(res, 201, { apiKey: account.apiKey, plan: 'free', message: 'Account created' });
222
352
  }
223
353
  catch (e) {
224
- errorResponse(res, 500, e.message);
354
+ errorResponse(res, 500, 'Signup failed');
225
355
  }
226
356
  });
227
357
  // ============================================================
358
+ // Account Login (Magic Link Email)
359
+ // ============================================================
360
+ const accountLookupLimiter = new IPRateLimiter(5, 60_000); // 5 lookups per IP per minute
361
+ // Step 1: User enters email -> we send a magic link
362
+ publicRoute('POST', '/account/login', async (req, res) => {
363
+ const ip = getClientIP(req);
364
+ if (!accountLookupLimiter.check(ip)) {
365
+ return errorResponse(res, 429, 'Too many attempts. Try again in a minute.');
366
+ }
367
+ const body = parseJSONSafe(await readBody(req));
368
+ if (!body)
369
+ return errorResponse(res, 400, 'Invalid JSON');
370
+ if (!body.email)
371
+ return errorResponse(res, 400, 'email is required');
372
+ if (!isValidEmail(body.email))
373
+ return errorResponse(res, 400, 'Invalid email format');
374
+ const account = accountStore.getAccountByEmail(body.email);
375
+ // Always return success to avoid leaking whether an account exists
376
+ if (!account) {
377
+ return json(res, 200, { sent: true });
378
+ }
379
+ if (!RESEND_API_KEY) {
380
+ console.error('RESEND_API_KEY not configured -- cannot send magic link');
381
+ return json(res, 200, { sent: true });
382
+ }
383
+ const token = generateMagicToken(account.email, ADMIN_KEY);
384
+ const sent = await sendMagicLinkEmail(account.email, token, RESEND_API_KEY);
385
+ if (!sent) {
386
+ console.error(`Failed to send magic link to ${account.email}`);
387
+ }
388
+ json(res, 200, { sent: true });
389
+ });
390
+ // Step 2: User clicks magic link -> verify token, return account data
391
+ publicRoute('POST', '/account/verify', async (req, res) => {
392
+ const body = parseJSONSafe(await readBody(req));
393
+ if (!body)
394
+ return errorResponse(res, 400, 'Invalid JSON');
395
+ if (!body.token)
396
+ return errorResponse(res, 400, 'token is required');
397
+ const email = verifyMagicToken(body.token, ADMIN_KEY);
398
+ if (!email) {
399
+ return errorResponse(res, 401, 'Invalid or expired link. Please request a new one.');
400
+ }
401
+ const account = accountStore.getAccountByEmail(email);
402
+ if (!account) {
403
+ return errorResponse(res, 404, 'Account not found');
404
+ }
405
+ const limits = PLAN_LIMITS[account.plan];
406
+ // Generate Stripe billing portal URL if they have a subscription
407
+ let portalUrl;
408
+ if (account.stripeCustomerId) {
409
+ try {
410
+ const portal = await createCustomerPortalSession(account.stripeCustomerId);
411
+ portalUrl = portal.url;
412
+ }
413
+ catch { /* no portal if Stripe fails */ }
414
+ }
415
+ json(res, 200, {
416
+ email: account.email,
417
+ plan: account.plan,
418
+ apiKey: account.apiKey,
419
+ usage: {
420
+ memoriesStored: account.memoriesStored,
421
+ recallsThisMonth: account.recallsThisMonth,
422
+ consolidationsThisMonth: account.consolidationsThisMonth,
423
+ usageResetAt: account.usageResetAt,
424
+ },
425
+ limits,
426
+ portalUrl,
427
+ });
428
+ });
429
+ // Keep old endpoint as alias during transition
430
+ publicRoute('POST', '/account/lookup', async (req, res) => {
431
+ // Redirect to login flow
432
+ const body = parseJSONSafe(await readBody(req));
433
+ if (!body)
434
+ return errorResponse(res, 400, 'Invalid JSON');
435
+ json(res, 200, { message: 'This endpoint has moved. Use POST /account/login to receive a magic link.' });
436
+ });
437
+ // ============================================================
438
+ // Post-Checkout API Key Retrieval
439
+ // ============================================================
440
+ publicRoute('POST', '/checkout/complete', async (req, res) => {
441
+ const body = parseJSONSafe(await readBody(req));
442
+ if (!body)
443
+ return errorResponse(res, 400, 'Invalid JSON');
444
+ if (!body.sessionId)
445
+ return errorResponse(res, 400, 'sessionId is required');
446
+ // Look up the Stripe checkout session to get the customer email
447
+ const session = await retrieveCheckoutSession(body.sessionId);
448
+ if (!session) {
449
+ return errorResponse(res, 404, 'Checkout session not found or still processing');
450
+ }
451
+ // Try to find existing account (created by webhook)
452
+ let account = accountStore.getAccountByEmail(session.email);
453
+ // Fallback: if webhook hasn't fired yet, create the account now
454
+ // The webhook will find this account later and just update Stripe IDs
455
+ if (!account) {
456
+ try {
457
+ // Determine plan from subscription
458
+ let plan = 'developer';
459
+ if (session.subscriptionId) {
460
+ const sub = await stripeRequest(`/subscriptions/${session.subscriptionId}`, 'GET');
461
+ const priceId = sub.items?.data?.[0]?.price?.id;
462
+ if (priceId)
463
+ plan = priceToPlan(priceId) ?? 'developer';
464
+ }
465
+ account = accountStore.createAccount(session.email, plan);
466
+ accountStore.updateStripeIds(account.id, session.customerId, session.subscriptionId);
467
+ console.log(`Created account via checkout/complete fallback: ${session.email} (${plan})`);
468
+ }
469
+ catch (e) {
470
+ // If createAccount fails (e.g., race with webhook), try fetching again
471
+ account = accountStore.getAccountByEmail(session.email);
472
+ if (!account) {
473
+ return errorResponse(res, 500, 'Failed to create account. Please contact support.');
474
+ }
475
+ }
476
+ }
477
+ // Verify Stripe customer ID matches if set (prevents session ID guessing)
478
+ if (account.stripeCustomerId && account.stripeCustomerId !== session.customerId) {
479
+ return errorResponse(res, 403, 'Session does not match this account');
480
+ }
481
+ // Ensure Stripe IDs are set (in case webhook created account without them)
482
+ if (!account.stripeCustomerId) {
483
+ accountStore.updateStripeIds(account.id, session.customerId, session.subscriptionId);
484
+ }
485
+ json(res, 200, {
486
+ apiKey: account.apiKey,
487
+ plan: account.plan,
488
+ email: account.email,
489
+ });
490
+ });
491
+ // ============================================================
228
492
  // Admin Routes
229
493
  // ============================================================
230
494
  adminRoute('POST', '/admin/accounts', async (req, res) => {
231
- const body = JSON.parse(await readBody(req));
495
+ const body = parseJSONSafe(await readBody(req));
496
+ if (!body)
497
+ return errorResponse(res, 400, 'Invalid JSON');
232
498
  if (!body.email)
233
499
  return errorResponse(res, 400, 'email is required');
500
+ if (!isValidEmail(body.email))
501
+ return errorResponse(res, 400, 'Invalid email format');
234
502
  const plan = body.plan ?? 'free';
235
- if (!['free', 'developer', 'team', 'business', 'enterprise'].includes(plan))
503
+ if (!['free', 'developer', 'team', 'business', 'enterprise', 'enterprise_trial'].includes(plan))
236
504
  return errorResponse(res, 400, 'invalid plan');
237
505
  try {
238
506
  const account = accountStore.createAccount(body.email, plan);
@@ -253,6 +521,82 @@ adminRoute('GET', '/admin/accounts/:id', (req, res, params) => {
253
521
  return errorResponse(res, 404, 'account not found');
254
522
  json(res, 200, account);
255
523
  });
524
+ // ── Organization Admin Routes ──
525
+ adminRoute('POST', '/admin/orgs', async (req, res) => {
526
+ const body = parseJSONSafe(await readBody(req));
527
+ if (!body)
528
+ return errorResponse(res, 400, 'Invalid JSON');
529
+ if (!body.name)
530
+ return errorResponse(res, 400, 'name is required');
531
+ if (!body.adminEmail)
532
+ return errorResponse(res, 400, 'adminEmail is required');
533
+ if (!isValidEmail(body.adminEmail))
534
+ return errorResponse(res, 400, 'Invalid email format');
535
+ const plan = body.plan ?? 'enterprise';
536
+ if (!['enterprise', 'enterprise_trial'].includes(plan)) {
537
+ return errorResponse(res, 400, 'Org plan must be enterprise or enterprise_trial');
538
+ }
539
+ try {
540
+ const { org, owner } = accountStore.createOrg(body.name, body.adminEmail, plan, {
541
+ trialDays: body.trialDays ?? 60,
542
+ allowedDomain: body.allowedDomain ?? null,
543
+ });
544
+ json(res, 201, { org, owner });
545
+ }
546
+ catch (e) {
547
+ if (e.message?.includes('UNIQUE'))
548
+ return errorResponse(res, 409, 'Account with this email already exists');
549
+ throw e;
550
+ }
551
+ });
552
+ adminRoute('GET', '/admin/orgs', (req, res) => {
553
+ json(res, 200, accountStore.listOrgs());
554
+ });
555
+ adminRoute('GET', '/admin/orgs/:id', (req, res, params) => {
556
+ const org = accountStore.getOrgById(params.id);
557
+ if (!org)
558
+ return errorResponse(res, 404, 'Organization not found');
559
+ const members = accountStore.getOrgMembers(params.id);
560
+ json(res, 200, { org, members });
561
+ });
562
+ adminRoute('POST', '/admin/orgs/:id/members', async (req, res, params) => {
563
+ const body = parseJSONSafe(await readBody(req));
564
+ if (!body)
565
+ return errorResponse(res, 400, 'Invalid JSON');
566
+ if (!body.email)
567
+ return errorResponse(res, 400, 'email is required');
568
+ if (!isValidEmail(body.email))
569
+ return errorResponse(res, 400, 'Invalid email format');
570
+ try {
571
+ const member = accountStore.addOrgMember(params.id, body.email, body.role ?? 'member');
572
+ json(res, 201, member);
573
+ }
574
+ catch (e) {
575
+ if (e.message?.includes('domain'))
576
+ return errorResponse(res, 400, e.message);
577
+ if (e.message?.includes('UNIQUE'))
578
+ return errorResponse(res, 409, 'Account with this email already exists');
579
+ if (e.message?.includes('not found'))
580
+ return errorResponse(res, 404, e.message);
581
+ throw e;
582
+ }
583
+ });
584
+ adminRoute('POST', '/admin/orgs/:id/extend-trial', async (req, res, params) => {
585
+ const body = parseJSONSafe(await readBody(req));
586
+ if (!body)
587
+ return errorResponse(res, 400, 'Invalid JSON');
588
+ const days = body.days ?? 30;
589
+ try {
590
+ accountStore.extendTrial(params.id, days);
591
+ const org = accountStore.getOrgById(params.id);
592
+ json(res, 200, { message: `Trial extended by ${days} days`, org });
593
+ }
594
+ catch (e) {
595
+ if (e.message?.includes('not found'))
596
+ return errorResponse(res, 404, e.message);
597
+ throw e;
598
+ }
599
+ });
256
600
  // ============================================================
257
601
  // Server
258
602
  // ============================================================
@@ -267,12 +611,21 @@ function startServer() {
267
611
  const server = createServer(async (req, res) => {
268
612
  const start = Date.now();
269
613
  let status = 200;
270
- // CORS
271
- res.setHeader('Access-Control-Allow-Origin', '*');
272
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
273
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
614
+ // CORS — restrict to known origins; API clients don't need CORS
615
+ const origin = req.headers.origin;
616
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
617
+ res.setHeader('Access-Control-Allow-Origin', origin);
618
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
619
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
620
+ res.setHeader('Vary', 'Origin');
621
+ }
274
622
  if (req.method === 'OPTIONS') {
275
- res.writeHead(204);
623
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
624
+ res.writeHead(204);
625
+ }
626
+ else {
627
+ res.writeHead(204);
628
+ }
276
629
  res.end();
277
630
  return;
278
631
  }
@@ -307,6 +660,7 @@ function startServer() {
307
660
  return;
308
661
  }
309
662
  // User auth
663
+ const clientIP = getClientIP(req);
310
664
  const authHeader = req.headers.authorization;
311
665
  if (!authHeader?.startsWith('Bearer ')) {
312
666
  status = 401;
@@ -315,17 +669,47 @@ function startServer() {
315
669
  return;
316
670
  }
317
671
  const apiKey = authHeader.slice(7);
672
+ // Check if this IP is blocked from too many past failures
673
+ if (authFailLimiter.isBlocked(clientIP)) {
674
+ status = 429;
675
+ errorResponse(res, 429, 'Too many authentication failures. Try again in a minute.');
676
+ log(req, pathname, status, start);
677
+ return;
678
+ }
318
679
  const account = accountStore.getAccountByKey(apiKey);
319
680
  if (!account) {
681
+ // Only count failures against the rate limit
682
+ authFailLimiter.record(clientIP);
320
683
  status = 401;
684
+ console.warn(`Auth failure from ${clientIP}: invalid API key (${apiKey.slice(0, 12)}...)`);
321
685
  json(res, 401, { error: 'unauthorized', message: 'Invalid API key' });
322
686
  log(req, pathname, status, start);
323
687
  return;
324
688
  }
689
+ // Check org trial expiry
690
+ if (account.orgId) {
691
+ if (accountStore.isTrialExpired(account.orgId)) {
692
+ status = 403;
693
+ json(res, 403, { error: 'trial_expired', message: 'Organization trial has expired. Contact sales@engram.fyi to upgrade.' });
694
+ log(req, pathname, status, start);
695
+ return;
696
+ }
697
+ }
698
+ // RBAC: readonly members can only read
699
+ if (account.role === 'readonly') {
700
+ const isWrite = req.method === 'POST' || req.method === 'DELETE' || req.method === 'PUT' || req.method === 'PATCH';
701
+ const isReadEndpoint = pathname.startsWith('/v1/memories/recall') || pathname === '/v1/stats' || pathname === '/v1/entities' || pathname === '/v1/account';
702
+ if (isWrite && !isReadEndpoint) {
703
+ status = 403;
704
+ json(res, 403, { error: 'forbidden', message: 'Readonly members cannot create, modify, or delete memories' });
705
+ log(req, pathname, status, start, account.email);
706
+ return;
707
+ }
708
+ }
325
709
  const vault = getVault(account.id);
326
710
  await r.handler(req, res, { account, vault, params });
327
711
  status = res.statusCode;
328
- log(req, pathname, status, start);
712
+ log(req, pathname, status, start, account.email);
329
713
  return;
330
714
  }
331
715
  status = 404;
@@ -333,9 +717,27 @@ function startServer() {
333
717
  log(req, pathname, status, start);
334
718
  }
335
719
  catch (err) {
336
- status = 500;
337
- console.error(`Error handling ${req.method} ${pathname}:`, err);
338
- errorResponse(res, 500, err.message ?? 'Internal server error');
720
+ if (err instanceof PayloadTooLargeError) {
721
+ status = 413;
722
+ errorResponse(res, 413, err.message);
723
+ }
724
+ else if (err instanceof SyntaxError && err.message?.includes('JSON')) {
725
+ status = 400;
726
+ errorResponse(res, 400, 'Invalid JSON in request body');
727
+ }
728
+ else if (err?.name === 'ZodError' || err?.issues) {
729
+ // Zod validation error (e.g. content too long, missing required fields)
730
+ status = 400;
731
+ const issues = err.issues ?? [];
732
+ const msg = issues.map((i) => `${i.path?.join('.')}: ${i.message}`).join('; ') || 'Validation error';
733
+ errorResponse(res, 400, msg);
734
+ }
735
+ else {
736
+ status = 500;
737
+ // Log full error server-side, return generic message to client
738
+ console.error(`Error handling ${req.method} ${pathname}:`, err);
739
+ errorResponse(res, 500, 'Internal server error');
740
+ }
339
741
  log(req, pathname, status, start);
340
742
  }
341
743
  });
@@ -348,7 +750,7 @@ function startServer() {
348
750
  console.log(`⚠ No GEMINI_API_KEY — semantic search disabled`);
349
751
  console.log(`✓ Listening on http://${HOST}:${PORT}`);
350
752
  console.log();
351
- console.log(`Admin key: ${ADMIN_KEY.slice(0, 12)}...`);
753
+ console.log(`Admin key: configured ✓`);
352
754
  console.log(`Create accounts: POST /admin/accounts -d '{"email":"user@co.com","plan":"growth"}'`);
353
755
  });
354
756
  // Graceful shutdown
@@ -363,9 +765,11 @@ function startServer() {
363
765
  process.on('SIGINT', shutdown);
364
766
  process.on('SIGTERM', shutdown);
365
767
  }
366
- function log(req, path, status, start) {
768
+ function log(req, path, status, start, user) {
367
769
  const ms = Date.now() - start;
368
- console.log(`${new Date().toISOString()} ${req.method} ${path} ${status} ${ms}ms`);
770
+ const ip = getClientIP(req);
771
+ const userTag = user ? ` [${user}]` : '';
772
+ console.log(`${new Date().toISOString()} ${req.method} ${path} ${status} ${ms}ms ${ip}${userTag}`);
369
773
  }
370
774
  // ============================================================
371
775
  // CLI Entry Point