meyi-vault-server-dev 1.0.1

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/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # vault-server
2
+
3
+ > Self-hosted AES-256-GCM encrypted password manager — Express plugin for MeyiConnect
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install vault-server
9
+ ```
10
+
11
+ ## MeyiConnect plugin integration
12
+
13
+ Copy the wrapper into your MeyiConnect backend:
14
+
15
+ ```
16
+ backend/src/plugins/vault/index.mjs
17
+ ```
18
+
19
+ ```js
20
+ import { install, start, stop } from 'vault-server'
21
+ import { verifyToken } from '../../middleware/auth.mjs'
22
+
23
+ export async function install() {
24
+ await install()
25
+ }
26
+
27
+ export async function start(app, config, db) {
28
+ await start(app, config, db, verifyToken)
29
+ }
30
+
31
+ export async function stop() {
32
+ await stop()
33
+ }
34
+ ```
35
+
36
+ MeyiConnect's `pluginService` will call `install()` once and `start()` on each boot.
37
+
38
+ ## Standalone usage
39
+
40
+ ```js
41
+ import express from 'express'
42
+ import { install, start } from 'vault-server'
43
+
44
+ const app = express()
45
+
46
+ // Your own auth middleware that sets req.user = { id, role, email }
47
+ const myAuth = (req, res, next) => { /* ... */ next() }
48
+
49
+ await install() // create DB tables
50
+ await start(app, {}, null, myAuth) // mount at /api/v1/vault
51
+
52
+ app.listen(4000)
53
+ ```
54
+
55
+ ## Environment variables
56
+
57
+ | Variable | Default | Description |
58
+ |-------------------|------------------|--------------------------------------|
59
+ | `DATABASE_URL` | required | PostgreSQL connection string |
60
+ | `VAULT_DB_SCHEMA` | `meyiconnect` | PostgreSQL schema for vault tables |
61
+ | `VAULT_MOUNT_PATH`| `/api/v1/vault` | Express mount path |
62
+
63
+ ## API routes
64
+
65
+ All routes require `req.user` set by the injected auth middleware.
66
+
67
+ ```
68
+ GET /api/v1/vault/vaults
69
+ POST /api/v1/vault/vaults
70
+ DELETE /api/v1/vault/vaults/:id
71
+
72
+ GET /api/v1/vault/vaults/:vaultId/groups
73
+ POST /api/v1/vault/vaults/:vaultId/groups
74
+ PUT /api/v1/vault/vaults/:vaultId/groups/:id
75
+ DELETE /api/v1/vault/vaults/:vaultId/groups/:id
76
+
77
+ GET /api/v1/vault/groups/:groupId/entries
78
+ POST /api/v1/vault/groups/:groupId/entries
79
+ PUT /api/v1/vault/groups/:groupId/entries/:id
80
+ DELETE /api/v1/vault/groups/:groupId/entries/:id (soft delete)
81
+
82
+ POST /api/v1/vault/grants
83
+ GET /api/v1/vault/grants
84
+ GET /api/v1/vault/grants/received
85
+ DELETE /api/v1/vault/grants/:id
86
+
87
+ GET /api/v1/vault/stats
88
+ ```
89
+
90
+ ## Security
91
+
92
+ - **AES-256-GCM** — authenticated encryption, throws on tampered ciphertext
93
+ - **Per-vault keys** — each vault has its own random 32-byte key
94
+ - **Soft deletes** — entries are never hard-deleted; `deleted_at` timestamp set
95
+ - **No auth code** — delegates entirely to the host's auth middleware via `verifyToken` injection
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "meyi-vault-server-dev",
3
+ "version": "1.0.1",
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,17 @@
1
+ import { Pool } from 'pg'
2
+ import { runMigrations } from '../src/database/migrate.mjs'
3
+
4
+ const pool = new Pool({
5
+ connectionString: "postgresql://postgres:root@localhost:5432/postgres",
6
+ })
7
+
8
+ console.log('Running migrations...')
9
+ runMigrations(pool, 'meyiconnect')
10
+ .then(() => {
11
+ console.log('Done')
12
+ process.exit(0)
13
+ })
14
+ .catch(err => {
15
+ console.error('Migration failed:', err)
16
+ process.exit(1)
17
+ })
@@ -0,0 +1,138 @@
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
+ -- Entries: AES-256-GCM encrypted credentials
23
+ -- Columns are initially created with the new naming/optionality
24
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_entries (
25
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
26
+ vault_id UUID NOT NULL REFERENCES "${schemaName}".vault_vaults(id) ON DELETE CASCADE,
27
+ domain TEXT DEFAULT '',
28
+ username TEXT,
29
+ encrypted_data TEXT,
30
+ iv TEXT,
31
+ auth_tag TEXT,
32
+ alias TEXT,
33
+ deleted_at TIMESTAMPTZ,
34
+ created_at TIMESTAMPTZ DEFAULT NOW(),
35
+ updated_at TIMESTAMPTZ DEFAULT NOW()
36
+ );
37
+
38
+ -- Migration Logic: Handle existing tables from previous versions
39
+ DO $$
40
+ BEGIN
41
+ -- 1. Rename title to alias if title exists
42
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schemaName}' AND table_name = 'vault_entries' AND column_name = 'title') THEN
43
+ ALTER TABLE "${schemaName}".vault_entries RENAME COLUMN title TO alias;
44
+ END IF;
45
+
46
+ -- 2. Make columns nullable for optional fields
47
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN domain DROP NOT NULL;
48
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN username DROP NOT NULL;
49
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN encrypted_data DROP NOT NULL;
50
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN iv DROP NOT NULL;
51
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN auth_tag DROP NOT NULL;
52
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN alias DROP NOT NULL;
53
+
54
+ -- 3. Add vault_id if it doesn't exist (from older group-based schema)
55
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schemaName}' AND table_name = 'vault_entries' AND column_name = 'vault_id') THEN
56
+ ALTER TABLE "${schemaName}".vault_entries ADD COLUMN vault_id UUID REFERENCES "${schemaName}".vault_vaults(id) ON DELETE CASCADE;
57
+ END IF;
58
+
59
+ -- 4. If group_id still exists, try to backfill vault_id and domain from vault_groups
60
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schemaName}' AND table_name = 'vault_entries' AND column_name = 'group_id') THEN
61
+ UPDATE "${schemaName}".vault_entries e
62
+ SET vault_id = g.vault_id,
63
+ domain = g.domain
64
+ FROM "${schemaName}".vault_groups g
65
+ WHERE e.group_id = g.id AND e.vault_id IS NULL;
66
+
67
+ -- Drop old references
68
+ DROP INDEX IF EXISTS idx_vault_entries_group_active;
69
+ ALTER TABLE "${schemaName}".vault_entries DROP COLUMN group_id;
70
+ END IF;
71
+
72
+ -- Ensure vault_id is NOT NULL
73
+ ALTER TABLE "${schemaName}".vault_entries ALTER COLUMN vault_id SET NOT NULL;
74
+ END $$;
75
+
76
+ -- Grants: time-limited shared access
77
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_grants (
78
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
79
+ scope TEXT NOT NULL CHECK (scope IN ('vault','entry')),
80
+ scope_id UUID NOT NULL,
81
+ grantee_id UUID NOT NULL,
82
+ granted_by_id UUID NOT NULL,
83
+ expires_at TIMESTAMPTZ,
84
+ revoked_at TIMESTAMPTZ,
85
+ created_at TIMESTAMPTZ DEFAULT NOW(),
86
+ UNIQUE(scope, scope_id, grantee_id)
87
+ );
88
+
89
+ -- Index for fast grant lookups by grantee
90
+ CREATE INDEX IF NOT EXISTS idx_vault_grants_grantee
91
+ ON "${schemaName}".vault_grants (grantee_id)
92
+ WHERE revoked_at IS NULL;
93
+
94
+ -- Index for soft-delete queries on entries
95
+ CREATE INDEX IF NOT EXISTS idx_vault_entries_vault_active
96
+ ON "${schemaName}".vault_entries (vault_id)
97
+ WHERE deleted_at IS NULL;
98
+
99
+ -- Audit logs: immutable activity trail
100
+ CREATE TABLE IF NOT EXISTS "${schemaName}".vault_audit_logs (
101
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
102
+ actor_id TEXT NOT NULL,
103
+ actor_name TEXT NOT NULL DEFAULT '',
104
+ actor_email TEXT NOT NULL DEFAULT '',
105
+ action TEXT NOT NULL,
106
+ module TEXT NOT NULL,
107
+ item_id TEXT NOT NULL DEFAULT '',
108
+ item_label TEXT NOT NULL DEFAULT '',
109
+ meta TEXT,
110
+ created_at TIMESTAMPTZ DEFAULT NOW()
111
+ );
112
+
113
+ CREATE INDEX IF NOT EXISTS idx_vault_audit_created
114
+ ON "${schemaName}".vault_audit_logs (created_at DESC);
115
+
116
+ -- Cleanup old groups table
117
+ DROP TABLE IF EXISTS "${schemaName}".vault_groups;
118
+ `)
119
+
120
+ console.log(`[vault-server] ✅ Migrations complete (schema: ${schemaName})`)
121
+ } finally {
122
+ client.release()
123
+ }
124
+ }
125
+
126
+ export async function dropTables(pool, schemaName = 'meyiconnect') {
127
+ const client = await pool.connect()
128
+ try {
129
+ await client.query(`
130
+ DROP TABLE IF EXISTS "${schemaName}".vault_grants;
131
+ DROP TABLE IF EXISTS "${schemaName}".vault_entries;
132
+ DROP TABLE IF EXISTS "${schemaName}".vault_vaults;
133
+ `)
134
+ console.log(`[vault-server] Tables dropped (schema: ${schemaName})`)
135
+ } finally {
136
+ client.release()
137
+ }
138
+ }
@@ -0,0 +1,75 @@
1
+ import { pgSchema, text, timestamp, uuid, unique } from 'drizzle-orm/pg-core'
2
+
3
+ /**
4
+ * Vault Schema Definitions
5
+ * ─────────────────────────────────────────
6
+ * We use a dedicated schema 'meyiconnect' (or configurable) to avoid
7
+ * polluting the public namespace.
8
+ */
9
+
10
+ export const vaultSchema = pgSchema('meyiconnect')
11
+
12
+ // ── vault_vaults ──────────────────────────────────────────────────────────────
13
+ // Top-level container for credentials. Each vault has its own AES-256 master key.
14
+ export const vaultVaults = vaultSchema.table('vault_vaults', {
15
+ id: uuid('id').defaultRandom().primaryKey(),
16
+ name: text('name').notNull(),
17
+ owner_id: uuid('owner_id').notNull(),
18
+ key_material: text('key_material').notNull().default(''), // The "Vault Key" (hex)
19
+ created_at: timestamp('created_at').defaultNow(),
20
+ })
21
+
22
+ // ── vault_entries ─────────────────────────────────────────────────────────────
23
+ // One credential per entry. password+notes encrypted with AES-256-GCM.
24
+ export const vaultEntries = vaultSchema.table('vault_entries', {
25
+ id: uuid('id').defaultRandom().primaryKey(),
26
+ vault_id: uuid('vault_id').notNull().references(() => vaultVaults.id, { onDelete: 'cascade' }),
27
+ domain: text('domain').default(''), // e.g. "console.aws.amazon.com"
28
+ username: text('username'), // plaintext — not a secret
29
+ encrypted_data: text('encrypted_data'), // base64 AES-GCM ciphertext
30
+ iv: text('iv'), // base64 12-byte IV
31
+ auth_tag: text('auth_tag'), // base64 16-byte GCM auth tag
32
+ alias: text('alias'), // was 'title', now at the end
33
+ deleted_at: timestamp('deleted_at'), // soft delete — never hard delete
34
+ created_at: timestamp('created_at').defaultNow(),
35
+ updated_at: timestamp('updated_at').defaultNow(),
36
+ })
37
+
38
+ // ── vault_grants ──────────────────────────────────────────────────────────────
39
+ // Fine-grained sharing. Users can grant access to a whole vault or just one entry.
40
+ export const vaultGrants = vaultSchema.table('vault_grants', {
41
+ id: uuid('id').defaultRandom().primaryKey(),
42
+ // vault | entry
43
+ scope: text('scope').notNull(),
44
+ scope_id: uuid('scope_id').notNull(),
45
+ // User receiving the access
46
+ grantee_id: uuid('grantee_id').notNull(),
47
+ // User who granted the access
48
+ granted_by_id: uuid('granted_by_id').notNull(),
49
+ expires_at: timestamp('expires_at'), // null = no expiry
50
+ revoked_at: timestamp('revoked_at'), // null = still active
51
+ created_at: timestamp('created_at').defaultNow(),
52
+ }, (t) => ({
53
+ unq: unique().on(t.scope, t.scope_id, t.grantee_id)
54
+ }))
55
+
56
+ // ── vault_audit_logs ──────────────────────────────────────────────────────────
57
+ // Immutable audit trail — never updated or deleted.
58
+ // Every mutation in vaults/entries/grants writes one row here.
59
+ export const vaultAuditLogs = vaultSchema.table('vault_audit_logs', {
60
+ id: uuid('id').defaultRandom().primaryKey(),
61
+ // Who performed the action
62
+ actor_id: text('actor_id').notNull(),
63
+ actor_name: text('actor_name').notNull().default(''),
64
+ actor_email: text('actor_email').notNull().default(''),
65
+ // What happened: VAULT_CREATED | ENTRY_CREATED | GRANT_CREATED etc.
66
+ action: text('action').notNull(),
67
+ // Which module: vault | entry | grant
68
+ module: text('module').notNull(),
69
+ // The resource that was acted on
70
+ item_id: text('item_id').notNull().default(''),
71
+ item_label: text('item_label').notNull().default(''),
72
+ // Extra context (JSON)
73
+ meta: text('meta'),
74
+ created_at: timestamp('created_at').defaultNow(),
75
+ })
package/src/index.mjs ADDED
@@ -0,0 +1,135 @@
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 { createEntriesRouter } from './routes/entries.mjs'
26
+ import { createGrantsRouter } from './routes/grants.mjs'
27
+ import { createStatsRouter } from './routes/stats.mjs'
28
+ import { createAuditRouter } from './routes/audit.mjs'
29
+ import * as schema from './database/schema.mjs'
30
+
31
+ const SCHEMA = process.env.VAULT_DB_SCHEMA || 'meyiconnect'
32
+ const MOUNT = process.env.VAULT_MOUNT_PATH || '/api/v1/vault'
33
+
34
+ // Module-level singletons — pool lives for the process lifetime
35
+ let _pool = null
36
+ let _db = null
37
+ let _mounted = false
38
+
39
+ function getPool() {
40
+ if (!_pool) {
41
+ if (!process.env.DATABASE_URL) {
42
+ throw new Error('[vault-server] DATABASE_URL is not set')
43
+ }
44
+ _pool = new Pool({
45
+ connectionString: process.env.DATABASE_URL,
46
+ max: 10,
47
+ idleTimeoutMillis: 30_000,
48
+ })
49
+ _pool.on('error', (err) => {
50
+ console.error('[vault-server] Pool error:', err.message)
51
+ })
52
+ }
53
+ return _pool
54
+ }
55
+
56
+ function getDb() {
57
+ if (!_db) {
58
+ _db = drizzle(getPool(), { schema })
59
+ }
60
+ return _db
61
+ }
62
+
63
+ // ── Plugin lifecycle ──────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * install() — idempotent table creation.
67
+ * Safe to run on every app start; uses CREATE TABLE IF NOT EXISTS.
68
+ */
69
+ export async function install() {
70
+ console.log('[vault-server] install() — running migrations...')
71
+ await runMigrations(getPool(), SCHEMA)
72
+ }
73
+
74
+ /**
75
+ * start(app, config, hostDb) — mount all vault routes.
76
+ *
77
+ * @param {import('express').Application} app — Express app from host
78
+ * @param {object} config — plugin config row from DB (unused, reserved)
79
+ * @param {object} hostDb — host's Drizzle instance (not used; we use own pool)
80
+ * @param {Function} verifyToken — host's auth middleware (injected by host)
81
+ */
82
+ export async function start(app, config = {}, hostDb = null, verifyToken) {
83
+ if (_mounted) {
84
+ console.log('[vault-server] Already mounted, skipping')
85
+ return
86
+ }
87
+
88
+ const db = getDb()
89
+ const router = Router()
90
+
91
+ // Vault CRUD
92
+ router.use('/vaults', createVaultsRouter(db))
93
+
94
+ // Entry CRUD (nested under vault param)
95
+ router.use('/vaults/:vaultId/entries', createEntriesRouter(db))
96
+
97
+ // Access grants
98
+ router.use('/grants', createGrantsRouter(db))
99
+
100
+ // Dashboard stats widget
101
+ router.use('/stats', createStatsRouter(db))
102
+
103
+ // Audit logs
104
+ router.use('/audit', createAuditRouter(db))
105
+
106
+ // Mount on Express app
107
+ // verifyToken is injected by the host so we don't couple to its internals
108
+ if (verifyToken) {
109
+ app.use(MOUNT, verifyToken, router)
110
+ } else {
111
+ // Fallback: mount without auth (host should protect at a higher level)
112
+ console.warn('[vault-server] ⚠️ No verifyToken provided — routes are unprotected!')
113
+ app.use(MOUNT, router)
114
+ }
115
+
116
+ _mounted = true
117
+ console.log(`[vault-server] ✅ Mounted at ${MOUNT}`)
118
+ }
119
+
120
+ /**
121
+ * stop() — graceful teardown on plugin disable / app shutdown.
122
+ */
123
+ export async function stop() {
124
+ if (_pool) {
125
+ await _pool.end()
126
+ _pool = null
127
+ _db = null
128
+ }
129
+ _mounted = false
130
+ console.log('[vault-server] stopped')
131
+ }
132
+
133
+ // Named export for direct use outside MeyiConnect
134
+ export { schema as vaultSchema }
135
+ export { encrypt, decrypt, generateVaultKey } from './utils/crypto.mjs'
@@ -0,0 +1,80 @@
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
+ // Only admins can see audit logs
14
+ if (req.user?.role !== 'admin') {
15
+ return res.status(403).json({ error: 'Forbidden: Admin access required' })
16
+ }
17
+
18
+ const page = Math.max(1, parseInt(req.query.page) || 1)
19
+ const limit = Math.min(100, parseInt(req.query.limit) || 25)
20
+ const offset = (page - 1) * limit
21
+
22
+ const conditions = []
23
+
24
+ if (req.query.module) {
25
+ conditions.push(eq(vaultAuditLogs.module, req.query.module))
26
+ }
27
+ if (req.query.action) {
28
+ conditions.push(eq(vaultAuditLogs.action, req.query.action))
29
+ }
30
+ if (req.query.actor_id) {
31
+ conditions.push(eq(vaultAuditLogs.actor_id, req.query.actor_id))
32
+ }
33
+ if (req.query.search) {
34
+ const pattern = `%${req.query.search}%`
35
+ conditions.push(
36
+ or(
37
+ ilike(vaultAuditLogs.actor_name, pattern),
38
+ ilike(vaultAuditLogs.actor_email, pattern),
39
+ ilike(vaultAuditLogs.item_label, pattern)
40
+ )
41
+ )
42
+ }
43
+
44
+ const where = conditions.length > 0 ? and(...conditions) : undefined
45
+
46
+ // Get total count
47
+ const [countRes] = await db
48
+ .select({ total: count() })
49
+ .from(vaultAuditLogs)
50
+ .where(where)
51
+
52
+ const total = countRes.total
53
+
54
+ // Get logs
55
+ const logs = await db
56
+ .select()
57
+ .from(vaultAuditLogs)
58
+ .where(where)
59
+ .orderBy(desc(vaultAuditLogs.created_at))
60
+ .limit(limit)
61
+ .offset(offset)
62
+
63
+ res.json({
64
+ logs: logs.map(l => ({
65
+ ...l,
66
+ meta: l.meta ? JSON.parse(l.meta) : null
67
+ })),
68
+ total,
69
+ page,
70
+ limit,
71
+ total_pages: Math.max(1, Math.ceil(total / limit))
72
+ })
73
+ } catch (err) {
74
+ console.error('[vault] GET /audit ERROR:', err)
75
+ res.status(500).json({ error: 'Failed to fetch audit logs', details: err.message })
76
+ }
77
+ })
78
+
79
+ return router
80
+ }
@@ -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
+ }