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 +95 -0
- package/package.json +33 -0
- package/scratch/migrate-up.mjs +17 -0
- package/src/database/migrate.mjs +138 -0
- package/src/database/schema.mjs +75 -0
- package/src/index.mjs +135 -0
- package/src/routes/audit.mjs +80 -0
- package/src/routes/domains.mjs +80 -0
- package/src/routes/entries.mjs +217 -0
- package/src/routes/grants.mjs +136 -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 +52 -0
- package/src/utils/crypto.mjs +97 -0
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
|
+
}
|