@7ots/cli 0.1.0

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.
Files changed (97) hide show
  1. package/.env.example +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -0
  4. package/brand/apple-touch-icon.png +0 -0
  5. package/brand/favicon.svg +4 -0
  6. package/brand/icon-512.png +0 -0
  7. package/brand/mark-mono.svg +3 -0
  8. package/brand/mark.svg +4 -0
  9. package/brand/og-es.png +0 -0
  10. package/brand/og-pt.png +0 -0
  11. package/brand/og.png +0 -0
  12. package/brand/ots.json +11 -0
  13. package/brand/tokens.css +59 -0
  14. package/brand/wordmark.svg +5 -0
  15. package/cli/7ots.mjs +400 -0
  16. package/cli/lib/brain.mjs +143 -0
  17. package/cli/lib/config.mjs +70 -0
  18. package/cli/lib/hooks.mjs +151 -0
  19. package/cli/lib/i18n.mjs +480 -0
  20. package/cli/lib/identity.mjs +51 -0
  21. package/cli/lib/install.mjs +79 -0
  22. package/cli/lib/lines.mjs +73 -0
  23. package/cli/lib/meet.mjs +166 -0
  24. package/cli/lib/open.mjs +19 -0
  25. package/cli/lib/orquesta.mjs +84 -0
  26. package/cli/lib/paths.mjs +49 -0
  27. package/cli/lib/pet-core.mjs +184 -0
  28. package/cli/lib/pet-server.mjs +296 -0
  29. package/cli/lib/prompts.mjs +75 -0
  30. package/cli/lib/terminal.mjs +145 -0
  31. package/cli/lib/ui.mjs +234 -0
  32. package/cli/lib/wizard.mjs +279 -0
  33. package/cli/pet/electron/main.cjs +86 -0
  34. package/cli/pet/pet.html +404 -0
  35. package/dist/7ots.esm.js +627 -0
  36. package/dist/7ots.esm.js.map +7 -0
  37. package/dist/7ots.iife.js +627 -0
  38. package/dist/7ots.iife.js.map +7 -0
  39. package/llms.txt +57 -0
  40. package/package.json +73 -0
  41. package/server/admin.mjs +213 -0
  42. package/server/apuchat-relay.mjs +170 -0
  43. package/server/channels/agent.mjs +136 -0
  44. package/server/channels/apuchat.mjs +364 -0
  45. package/server/channels/apumail.mjs +211 -0
  46. package/server/channels/index.mjs +69 -0
  47. package/server/contact.mjs +151 -0
  48. package/server/demo.mjs +154 -0
  49. package/server/i18n.mjs +54 -0
  50. package/server/identity.mjs +108 -0
  51. package/server/llm.mjs +413 -0
  52. package/server/platform/auth.mjs +290 -0
  53. package/server/platform/crypto.mjs +80 -0
  54. package/server/platform/db.mjs +130 -0
  55. package/server/platform/routes.mjs +388 -0
  56. package/server/platform/runtime.mjs +180 -0
  57. package/server/platform/store.mjs +331 -0
  58. package/server/sdk.mjs +125 -0
  59. package/server/server.mjs +456 -0
  60. package/server/settings.mjs +177 -0
  61. package/server/tts.mjs +186 -0
  62. package/skills/7ots/SKILL.md +59 -0
  63. package/src/actions/ActionRegistry.js +254 -0
  64. package/src/actions/PageTools.js +270 -0
  65. package/src/actions/builtins.js +565 -0
  66. package/src/auth/AuthManager.js +128 -0
  67. package/src/avatar/AvatarStage.js +473 -0
  68. package/src/character/AvatarEditor.js +829 -0
  69. package/src/character/Character.js +1191 -0
  70. package/src/character/motion.js +794 -0
  71. package/src/character/parts.js +559 -0
  72. package/src/context/ContextManager.js +472 -0
  73. package/src/core/AgentBrain.js +341 -0
  74. package/src/core/AgentWidget.js +1059 -0
  75. package/src/core/EventBus.js +61 -0
  76. package/src/core/Proactivity.js +363 -0
  77. package/src/core/storage.js +35 -0
  78. package/src/i18n/index.js +193 -0
  79. package/src/i18n/messages/character.js +543 -0
  80. package/src/i18n/messages/identity.js +141 -0
  81. package/src/i18n/messages/platform.js +114 -0
  82. package/src/i18n/messages/server.js +630 -0
  83. package/src/i18n/messages/widget.js +362 -0
  84. package/src/identity/ContactCard.js +351 -0
  85. package/src/identity/Face.js +148 -0
  86. package/src/identity/random.js +112 -0
  87. package/src/identity/schema.js +262 -0
  88. package/src/index.js +117 -0
  89. package/src/integrations/apuchat.js +212 -0
  90. package/src/integrations/apumail.js +88 -0
  91. package/src/llm/ProxyLLM.js +87 -0
  92. package/src/mcp/McpClient.js +153 -0
  93. package/src/ui/Companion.js +498 -0
  94. package/src/ui/UIManager.js +425 -0
  95. package/src/ui/VirtualPointer.js +746 -0
  96. package/src/ui/markdown.js +94 -0
  97. package/src/voice/VoiceEngine.js +227 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Acceso a la plataforma: enlace por correo y "Entrar con notlogin" (OIDC + PKCE).
