@dotrino/vaultd 0.6.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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Store de SECRETOS de servicios (`secrets.json`, 0600, mismo dir 0700 que la
3
+ * maestra — mismo dominio de confianza, v1 en claro en reposo igual que
4
+ * `identity.json`; el cifrado en reposo con contraseña llega con v2 para todo
5
+ * el dir). Organizado por NAMESPACE de servicio (`proxy`, `geo`, `bots`…):
6
+ * un cert `vault:secrets:<ns>` solo puede leer SU ns.
7
+ */
8
+ import path from 'node:path'
9
+ import { readJson, writeJson } from './paths.js'
10
+ import { isValidSecretsNs } from './protocol.js'
11
+
12
+ const SCHEMA_VERSION = 1
13
+ const MAX_VALUE_LEN = 8 * 1024
14
+ const KEY_RE = /^[A-Z0-9_]{1,64}$/
15
+
16
+ export function openSecretsStore (dir) {
17
+ const file = path.join(dir, 'secrets.json')
18
+ let data = readJson(file, null)
19
+ if (!data || data.schemaVersion !== SCHEMA_VERSION) {
20
+ data = { schemaVersion: SCHEMA_VERSION, ns: {} }
21
+ writeJson(file, data)
22
+ }
23
+ const save = () => writeJson(file, data)
24
+
25
+ const assertNs = (ns) => {
26
+ if (!isValidSecretsNs(ns)) throw new Error('namespace inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
27
+ }
28
+
29
+ return {
30
+ /** Secretos de un ns (objeto plano KEY→valor; {} si no hay). */
31
+ get (ns) {
32
+ assertNs(ns)
33
+ return { ...(data.ns[ns] || {}) }
34
+ },
35
+ set (ns, key, value) {
36
+ assertNs(ns)
37
+ if (!KEY_RE.test(String(key || ''))) throw new Error('clave inválida (usa MAYUSCULAS_CON_GUION_BAJO, p.ej. TURN_KEY_ID)')
38
+ if (typeof value !== 'string' || !value) throw new Error('el valor debe ser un string no vacío')
39
+ if (value.length > MAX_VALUE_LEN) throw new Error(`valor demasiado largo (máx ${MAX_VALUE_LEN})`)
40
+ if (!data.ns[ns]) data.ns[ns] = {}
41
+ data.ns[ns][key] = value
42
+ save()
43
+ },
44
+ delete (ns, key) {
45
+ assertNs(ns)
46
+ const existed = !!(data.ns[ns] && key in data.ns[ns])
47
+ if (existed) {
48
+ delete data.ns[ns][key]
49
+ if (Object.keys(data.ns[ns]).length === 0) delete data.ns[ns]
50
+ save()
51
+ }
52
+ return existed
53
+ },
54
+ /** Solo nombres (ns → [claves]), sin valores: para `secret list`. */
55
+ list () {
56
+ const out = {}
57
+ for (const ns of Object.keys(data.ns)) out[ns] = Object.keys(data.ns[ns])
58
+ return out
59
+ }
60
+ }
61
+ }
package/src/store.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Store del árbol de contenidos del vault (`vault.json`). Versionado con
3
+ * `schemaVersion` para que v2 pueda introducir cifrado en reposo sin migración
4
+ * dolorosa. NO guarda identidad/dispositivos/certs: de eso se encarga
5
+ * `@dotrino/identity` dentro del mismo dir (keypair, contactos, delegaciones,
6
+ * revocaciones). Aquí vive solo lo del usuario: el árbol y los settings.
7
+ */
8
+ import path from 'node:path'
9
+ import { readJson, writeJson } from './paths.js'
10
+
11
+ const SCHEMA_VERSION = 1
12
+
13
+ function newTree () {
14
+ return { id: 'root', name: '', type: 'folder', children: [] }
15
+ }
16
+
17
+ function findNode (node, id) {
18
+ if (!node) return null
19
+ if (node.id === id) return node
20
+ if (Array.isArray(node.children)) {
21
+ for (const c of node.children) {
22
+ const r = findNode(c, id)
23
+ if (r) return r
24
+ }
25
+ }
26
+ return null
27
+ }
28
+
29
+ export function openStore (dir) {
30
+ const file = path.join(dir, 'vault.json')
31
+ let data = readJson(file, null)
32
+ if (!data || data.schemaVersion !== SCHEMA_VERSION) {
33
+ data = { schemaVersion: SCHEMA_VERSION, tree: newTree(), settings: {} }
34
+ writeJson(file, data)
35
+ }
36
+ const save = () => writeJson(file, data)
37
+
38
+ return {
39
+ get raw () { return data },
40
+ getTree () { return data.tree },
41
+ getNode (id) { return findNode(data.tree, id || 'root') },
42
+ addNode (parentId, node) {
43
+ const parent = findNode(data.tree, parentId || 'root')
44
+ if (!parent) throw new Error('nodo padre no encontrado: ' + parentId)
45
+ if (!Array.isArray(parent.children)) parent.children = []
46
+ parent.children.push(node)
47
+ save()
48
+ return node
49
+ },
50
+ removeNode (id) {
51
+ const remove = (node) => {
52
+ if (!Array.isArray(node.children)) return false
53
+ const i = node.children.findIndex((c) => c.id === id)
54
+ if (i >= 0) { node.children.splice(i, 1); return true }
55
+ return node.children.some(remove)
56
+ }
57
+ const ok = remove(data.tree)
58
+ if (ok) save()
59
+ return ok
60
+ },
61
+ getSetting (k) { return data.settings[k] },
62
+ setSetting (k, v) { data.settings[k] = v; save() }
63
+ }
64
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Sub-store de HILOS + APERTURAS del vault (Fase 3: store centralizado).
3
+ *
4
+ * Espeja el modelo de datos de `@dotrino/store` (store.dotrino.com):
5
+ * threads: { [threadKey]: Entry[] } (Entry tiene `id` + `ts`, opaco para el store)
6
+ * opens: { [appId]: { count, ts } } (contador de "recientes" del hub)
7
+ * para que un dispositivo emparejado pueda guardar su contenido EN el vault del
8
+ * usuario (su propio servidor) en vez de —o además de— el IndexedDB del navegador.
9
+ *
10
+ * File-backed (`threads.json`), síncrono y simple (sin cuota/IndexedDB). Es el
11
+ * backend autoritativo; el navegador usa su IndexedDB como caché y sincroniza.
12
+ */
13
+ import path from 'node:path'
14
+ import { readJson, writeJson } from './paths.js'
15
+
16
+ const MAX_PER_THREAD = 1000
17
+
18
+ export function openThreadStore (dir) {
19
+ const file = path.join(dir, 'threads.json')
20
+ let data = readJson(file, null)
21
+ if (!data || typeof data !== 'object') data = { v: 1, threads: {}, opens: {} }
22
+ if (!data.threads) data.threads = {}
23
+ if (!data.opens) data.opens = {}
24
+ const save = () => writeJson(file, data)
25
+ const trim = (arr) => { if (arr.length > MAX_PER_THREAD) arr.splice(0, arr.length - MAX_PER_THREAD) }
26
+
27
+ const methods = {
28
+ appendMessage ({ threadKey, entry }) {
29
+ if (!threadKey || typeof threadKey !== 'string') throw new Error('threadKey required')
30
+ if (!entry || typeof entry !== 'object') throw new Error('entry required')
31
+ if (!entry.id) entry.id = crypto.randomUUID()
32
+ if (!entry.ts) entry.ts = Date.now()
33
+ const arr = data.threads[threadKey] || (data.threads[threadKey] = [])
34
+ const i = arr.findIndex((e) => e.id === entry.id)
35
+ if (i >= 0) arr[i] = { ...arr[i], ...entry }; else arr.push(entry)
36
+ trim(arr); save(); return entry
37
+ },
38
+ listThread ({ threadKey, limit, before }) {
39
+ if (!threadKey) return []
40
+ let arr = data.threads[threadKey] || []
41
+ if (typeof before === 'number') arr = arr.filter((e) => (e.ts || 0) < before)
42
+ if (typeof limit === 'number' && limit > 0) arr = arr.slice(-limit)
43
+ return arr
44
+ },
45
+ listThreadKeys () { return Object.keys(data.threads) },
46
+ getThreadSummaries () {
47
+ const out = {}
48
+ for (const [k, arr] of Object.entries(data.threads)) out[k] = { lastEntry: arr.length ? arr[arr.length - 1] : null, count: arr.length }
49
+ return out
50
+ },
51
+ removeThread ({ threadKey }) {
52
+ const removed = data.threads[threadKey]?.length || 0
53
+ delete data.threads[threadKey]; save(); return { removed }
54
+ },
55
+ removeMessage ({ threadKey, id }) {
56
+ const arr = data.threads[threadKey] || []; const before = arr.length
57
+ data.threads[threadKey] = arr.filter((e) => e.id !== id)
58
+ if (data.threads[threadKey].length === 0) delete data.threads[threadKey]
59
+ save(); return { removed: before - (data.threads[threadKey]?.length || 0) }
60
+ },
61
+ recordOpen ({ appId }) {
62
+ if (!appId || typeof appId !== 'string') throw new Error('appId required')
63
+ const prev = data.opens[appId]
64
+ data.opens[appId] = { count: (prev?.count || 0) + 1, ts: Date.now() }
65
+ save(); return data.opens[appId]
66
+ },
67
+ getOpens () { return { ...data.opens } },
68
+ clearOpens () { data.opens = {}; save(); return { ok: true } },
69
+ exportThreads () { return { threads: data.threads } },
70
+ importThreads ({ threads, mode = 'merge' }) {
71
+ if (!threads || typeof threads !== 'object') throw new Error('threads required')
72
+ if (mode === 'replace') { data.threads = threads; save(); return { mode, count: Object.keys(threads).length } }
73
+ for (const [k, arr] of Object.entries(threads)) {
74
+ const cur = data.threads[k] || (data.threads[k] = [])
75
+ const byId = new Map(cur.map((e) => [e.id, e]))
76
+ for (const e of arr) { if (!e?.id) continue; const pr = byId.get(e.id); if (!pr || (e.ts || 0) > (pr.ts || 0)) byId.set(e.id, e) }
77
+ data.threads[k] = Array.from(byId.values()).sort((a, b) => (a.ts || 0) - (b.ts || 0)); trim(data.threads[k])
78
+ }
79
+ save(); return { mode, count: Object.keys(data.threads).length }
80
+ },
81
+ // ----- PERFIL del usuario (me): el vault es la copia AUTORITATIVA -----
82
+ // Cada dispositivo emparejado lo empuja al editarlo y lo jala al arrancar →
83
+ // el mismo perfil (apodo/avatar/datos) en todos los dispositivos.
84
+ profileSet ({ me }) {
85
+ if (!me || typeof me !== 'object') throw new Error('me required')
86
+ // nunca guardar llaves de dispositivo (son por-dispositivo)
87
+ const { publickey, encryptionPubkey, ...content } = me
88
+ data.profile = { ...content, updatedAt: content.updatedAt || Date.now() }
89
+ save(); return { ok: true, updatedAt: data.profile.updatedAt }
90
+ },
91
+ profileGet () { return { me: data.profile || null } },
92
+ getStats () {
93
+ const threads = {}
94
+ for (const [k, arr] of Object.entries(data.threads)) threads[k] = { count: arr.length }
95
+ return { threadCount: Object.keys(data.threads).length, threads, opensCount: Object.keys(data.opens).length }
96
+ }
97
+ }
98
+ return { methods, raw: () => data }
99
+ }
100
+
101
+ /** Métodos del store que son de SOLO LECTURA (para decidir el scope necesario). */
102
+ export const STORE_READ_METHODS = new Set([
103
+ 'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet'
104
+ ])
105
+
106
+ /**
107
+ * Métodos que EDITAN el perfil del usuario (quién es: apodo, avatar, campos).
108
+ * Son los únicos que el candado por contraseña bloquea (`vault.js`): el resto del
109
+ * store —contenido de las apps— sigue disponible con el perfil bloqueado.
110
+ */
111
+ export const PROFILE_EDIT_METHODS = new Set(['profileSet'])
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Transporte headless del vault: el cliente OFICIAL `@dotrino/proxy-client`
3
+ * corriendo en Node (mismo patrón que `dotrino-bots/src/core/transport.js`).
4
+ * Hace el `identify` firmado por la maestra del vault — liga el token efímero de
5
+ * la conexión a la pubkey estable, habilitando el direccionamiento por pubkey
6
+ * (`sendByPubkey`) y la cola offline de 24 h del proxy.
7
+ *
8
+ * IMPORTANTE: `me.publickey` puede ser null en el primer arranque (solo se puebla
9
+ * al fijar un nickname), así que la pubkey maestra se obtiene de forma robusta
10
+ * con un `signData` de cortesía si hace falta. El daemon (vault.js) además puebla
11
+ * `me` antes de conectar.
12
+ */
13
+ import { installNodeGlobals } from './node-globals.js'
14
+
15
+ const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
16
+
17
+ export async function masterPubkeyOf (identity) {
18
+ return identity.me?.publickey || (await identity.signData({ op: 'whoami', ts: Date.now() })).publickey
19
+ }
20
+
21
+ /**
22
+ * Conecta el transporte y lo identifica con la maestra del vault.
23
+ * @param {Object} opts
24
+ * @param {import('@dotrino/identity/node').Identity} opts.identity
25
+ * @param {string} opts.dir Directorio de persistencia.
26
+ * @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
27
+ * @returns {Promise<{ client, token:string, identify():Promise<void> }>}
28
+ */
29
+ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY }) {
30
+ installNodeGlobals(dir)
31
+ // Import dinámico DESPUÉS de instalar los globals que el paquete usa.
32
+ // `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
33
+ // ese es un SINGLETON de proceso, y con multi-perfil el vault necesita una
34
+ // conexión POR PERFIL (cada maestra se identifica con su propia pubkey ante el
35
+ // proxy). Con el singleton, el segundo perfil reusaba el cliente del primero y
36
+ // su `identify` pisaba al anterior. Sigue siendo el cliente oficial del paquete.
37
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
38
+
39
+ // WebRTC off: el vault usa el proxy como transporte (RTCPeerConnection no existe
40
+ // en Node). Reconexión prácticamente ilimitada: un daemon de larga duración no
41
+ // debe rendirse tras unos intentos.
42
+ const client = new WebSocketProxyClient({
43
+ url, enableWebRTC: false, autoReconnect: true,
44
+ maxReconnectAttempts: 100000, reconnectDelay: 4000
45
+ })
46
+
47
+ await client.connect()
48
+
49
+ const identify = async () => {
50
+ const publickey = await masterPubkeyOf(identity)
51
+ if (!publickey || !client.token) return
52
+ const data = { op: 'identify', publickey, token: client.token, ts: Date.now() }
53
+ const { signature } = await identity.signData(data)
54
+ // Con el acta, el proxy bindea también el `profileId`: escribirle a la PERSONA llega
55
+ // a cualquiera de sus dispositivos, no solo a esta bóveda.
56
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
57
+ await client.identify({ data, signature, acta })
58
+ }
59
+ await identify()
60
+ // Re-identificar al reconectar (el token cambia).
61
+ client.on('token', () => { identify().catch(() => {}) })
62
+
63
+ return { client, token: client.token, identify }
64
+ }