meyi-vault-server 1.0.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.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "meyi-vault-server",
3
+ "version": "1.0.0",
4
+ "description": "Self-hosted encrypted password manager — server plugin for MeyiConnect",
5
+ "type": "module",
6
+ "main": "src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./database": "./src/database/schema.mjs",
10
+ "./plugin": "./src/plugin.mjs"
11
+ },
12
+ "scripts": {
13
+ "dev": "node --watch src/index.mjs",
14
+ "build": "esbuild src/index.mjs --bundle --platform=node --format=esm --outfile=dist/index.mjs",
15
+ "test": "node --test src/**/*.test.mjs"
16
+ },
17
+ "keywords": ["password-manager", "vault", "meyiconnect", "plugin", "encrypted"],
18
+ "author": "Meyi Technologies",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "drizzle-orm": "^0.44.7",
22
+ "pg": "^8.18.0"
23
+ },
24
+ "devDependencies": {
25
+ "esbuild": "0.25.5"
26
+ },
27
+ "peerDependencies": {
28
+ "express": "^4.x"
29
+ },
30
+ "engines": {
31
+ "node": ">=20.0.0"
32
+ }
33
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Creates all vault tables inside the host's PostgreSQL schema.
3
+ * Uses raw SQL (IF NOT EXISTS) so it is safe to run multiple times.
4
+ * Called by the plugin's install() lifecycle method.
5
+ */
6
+
7
+ export async function runMigrations(pool, schemaName = 'meyiconnect') {
8
+ const client = await pool.connect()
9
+ try {
10
+ await client.query(`SET search_path TO "${schemaName}", public`)
11
+
12
+ await client.query(`
13
+ -- Vaults: top-level container, one AES key per vault
14
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_vaults (
15
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
16
+ name TEXT NOT NULL,
17
+ owner_id UUID NOT NULL,
18
+ key_material TEXT NOT NULL DEFAULT '',
19
+ created_at TIMESTAMPTZ DEFAULT NOW()
20
+ );
21
+
22
+ -- Groups: domain buckets inside a vault
23
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_groups (
24
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
25
+ vault_id UUID NOT NULL
26
+ REFERENCES "${schemaName}".vault_vaults(id)
27
+ ON DELETE CASCADE,
28
+ domain TEXT NOT NULL,
29
+ name TEXT NOT NULL,
30
+ created_at TIMESTAMPTZ DEFAULT NOW(),
31
+ UNIQUE(vault_id, domain)
32
+ );
33
+
34
+ -- Entries: AES-256-GCM encrypted credentials
35
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_entries (
36
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
37
+ group_id UUID NOT NULL
38
+ REFERENCES "${schemaName}".vault_groups(id)
39
+ ON DELETE CASCADE,
40
+ title TEXT NOT NULL,
41
+ username TEXT NOT NULL,
42
+ encrypted_data TEXT NOT NULL,
43
+ iv TEXT NOT NULL,
44
+ auth_tag TEXT NOT NULL,
45
+ deleted_at TIMESTAMPTZ,
46
+ created_at TIMESTAMPTZ DEFAULT NOW(),
47
+ updated_at TIMESTAMPTZ DEFAULT NOW()
48
+ );
49
+
50
+ -- Grants: time-limited shared access
51
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_grants (
52
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
53
+ scope TEXT NOT NULL CHECK (scope IN ('vault','group','entry')),
54
+ scope_id UUID NOT NULL,
55
+ grantee_id UUID NOT NULL,
56
+ granted_by_id UUID NOT NULL,
57
+ expires_at TIMESTAMPTZ,
58
+ revoked_at TIMESTAMPTZ,
59
+ created_at TIMESTAMPTZ DEFAULT NOW(),
60
+ UNIQUE(scope, scope_id, grantee_id)
61
+ );
62
+
63
+ -- Index for fast grant lookups by grantee
64
+ CREATE INDEX IF NOT EXISTS idx_vault_grants_grantee
65
+ ON "${schemaName}".vault_grants (grantee_id)
66
+ WHERE revoked_at IS NULL;
67
+
68
+ -- Index for soft-delete queries on entries
69
+ CREATE INDEX IF NOT EXISTS idx_vault_entries_group_active
70
+ ON "${schemaName}".vault_entries (group_id)
71
+ WHERE deleted_at IS NULL;
72
+
73
+ -- Audit logs: immutable activity trail
74
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_audit_logs (
75
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
76
+ actor_id TEXT NOT NULL,
77
+ actor_name TEXT NOT NULL DEFAULT '',
78
+ actor_email TEXT NOT NULL DEFAULT '',
79
+ action TEXT NOT NULL,
80
+ module TEXT NOT NULL,
81
+ item_id TEXT NOT NULL DEFAULT '',
82
+ item_label TEXT NOT NULL DEFAULT '',
83
+ meta TEXT,
84
+ created_at TIMESTAMPTZ DEFAULT NOW()
85
+ );
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_vault_audit_created
88
+ ON "${schemaName}".vault_audit_logs (created_at DESC);
89
+ `)
90
+
91
+ console.log(`[vault-server] ✅ Migrations complete (schema: ${schemaName})`)
92
+ } finally {
93
+ client.release()
94
+ }
95
+ }
96
+
97
+ export async function dropTables(pool, schemaName = 'meyiconnect') {
98
+ const client = await pool.connect()
99
+ try {
100
+ await client.query(`
101
+ DROP TABLE IF EXISTS "${schemaName}".vault_grants;
102
+ DROP TABLE IF EXISTS "${schemaName}".vault_entries;
103
+ DROP TABLE IF EXISTS "${schemaName}".vault_groups;
104
+ DROP TABLE IF EXISTS "${schemaName}".vault_vaults;
105
+ `)
106
+ console.log(`[vault-server] Tables dropped (schema: ${schemaName})`)
107
+ } finally {
108
+ client.release()
109
+ }
110
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * vault-server/database
3
+ * ─────────────────────
4
+ * Exports all Drizzle table definitions so the host app (MeyiConnect)
5
+ * can import them for migrations, type inference, and query building.
6
+ *
7
+ * Usage in plugin:
8
+ * import * as vaultSchema from 'vault-server/database'
9
+ */
10
+
11
+ import { pgSchema, uuid, text, timestamp } from 'drizzle-orm/pg-core'
12
+
13
+ // All vault tables live inside the host's PostgreSQL schema.
14
+ // Schema name is injected at runtime via VAULT_DB_SCHEMA env var
15
+ // (defaults to 'meyiconnect' to co-exist with MeyiConnect's tables).
16
+ const schemaName = process.env.VAULT_DB_SCHEMA || 'meyiconnect'
17
+ const vaultSchema = pgSchema(schemaName)
18
+
19
+ // ── vault_vaults ──────────────────────────────────────────────────────────────
20
+ export const vaultVaults = vaultSchema.table('vault_vaults', {
21
+ id: uuid('id').defaultRandom().primaryKey(),
22
+ name: text('name').notNull(),
23
+ owner_id: uuid('owner_id').notNull(),
24
+ // AES-256 key stored as hex. In production, encrypt this column using
25
+ // PostgreSQL pgcrypto or an HSM. For self-hosted this provides
26
+ // encryption-at-rest via full-disk encryption on the DB server.
27
+ key_material: text('key_material').notNull(),
28
+ created_at: timestamp('created_at').defaultNow(),
29
+ })
30
+
31
+ // ── vault_groups ──────────────────────────────────────────────────────────────
32
+ // A "group" maps a domain to a set of credentials.
33
+ // The browser extension uses the domain for auto-match on login pages.
34
+ export const vaultGroups = vaultSchema.table('vault_groups', {
35
+ id: uuid('id').defaultRandom().primaryKey(),
36
+ vault_id: uuid('vault_id').notNull().references(() => vaultVaults.id, { onDelete: 'cascade' }),
37
+ domain: text('domain').notNull(), // e.g. "console.aws.amazon.com"
38
+ name: text('name').notNull(), // e.g. "AWS Console"
39
+ created_at: timestamp('created_at').defaultNow(),
40
+ })
41
+
42
+ // ── vault_entries ─────────────────────────────────────────────────────────────
43
+ // One credential per entry. password+notes encrypted with AES-256-GCM.
44
+ export const vaultEntries = vaultSchema.table('vault_entries', {
45
+ id: uuid('id').defaultRandom().primaryKey(),
46
+ group_id: uuid('group_id').notNull().references(() => vaultGroups.id, { onDelete: 'cascade' }),
47
+ title: text('title').notNull(),
48
+ username: text('username').notNull(), // plaintext — not a secret
49
+ encrypted_data: text('encrypted_data').notNull(), // base64 AES-GCM ciphertext
50
+ iv: text('iv').notNull(), // base64 12-byte IV
51
+ auth_tag: text('auth_tag').notNull(), // base64 16-byte GCM auth tag
52
+ deleted_at: timestamp('deleted_at'), // soft delete — never hard delete
53
+ created_at: timestamp('created_at').defaultNow(),
54
+ updated_at: timestamp('updated_at').defaultNow(),
55
+ })
56
+
57
+ // ── vault_grants ──────────────────────────────────────────────────────────────
58
+ // Time-limited access grants from one user to another for any scope.
59
+ export const vaultGrants = vaultSchema.table('vault_grants', {
60
+ id: uuid('id').defaultRandom().primaryKey(),
61
+ scope: text('scope').notNull(), // 'vault' | 'group' | 'entry'
62
+ scope_id: uuid('scope_id').notNull(), // ID of the scoped resource
63
+ grantee_id: uuid('grantee_id').notNull(),
64
+ granted_by_id: uuid('granted_by_id').notNull(),
65
+ expires_at: timestamp('expires_at'), // null = no expiry
66
+ revoked_at: timestamp('revoked_at'), // null = still active
67
+ created_at: timestamp('created_at').defaultNow(),
68
+ })
69
+
70
+ // ── vault_audit_logs ──────────────────────────────────────────────────────────
71
+ // Immutable audit trail — never updated or deleted.
72
+ // Every mutation in vaults/groups/entries/grants writes one row here.
73
+ export const vaultAuditLogs = vaultSchema.table('vault_audit_logs', {
74
+ id: uuid('id').defaultRandom().primaryKey(),
75
+ // Who performed the action
76
+ actor_id: text('actor_id').notNull(),
77
+ actor_name: text('actor_name').notNull().default(''),
78
+ actor_email: text('actor_email').notNull().default(''),
79
+ // What happened: VAULT_CREATED | GROUP_CREATED | ENTRY_CREATED | GRANT_CREATED etc.
80
+ action: text('action').notNull(),
81
+ // Which module: vault | group | entry | grant
82
+ module: text('module').notNull(),
83
+ // The resource that was acted on
84
+ item_id: text('item_id').notNull().default(''),
85
+ item_label: text('item_label').notNull().default(''),
86
+ // Extra context (JSON)
87
+ meta: text('meta'),
88
+ created_at: timestamp('created_at').defaultNow(),
89
+ })
package/src/index.mjs ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * vault-server — MeyiConnect plugin entry point
3
+ * ─────────────────────────────────────────────
4
+ * Implements the MeyiConnect plugin contract:
5
+ *
6
+ * install() → create DB tables (idempotent)
7
+ * start(app, cfg, db) → mount Express routes at /api/v1/vault
8
+ * stop() → teardown (optional cleanup)
9
+ *
10
+ * The host (MeyiConnect) calls these from pluginService.mjs.
11
+ * Auth is handled by the host's verifyToken middleware — this
12
+ * package does NOT ship its own auth.
13
+ *
14
+ * Environment variables:
15
+ * DATABASE_URL — PostgreSQL connection string (from host)
16
+ * VAULT_DB_SCHEMA — override schema name (default: 'meyiconnect')
17
+ * VAULT_MOUNT_PATH — override mount path (default: '/api/v1/vault')
18
+ */
19
+
20
+ import { Router } from 'express'
21
+ import { Pool } from 'pg'
22
+ import { drizzle } from 'drizzle-orm/node-postgres'
23
+ import { runMigrations } from './database/migrate.mjs'
24
+ import { createVaultsRouter } from './routes/vaults.mjs'
25
+ import { createGroupsRouter } from './routes/groups.mjs'
26
+ import { createEntriesRouter } from './routes/entries.mjs'
27
+ import { createGrantsRouter } from './routes/grants.mjs'
28
+ import { createStatsRouter } from './routes/stats.mjs'
29
+ import { createAuditRouter } from './routes/audit.mjs'
30
+ import * as schema from './database/schema.mjs'
31
+
32
+ const SCHEMA = process.env.VAULT_DB_SCHEMA || 'meyiconnect'
33
+ const MOUNT = process.env.VAULT_MOUNT_PATH || '/api/v1/vault'
34
+
35
+ // Module-level singletons — pool lives for the process lifetime
36
+ let _pool = null
37
+ let _db = null
38
+ let _mounted = false
39
+
40
+ function getPool() {
41
+ if (!_pool) {
42
+ if (!process.env.DATABASE_URL) {
43
+ throw new Error('[vault-server] DATABASE_URL is not set')
44
+ }
45
+ _pool = new Pool({
46
+ connectionString: process.env.DATABASE_URL,
47
+ max: 10,
48
+ idleTimeoutMillis: 30_000,
49
+ })
50
+ _pool.on('error', (err) => {
51
+ console.error('[vault-server] Pool error:', err.message)
52
+ })
53
+ }
54
+ return _pool
55
+ }
56
+
57
+ function getDb() {
58
+ if (!_db) {
59
+ _db = drizzle(getPool(), { schema })
60
+ }
61
+ return _db
62
+ }
63
+
64
+ // ── Plugin lifecycle ──────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * install() — idempotent table creation.
68
+ * Safe to run on every app start; uses CREATE TABLE IF NOT EXISTS.
69
+ */
70
+ export async function install() {
71
+ console.log('[vault-server] install() — running migrations...')
72
+ await runMigrations(getPool(), SCHEMA)
73
+ }
74
+
75
+ /**
76
+ * start(app, config, hostDb) — mount all vault routes.
77
+ *
78
+ * @param {import('express').Application} app — Express app from host
79
+ * @param {object} config — plugin config row from DB (unused, reserved)
80
+ * @param {object} hostDb — host's Drizzle instance (not used; we use own pool)
81
+ * @param {Function} verifyToken — host's auth middleware (injected by host)
82
+ */
83
+ export async function start(app, config = {}, hostDb = null, verifyToken) {
84
+ if (_mounted) {
85
+ console.log('[vault-server] Already mounted, skipping')
86
+ return
87
+ }
88
+
89
+ const db = getDb()
90
+ const router = Router()
91
+
92
+ // Vault CRUD
93
+ router.use('/vaults', createVaultsRouter(db))
94
+
95
+ // Group CRUD (nested under vault param)
96
+ router.use('/vaults/:vaultId/groups', createGroupsRouter(db))
97
+
98
+ // Entry CRUD (nested under group param)
99
+ router.use('/groups/:groupId/entries', createEntriesRouter(db))
100
+
101
+ // Access grants
102
+ router.use('/grants', createGrantsRouter(db))
103
+
104
+ // Dashboard stats widget
105
+ router.use('/stats', createStatsRouter(db))
106
+
107
+ // Audit logs
108
+ router.use('/audit', createAuditRouter(db))
109
+
110
+ // Mount on Express app
111
+ // verifyToken is injected by the host so we don't couple to its internals
112
+ if (verifyToken) {
113
+ app.use(MOUNT, verifyToken, router)
114
+ } else {
115
+ // Fallback: mount without auth (host should protect at a higher level)
116
+ console.warn('[vault-server] ⚠️ No verifyToken provided — routes are unprotected!')
117
+ app.use(MOUNT, router)
118
+ }
119
+
120
+ _mounted = true
121
+ console.log(`[vault-server] ✅ Mounted at ${MOUNT}`)
122
+ }
123
+
124
+ /**
125
+ * stop() — graceful teardown on plugin disable / app shutdown.
126
+ */
127
+ export async function stop() {
128
+ if (_pool) {
129
+ await _pool.end()
130
+ _pool = null
131
+ _db = null
132
+ }
133
+ _mounted = false
134
+ console.log('[vault-server] stopped')
135
+ }
136
+
137
+ // Named export for direct use outside MeyiConnect
138
+ export { schema as vaultSchema }
139
+ export { encrypt, decrypt, generateVaultKey } from './utils/crypto.mjs'
@@ -0,0 +1,75 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, ilike, or, desc, sql, count } from 'drizzle-orm'
3
+ import { vaultAuditLogs } from '../database/schema.mjs'
4
+
5
+ export function createAuditRouter(db) {
6
+ const router = Router()
7
+
8
+ /**
9
+ * GET /audit
10
+ */
11
+ router.get('/', async (req, res) => {
12
+ try {
13
+ const page = Math.max(1, parseInt(req.query.page) || 1)
14
+ const limit = Math.min(100, parseInt(req.query.limit) || 25)
15
+ const offset = (page - 1) * limit
16
+
17
+ const conditions = []
18
+
19
+ if (req.query.module) {
20
+ conditions.push(eq(vaultAuditLogs.module, req.query.module))
21
+ }
22
+ if (req.query.action) {
23
+ conditions.push(eq(vaultAuditLogs.action, req.query.action))
24
+ }
25
+ if (req.query.actor_id) {
26
+ conditions.push(eq(vaultAuditLogs.actor_id, req.query.actor_id))
27
+ }
28
+ if (req.query.search) {
29
+ const pattern = `%${req.query.search}%`
30
+ conditions.push(
31
+ or(
32
+ ilike(vaultAuditLogs.actor_name, pattern),
33
+ ilike(vaultAuditLogs.actor_email, pattern),
34
+ ilike(vaultAuditLogs.item_label, pattern)
35
+ )
36
+ )
37
+ }
38
+
39
+ const where = conditions.length > 0 ? and(...conditions) : undefined
40
+
41
+ // Get total count
42
+ const [countRes] = await db
43
+ .select({ total: count() })
44
+ .from(vaultAuditLogs)
45
+ .where(where)
46
+
47
+ const total = countRes.total
48
+
49
+ // Get logs
50
+ const logs = await db
51
+ .select()
52
+ .from(vaultAuditLogs)
53
+ .where(where)
54
+ .orderBy(desc(vaultAuditLogs.created_at))
55
+ .limit(limit)
56
+ .offset(offset)
57
+
58
+ res.json({
59
+ logs: logs.map(l => ({
60
+ ...l,
61
+ meta: l.meta ? JSON.parse(l.meta) : null
62
+ })),
63
+ total,
64
+ page,
65
+ limit,
66
+ total_pages: Math.max(1, Math.ceil(total / limit))
67
+ })
68
+ } catch (err) {
69
+ console.error('[vault] GET /audit ERROR:', err)
70
+ res.status(500).json({ error: 'Failed to fetch audit logs', details: err.message })
71
+ }
72
+ })
73
+
74
+ return router
75
+ }
@@ -0,0 +1,80 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull, sql } from 'drizzle-orm'
3
+ import { vaultDomains, vaultEntries } from '../database/schema.mjs'
4
+
5
+ export function createDomainsRouter(db) {
6
+ const router = Router({ mergeParams: true })
7
+
8
+ // GET /vaults/:vaultId/domains
9
+ router.get('/', async (req, res) => {
10
+ try {
11
+ const domains = await db.select().from(vaultDomains)
12
+ .where(eq(vaultDomains.vault_id, req.params.vaultId))
13
+
14
+ // Entry counts per domain
15
+ const withCounts = await Promise.all(domains.map(async (d) => {
16
+ const [{ count }] = await db
17
+ .select({ count: sql`count(*)::int` })
18
+ .from(vaultEntries)
19
+ .where(and(eq(vaultEntries.domain_id, d.id), isNull(vaultEntries.deleted_at)))
20
+ return { ...d, entry_count: count ?? 0 }
21
+ }))
22
+
23
+ res.json({ domains: withCounts })
24
+ } catch (err) {
25
+ res.status(500).json({ error: 'Failed to fetch domains' })
26
+ }
27
+ })
28
+
29
+ // POST /vaults/:vaultId/domains
30
+ router.post('/', async (req, res) => {
31
+ try {
32
+ const { name, domain } = req.body
33
+ if (!name || !domain) return res.status(400).json({ error: 'name and domain required' })
34
+ const [domainRecord] = await db.insert(vaultDomains).values({
35
+ vault_id: req.params.vaultId,
36
+ name: name.trim(),
37
+ domain: domain.trim().toLowerCase(),
38
+ }).returning()
39
+ res.status(201).json({ domain: { ...domainRecord, entry_count: 0 } })
40
+ } catch (err) {
41
+ if (err.code === '23505') {
42
+ return res.status(409).json({ error: 'This domain already exists in the vault' })
43
+ }
44
+ res.status(500).json({ error: 'Failed to create domain group' })
45
+ }
46
+ })
47
+
48
+ // PUT /vaults/:vaultId/domains/:id
49
+ router.put('/:id', async (req, res) => {
50
+ try {
51
+ const updates = {}
52
+ if (req.body.name) updates.name = req.body.name.trim()
53
+ if (req.body.domain) updates.domain = req.body.domain.trim().toLowerCase()
54
+ if (!Object.keys(updates).length) return res.status(400).json({ error: 'Nothing to update' })
55
+
56
+ const [domainRecord] = await db.update(vaultDomains).set(updates)
57
+ .where(and(eq(vaultDomains.id, req.params.id), eq(vaultDomains.vault_id, req.params.vaultId)))
58
+ .returning()
59
+
60
+ if (!domainRecord) return res.status(404).json({ error: 'Domain not found' })
61
+ res.json({ domain: domainRecord })
62
+ } catch (err) {
63
+ res.status(500).json({ error: 'Failed to update domain' })
64
+ }
65
+ })
66
+
67
+ // DELETE /vaults/:vaultId/domains/:id
68
+ router.delete('/:id', async (req, res) => {
69
+ try {
70
+ await db.delete(vaultDomains).where(
71
+ and(eq(vaultDomains.id, req.params.id), eq(vaultDomains.vault_id, req.params.vaultId))
72
+ )
73
+ res.json({ ok: true })
74
+ } catch (err) {
75
+ res.status(500).json({ error: 'Failed to delete domain' })
76
+ }
77
+ })
78
+
79
+ return router
80
+ }