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/BADGE.md +47 -0
- package/README.md +13 -1
- package/assets/BADGE-USAGE.md +39 -0
- package/assets/badge-made-with-engram.svg +23 -0
- package/dist/accounts.d.ts +32 -2
- package/dist/accounts.d.ts.map +1 -1
- package/dist/accounts.js +157 -6
- package/dist/accounts.js.map +1 -1
- package/dist/cli.js +270 -13
- package/dist/cli.js.map +1 -1
- package/dist/hosted-client.d.ts +71 -0
- package/dist/hosted-client.d.ts.map +1 -0
- package/dist/hosted-client.js +154 -0
- package/dist/hosted-client.js.map +1 -0
- package/dist/hosted.d.ts.map +1 -1
- package/dist/hosted.js +442 -38
- package/dist/hosted.js.map +1 -1
- package/dist/magic-link.d.ts +22 -0
- package/dist/magic-link.d.ts.map +1 -0
- package/dist/magic-link.js +99 -0
- package/dist/magic-link.js.map +1 -0
- package/dist/mcp.js +140 -19
- package/dist/mcp.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -2
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +23 -4
- package/dist/store.js.map +1 -1
- package/dist/stripe.d.ts +9 -2
- package/dist/stripe.d.ts.map +1 -1
- package/dist/stripe.js +45 -14
- package/dist/stripe.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -3
- package/dist/types.js.map +1 -1
- package/dist/vault.d.ts +4 -1
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +8 -3
- package/dist/vault.js.map +1 -1
- package/eval-rescore-judge.mjs +158 -0
- package/package.json +1 -1
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
|
-
|
|
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, `${
|
|
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:
|
|
29
|
-
vaultCache.set(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
183
|
-
if (!
|
|
184
|
-
return errorResponse(res,
|
|
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,
|
|
313
|
+
errorResponse(res, 500, 'Checkout failed');
|
|
194
314
|
}
|
|
195
315
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (!
|
|
199
|
-
return errorResponse(res, 400, '
|
|
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(
|
|
322
|
+
const result = await createCustomerPortalSession(account.stripeCustomerId);
|
|
202
323
|
json(res, 200, result);
|
|
203
324
|
}
|
|
204
325
|
catch (e) {
|
|
205
|
-
errorResponse(res, 500,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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:
|
|
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
|
-
|
|
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
|