3
+ *
4
+ * POST /auth/email { email } → manda el enlace (buzón de apumail de la plataforma)
5
+ * GET /auth/email?token=… página con el botón "Entrar" (los antivirus del correo abren
6
+ * los enlaces: un GET nunca gasta el enlace)
7
+ * POST /auth/email/confirm token=… gasta el enlace, abre sesión y lleva a /app/
8
+ * GET /auth/notlogin → notlogin.com/authorize
9
+ * GET /auth/notlogin/callback ?code&state → sesión y /app/
10
+ * POST /auth/logout
11
+ *
12
+ * Variables:
13
+ * PLATFORM_URL origen público (https://7ots.com); los enlaces nunca salen de la cabecera Host
14
+ * PLATFORM_MAIL_INBOX buzón de apumail que manda los enlaces · PLATFORM_MAIL_TOKEN su token
15
+ * (sin buzón y fuera de producción, el enlace sale por consola)
16
+ * NOTLOGIN_CLIENT_ID slug de 7ots como vendor de notlogin (también el `aud` del id_token)
17
+ * NOTLOGIN_CLIENT_SECRET opcional · NOTLOGIN_URL (defecto https://notlogin.com)
18
+ *
19
+ * Sesión: cookie `ots_session` (HttpOnly, SameSite=Lax, 30 días); en la base de datos solo su hash.
20
+ */
21
+
22
+ import { createHash, createPublicKey, randomBytes, verify as cryptoVerify } from 'node:crypto';
23
+ import { reqT, teamT } from '../i18n.mjs';
24
+ import { signValue, unsignValue } from './crypto.mjs';
25
+ import { SESSION_TTL, consumeLoginToken, createLoginToken, createSession, deleteSession, recentLoginTokens, sessionAccount, upsertAccount } from './store.mjs';
26
+
27
+ const penv = process.env;
28
+ export const SESSION_COOKIE = 'ots_session';
29
+ const OIDC_COOKIE = 'ots_oidc';
30
+ const OIDC_TTL = 10 * 60_000;
31
+ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]+\.[^\s@]{2,}$/;
32
+
33
+ const notloginUrl = () => (penv.NOTLOGIN_URL || 'https://notlogin.com').replace(/\/+$/, '');
34
+ export const notloginConfigured = () => !!penv.NOTLOGIN_CLIENT_ID;
35
+ export const mailConfigured = () => !!(penv.PLATFORM_MAIL_INBOX && penv.PLATFORM_MAIL_TOKEN);
36
+ const production = () => penv.NODE_ENV === 'production';
37
+
38
+ export function parseCookies(h = '') {
39
+ const out = {};
40
+ for (const part of String(h).split(';')) {
41
+ const i = part.indexOf('=');
42
+ if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Cuenta de la sesión de esta petición (o null). */
48
+ export function currentAccount(req) {
49
+ return sessionAccount(parseCookies(req.headers.cookie)[SESSION_COOKIE]);
50
+ }
51
+
52
+ /**
53
+ * @param {{ send: Function, readJson: Function, originOf: (req) => string, clientIp: (req) => string }} deps
54
+ */
55
+ export function createAuth({ send, readJson, originOf, clientIp }) {
56
+ const base = (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, '');
57
+ const secure = (req) => base(req).startsWith('https:');
58
+ const ipHits = new Map();
59
+
60
+ function cookie(req, name, value, { path = '/', maxAge }) {
61
+ return `${name}=${encodeURIComponent(value)}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(maxAge / 1000)}${secure(req) ? '; Secure' : ''}`;
62
+ }
63
+
64
+ function startSession(req, res, account, via) {
65
+ const raw = createSession(account.id, via);
66
+ res.setHeader('Set-Cookie', [cookie(req, SESSION_COOKIE, raw, { maxAge: SESSION_TTL }), cookie(req, OIDC_COOKIE, '', { path: '/auth/notlogin', maxAge: 0 })]);
67
+ }
68
+
69
+ function redirect(res, to) {
70
+ res.writeHead(303, { Location: to, 'Cache-Control': 'no-store' });
71
+ res.end();
72
+ }
73
+
74
+ /** Vuelve a la pantalla de acceso con un aviso (clave i18n que traduce el dashboard). */
75
+ const fail = (res, key) => redirect(res, `/app/?error=${encodeURIComponent(key)}`);
76
+
77
+ function tooMany(req) {
78
+ const ip = clientIp(req);
79
+ const now = Date.now();
80
+ const recent = (ipHits.get(ip) || []).filter((t) => now - t < 3600_000);
81
+ if (recent.length >= 10) return true;
82
+ recent.push(now);
83
+ ipHits.set(ip, recent);
84
+ if (ipHits.size > 5000) ipHits.clear();
85
+ return false;
86
+ }
87
+
88
+ async function sendLink(req, email) {
89
+ const url = `${base(req)}/auth/email?token=${createLoginToken(email)}`;
90
+ const t = reqT(req); // el correo, en el idioma de quien lo pide
91
+ if (!mailConfigured()) {
92
+ if (production()) throw Object.assign(new Error('mail'), { status: 501, key: 'platform.api.mailNotConfigured' });
93
+ console.log(`[7ots] ${teamT()('platform.log.magicLink', { email, url })}`);
94
+ return;
95
+ }
96
+ const api = (penv.APUMAIL_API || 'https://api.apumail.com').replace(/\/+$/, '');
97
+ const r = await fetch(`${api}/api/v1/inbox/${encodeURIComponent(penv.PLATFORM_MAIL_INBOX.toLowerCase())}/send`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${penv.PLATFORM_MAIL_TOKEN}` },
100
+ body: JSON.stringify({ to: email, subject: t('platform.mail.subject'), text: t('platform.mail.body', { url }) }),
101
+ signal: AbortSignal.timeout(15000),
102
+ }).catch(() => null);
103
+ if (!r?.ok) {
104
+ console.error('[7ots] plataforma: no se pudo mandar el enlace', r?.status ?? 'sin respuesta');
105
+ throw Object.assign(new Error('mail'), { status: 502, key: 'platform.api.mailFailed' });
106
+ }
107
+ }
108
+
109
+ // ── notlogin (OIDC) ──
110
+
111
+ const redirectUri = (req) => `${base(req)}/auth/notlogin/callback`;
112
+
113
+ function notloginStart(req, res) {
114
+ if (!notloginConfigured()) return fail(res, 'platform.api.notloginNotConfigured');
115
+ const verifier = randomBytes(32).toString('base64url');
116
+ const st = { s: randomBytes(16).toString('base64url'), v: verifier, n: randomBytes(16).toString('base64url'), e: Date.now() + OIDC_TTL };
117
+ const params = new URLSearchParams({
118
+ vendor: penv.NOTLOGIN_CLIENT_ID,
119
+ client_id: penv.NOTLOGIN_CLIENT_ID,
120
+ redirect_uri: redirectUri(req),
121
+ response_type: 'code',
122
+ scope: 'openid profile email',
123
+ state: st.s,
124
+ nonce: st.n,
125
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
126
+ code_challenge_method: 'S256',
127
+ });
128
+ res.setHeader('Set-Cookie', cookie(req, OIDC_COOKIE, signValue(Buffer.from(JSON.stringify(st)).toString('base64url')), { path: '/auth/notlogin', maxAge: OIDC_TTL }));
129
+ redirect(res, `${notloginUrl()}/authorize?${params}`);
130
+ }
131
+
132
+ async function notloginCallback(req, res, url) {
133
+ const raw = unsignValue(parseCookies(req.headers.cookie)[OIDC_COOKIE]);
134
+ let st = null;
135
+ try {
136
+ st = raw && JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
137
+ } catch {
138
+ /* cookie rota */
139
+ }
140
+ const code = url.searchParams.get('code');
141
+ if (!st || st.e < Date.now() || !code || url.searchParams.get('state') !== st.s) return fail(res, 'platform.api.notloginFailed');
142
+ try {
143
+ const body = new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: st.v, redirect_uri: redirectUri(req), client_id: penv.NOTLOGIN_CLIENT_ID });
144
+ if (penv.NOTLOGIN_CLIENT_SECRET) body.set('client_secret', penv.NOTLOGIN_CLIENT_SECRET);
145
+ const r = await fetch(`${notloginUrl()}/api/oauth/token`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
148
+ body,
149
+ redirect: 'manual',
150
+ signal: AbortSignal.timeout(8000),
151
+ });
152
+ const j = await r.json().catch(() => ({}));
153
+ if (!r.ok || !j.id_token) throw new Error(`token ${r.status}`);
154
+ const claims = await verifyIdToken(j.id_token, st.n);
155
+ const account = upsertAccount({ notloginSub: claims.sub, email: claims.email_verified ? claims.email : null });
156
+ startSession(req, res, account, 'notlogin');
157
+ redirect(res, '/app/');
158
+ } catch (e) {
159
+ console.warn('[7ots] notlogin:', e.message);
160
+ fail(res, 'platform.api.notloginFailed');
161
+ }
162
+ }
163
+
164
+ /** @returns {Promise<boolean>} */
165
+ return async function handle(req, res, url) {
166
+ const p = url.pathname;
167
+ if (!p.startsWith('/auth/')) return false;
168
+ const t = reqT(req);
169
+ const write = req.method === 'POST';
170
+ // Formularios propios: mismo origen (la cookie es Lax, pero así no hay CSRF de login).
171
+ const origin = req.headers.origin;
172
+ if (write && origin && origin !== base(req) && origin !== originOf(req)) {
173
+ send(res, 403, { error: t('server.admin.sameOrigin') });
174
+ return true;
175
+ }
176
+
177
+ if (p === '/auth/providers' && req.method === 'GET') {
178
+ send(res, 200, { email: mailConfigured() || !production(), notlogin: notloginConfigured() });
179
+ return true;
180
+ }
181
+
182
+ if (p === '/auth/email' && write) {
183
+ const { email } = await readJson(req, 4096);
184
+ const e = String(email || '').trim().toLowerCase();
185
+ if (!EMAIL_RE.test(e) || e.length > 200) return send(res, 400, { error: t('platform.api.badEmail') }), true;
186
+ if (tooMany(req) || recentLoginTokens(e) >= 5) return send(res, 429, { error: t('platform.api.tooManyLinks') }), true;
187
+ try {
188
+ await sendLink(req, e);
189
+ } catch (err) {
190
+ send(res, err.status || 500, { error: t(err.key || 'server.http.internal') });
191
+ return true;
192
+ }
193
+ send(res, 200, { ok: true });
194
+ return true;
195
+ }
196
+
197
+ if (p === '/auth/email' && req.method === 'GET') {
198
+ const token = String(url.searchParams.get('token') || '').replace(/[^A-Za-z0-9_-]/g, '');
199
+ // same-origin (no no-referrer): con no-referrer el navegador envía el formulario con `Origin: null`.
200
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'same-origin', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'" });
201
+ res.end(confirmPage(t, token));
202
+ return true;
203
+ }
204
+
205
+ if (p === '/auth/email/confirm' && write) {
206
+ const form = await readForm(req);
207
+ const email = consumeLoginToken(form.get('token'));
208
+ if (!email) return fail(res, 'platform.api.linkInvalid'), true;
209
+ startSession(req, res, upsertAccount({ email }), 'email');
210
+ redirect(res, '/app/');
211
+ return true;
212
+ }
213
+
214
+ if (p === '/auth/notlogin' && req.method === 'GET') return notloginStart(req, res), true;
215
+ if (p === '/auth/notlogin/callback' && req.method === 'GET') return await notloginCallback(req, res, url), true;
216
+
217
+ if (p === '/auth/logout' && write) {
218
+ deleteSession(parseCookies(req.headers.cookie)[SESSION_COOKIE]);
219
+ res.setHeader('Set-Cookie', cookie(req, SESSION_COOKIE, '', { maxAge: 0 }));
220
+ send(res, 200, { ok: true });
221
+ return true;
222
+ }
223
+
224
+ send(res, 404, { error: t('server.http.notFound') });
225
+ return true;
226
+ };
227
+ }
228
+
229
+ async function readForm(req) {
230
+ let size = 0;
231
+ const chunks = [];
232
+ for await (const c of req) {
233
+ size += c.length;
234
+ if (size > 4096) break;
235
+ chunks.push(c);
236
+ }
237
+ return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
238
+ }
239
+
240
+ function confirmPage(t, token) {
241
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
242
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>7ots.com</title>
243
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;background:#f8f7fc;color:#16152a}
244
+ main{text-align:center;padding:32px}button{font:600 16px system-ui,sans-serif;background:#5b47f0;color:#fff;border:0;border-radius:8px;padding:12px 28px;cursor:pointer}
245
+ @media (prefers-color-scheme:dark){body{background:#0b0a14;color:#f0eff7}button{background:#8b7cff;color:#0b0a14}}</style></head>
246
+ <body><main><p>${esc(t('platform.mail.confirm'))}</p><form method="post" action="/auth/email/confirm"><input type="hidden" name="token" value="${esc(token)}"><button type="submit">${esc(t('platform.mail.button'))}</button></form></main></body></html>`;
247
+ }
248
+
249
+ // ───────────────────────────── id_token de notlogin (EdDSA) ─────────────────────────────
250
+
251
+ let jwks = null; // { at, keys: Map<kid, KeyObject> }
252
+
253
+ async function loadJwks(force) {
254
+ if (!force && jwks && Date.now() - jwks.at < 5 * 60_000) return jwks.keys;
255
+ const r = await fetch(`${notloginUrl()}/.well-known/jwks.json`, { signal: AbortSignal.timeout(8000) });
256
+ if (!r.ok) throw new Error(`jwks ${r.status}`);
257
+ const keys = new Map();
258
+ for (const k of (await r.json()).keys || []) {
259
+ try {
260
+ keys.set(k.kid, createPublicKey({ key: k, format: 'jwk' }));
261
+ } catch {
262
+ /* clave rara: se ignora */
263
+ }
264
+ }
265
+ jwks = { at: Date.now(), keys };
266
+ return keys;
267
+ }
268
+
269
+ const part = (s) => JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));
270
+
271
+ async function verifyIdToken(idToken, nonce) {
272
+ const [h, p, s] = String(idToken).split('.');
273
+ if (!s) throw new Error('id_token mal formado');
274
+ const header = part(h);
275
+ if (header.alg !== 'EdDSA') throw new Error('alg inesperado');
276
+ let keys = await loadJwks(false);
277
+ let key = keys.get(header.kid);
278
+ if (!key) key = (keys = await loadJwks(true)).get(header.kid);
279
+ if (!key) throw new Error('clave de firma desconocida');
280
+ if (!cryptoVerify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url'))) throw new Error('firma inválida');
281
+ const c = part(p);
282
+ const now = Math.floor(Date.now() / 1000);
283
+ if (c.iss !== notloginUrl()) throw new Error('iss');
284
+ if (c.aud !== penv.NOTLOGIN_CLIENT_ID && !(Array.isArray(c.aud) && c.aud.includes(penv.NOTLOGIN_CLIENT_ID))) throw new Error('aud');
285
+ if (c.token_use && c.token_use !== 'id') throw new Error('token_use');
286
+ if (!(c.exp + 60 > now)) throw new Error('caducado');
287
+ if (c.nonce !== nonce) throw new Error('nonce');
288
+ if (typeof c.sub !== 'string' || !c.sub) throw new Error('sin sub');
289
+ return c;
290
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Criptografía de la plataforma. Todo sale de PLATFORM_SECRET (≥ 32 caracteres, aleatorio):
3
+ * - las claves de cada ots se guardan cifradas (AES-256-GCM),
4
+ * - las cookies temporales del login (estado de OIDC) van firmadas (HMAC-SHA256),
5
+ * - los tokens (sesión, enlace por email) se guardan como hash, nunca en claro.
6
+ * Cambiar PLATFORM_SECRET invalida las claves guardadas: los usuarios tendrían que volver a ponerlas.
7
+ */
8
+
9
+ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
10
+
11
+ let keys = null;
12
+
13
+ function derive() {
14
+ if (keys) return keys;
15
+ const secret = process.env.PLATFORM_SECRET || '';
16
+ if (secret.length < 32) throw new Error('PLATFORM_SECRET debe tener al menos 32 caracteres (la plataforma no arranca sin él)');
17
+ const k = (info) => Buffer.from(hkdfSync('sha256', secret, '7ots-platform', info, 32));
18
+ keys = { box: k('secrets-v1'), mac: k('cookies-v1') };
19
+ return keys;
20
+ }
21
+
22
+ /** Comprueba PLATFORM_SECRET al arrancar (lanza si falta). */
23
+ export function assertSecret() {
24
+ derive();
25
+ }
26
+
27
+ /** Cifra un objeto → "v1.<iv>.<tag>.<datos>" (base64url). */
28
+ export function seal(obj) {
29
+ const iv = randomBytes(12);
30
+ const c = createCipheriv('aes-256-gcm', derive().box, iv);
31
+ const data = Buffer.concat([c.update(JSON.stringify(obj ?? {}), 'utf8'), c.final()]);
32
+ return ['v1', iv.toString('base64url'), c.getAuthTag().toString('base64url'), data.toString('base64url')].join('.');
33
+ }
34
+
35
+ /** Descifra lo de seal(). Vacío o inválido → {} (y avisa: nunca rompe el ots). */
36
+ export function open(text) {
37
+ if (!text) return {};
38
+ try {
39
+ const [v, iv, tag, data] = String(text).split('.');
40
+ if (v !== 'v1') return {};
41
+ const d = createDecipheriv('aes-256-gcm', derive().box, Buffer.from(iv, 'base64url'));
42
+ d.setAuthTag(Buffer.from(tag, 'base64url'));
43
+ return JSON.parse(Buffer.concat([d.update(Buffer.from(data, 'base64url')), d.final()]).toString('utf8'));
44
+ } catch {
45
+ console.warn('[7ots] plataforma: no se pudieron descifrar unas claves (¿cambió PLATFORM_SECRET?)');
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /** Firma un valor corto para una cookie: "<valor>.<mac>". */
51
+ export function signValue(value) {
52
+ return `${value}.${createHmac('sha256', derive().mac).update(value).digest('base64url')}`;
53
+ }
54
+
55
+ /** Devuelve el valor si la firma es buena; si no, null. */
56
+ export function unsignValue(signed) {
57
+ const s = String(signed || '');
58
+ const i = s.lastIndexOf('.');
59
+ if (i < 1) return null;
60
+ const value = s.slice(0, i);
61
+ return safeEqual(s.slice(i + 1), createHmac('sha256', derive().mac).update(value).digest('base64url')) ? value : null;
62
+ }
63
+
64
+ export const token = (bytes = 32) => randomBytes(bytes).toString('base64url');
65
+ export const sha256 = (s) => createHash('sha256').update(String(s)).digest('base64url');
66
+
67
+ /** Id corto y legible para URLs: prefijo + 16 caracteres [a-z0-9]. */
68
+ export function newId(prefix) {
69
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
70
+ const b = randomBytes(16);
71
+ let out = '';
72
+ for (const x of b) out += alphabet[x % 36];
73
+ return `${prefix}_${out}`;
74
+ }
75
+
76
+ export function safeEqual(a, b) {
77
+ const x = Buffer.from(String(a));
78
+ const y = Buffer.from(String(b));
79
+ return x.length === y.length && timingSafeEqual(x, y);
80
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Base de datos de la plataforma 7ots.com (solo con PLATFORM=true).
3
+ *
4
+ * SQLite de Node (node:sqlite): sin dependencias ni servidor aparte. Un fichero,
5
+ * PLATFORM_DB (defecto data/platform.db), en modo WAL.
6
+ *
7
+ * accounts cuentas (email y/o notlogin_sub)
8
+ * sessions sesiones del dashboard (se guarda el hash del token, nunca el token)
9
+ * login_tokens enlaces de acceso por email (hash, 15 min, un solo uso)
10
+ * ots los agentes: identidad, widget y ajustes en JSON; claves cifradas aparte
11
+ * conversations una por visitante y sesión del widget (o contacto de un canal)
12
+ * messages lo que dijo cada uno (texto, sin el `raw` del proveedor)
13
+ * usage contadores por ots, mes y tipo
14
+ */
15
+
16
+ import { DatabaseSync } from 'node:sqlite';
17
+ import { mkdirSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
22
+
23
+ const SCHEMA = `
24
+ CREATE TABLE IF NOT EXISTS accounts (
25
+ id TEXT PRIMARY KEY,
26
+ email TEXT UNIQUE,
27
+ notlogin_sub TEXT UNIQUE,
28
+ name TEXT NOT NULL DEFAULT '',
29
+ plan TEXT NOT NULL DEFAULT 'free',
30
+ created_at INTEGER NOT NULL,
31
+ last_login_at INTEGER
32
+ );
33
+ CREATE TABLE IF NOT EXISTS sessions (
34
+ id TEXT PRIMARY KEY,
35
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
36
+ created_at INTEGER NOT NULL,
37
+ expires_at INTEGER NOT NULL,
38
+ via TEXT NOT NULL DEFAULT ''
39
+ );
40
+ CREATE INDEX IF NOT EXISTS sessions_account ON sessions(account_id);
41
+ CREATE TABLE IF NOT EXISTS login_tokens (
42
+ hash TEXT PRIMARY KEY,
43
+ email TEXT NOT NULL,
44
+ expires_at INTEGER NOT NULL,
45
+ used_at INTEGER
46
+ );
47
+ CREATE TABLE IF NOT EXISTS ots (
48
+ id TEXT PRIMARY KEY,
49
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
50
+ name TEXT NOT NULL,
51
+ status TEXT NOT NULL DEFAULT 'on',
52
+ domains TEXT NOT NULL DEFAULT '[]',
53
+ identity TEXT NOT NULL DEFAULT '{}',
54
+ widget TEXT NOT NULL DEFAULT '{}',
55
+ settings TEXT NOT NULL DEFAULT '{}',
56
+ secrets TEXT NOT NULL DEFAULT '',
57
+ created_at INTEGER NOT NULL,
58
+ updated_at INTEGER NOT NULL
59
+ );
60
+ CREATE INDEX IF NOT EXISTS ots_account ON ots(account_id);
61
+ CREATE TABLE IF NOT EXISTS conversations (
62
+ id TEXT PRIMARY KEY,
63
+ ots_id TEXT NOT NULL REFERENCES ots(id) ON DELETE CASCADE,
64
+ channel TEXT NOT NULL DEFAULT 'web',
65
+ visitor TEXT NOT NULL DEFAULT '',
66
+ page TEXT NOT NULL DEFAULT '',
67
+ title TEXT NOT NULL DEFAULT '',
68
+ messages INTEGER NOT NULL DEFAULT 0,
69
+ started_at INTEGER NOT NULL,
70
+ updated_at INTEGER NOT NULL
71
+ );
72
+ CREATE INDEX IF NOT EXISTS conversations_ots ON conversations(ots_id, updated_at DESC);
73
+ CREATE TABLE IF NOT EXISTS messages (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
76
+ role TEXT NOT NULL,
77
+ content TEXT NOT NULL,
78
+ at INTEGER NOT NULL
79
+ );
80
+ CREATE INDEX IF NOT EXISTS messages_conversation ON messages(conversation_id, id);
81
+ CREATE TABLE IF NOT EXISTS usage (
82
+ account_id TEXT NOT NULL,
83
+ ots_id TEXT NOT NULL,
84
+ month TEXT NOT NULL,
85
+ kind TEXT NOT NULL,
86
+ count INTEGER NOT NULL DEFAULT 0,
87
+ PRIMARY KEY (ots_id, month, kind)
88
+ );
89
+ CREATE INDEX IF NOT EXISTS usage_account ON usage(account_id, month);
90
+ `;
91
+
92
+ let db = null;
93
+
94
+ /** Abre (y crea si hace falta) la base de datos. Idempotente. */
95
+ export function openDb(path = process.env.PLATFORM_DB) {
96
+ if (db) return db;
97
+ const file = path === ':memory:' ? path : resolve(ROOT, path || join('data', 'platform.db'));
98
+ if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
99
+ db = new DatabaseSync(file);
100
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;');
101
+ db.exec(SCHEMA);
102
+ return db;
103
+ }
104
+
105
+ export function getDb() {
106
+ return db || openDb();
107
+ }
108
+
109
+ /** Cierra la base de datos (tests). */
110
+ export function closeDb() {
111
+ db?.close();
112
+ db = null;
113
+ }
114
+
115
+ /** Varias escrituras como una sola. */
116
+ export function tx(fn) {
117
+ const d = getDb();
118
+ d.exec('BEGIN');
119
+ try {
120
+ const out = fn(d);
121
+ d.exec('COMMIT');
122
+ return out;
123
+ } catch (e) {
124
+ d.exec('ROLLBACK');
125
+ throw e;
126
+ }
127
+ }
128
+
129
+ /** Mes en curso (UTC) para los contadores: "2026-09". */
130
+ export const monthOf = (t = Date.now()) => new Date(t).toISOString().slice(0, 7);