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/README.md +95 -0
- package/dist/index.mjs +37038 -0
- package/package.json +33 -0
- package/src/database/migrate.mjs +110 -0
- package/src/database/schema.mjs +89 -0
- package/src/index.mjs +139 -0
- package/src/routes/audit.mjs +75 -0
- package/src/routes/domains.mjs +80 -0
- package/src/routes/entries.mjs +170 -0
- package/src/routes/grants.mjs +141 -0
- package/src/routes/groups.mjs +127 -0
- package/src/routes/stats.mjs +50 -0
- package/src/routes/vaults.mjs +156 -0
- package/src/utils/audit.mjs +57 -0
- package/src/utils/auth.mjs +60 -0
- package/src/utils/crypto.mjs +97 -0
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
|
+
}
|