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.
@@ -0,0 +1,170 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull } from 'drizzle-orm'
3
+ import { vaultVaults, vaultGroups, vaultEntries } from '../database/schema.mjs'
4
+ import { encrypt, decrypt } from '../utils/crypto.mjs'
5
+ import { checkVaultAccess } from '../utils/auth.mjs'
6
+ import { writeAudit, actorFromReq } from '../utils/audit.mjs'
7
+
8
+ async function getVaultForGroup(db, groupId) {
9
+ const [group] = await db.select().from(vaultGroups).where(eq(vaultGroups.id, groupId))
10
+ if (!group) throw Object.assign(new Error('Group not found'), { status: 404 })
11
+ const [vault] = await db.select().from(vaultVaults).where(eq(vaultVaults.id, group.vault_id))
12
+ if (!vault) throw Object.assign(new Error('Vault not found'), { status: 404 })
13
+ return vault
14
+ }
15
+
16
+ export function createEntriesRouter(db) {
17
+ const router = Router({ mergeParams: true })
18
+
19
+ // GET /groups/:groupId/entries — decrypt all entries
20
+ router.get('/', async (req, res) => {
21
+ try {
22
+ const vault = await getVaultForGroup(db, req.params.groupId)
23
+ const { access } = await checkVaultAccess(db, vault.id, req.user)
24
+ if (!access) return res.status(403).json({ error: 'Forbidden' })
25
+
26
+ const keyHex = vault.key_material
27
+ const rows = await db.select().from(vaultEntries).where(
28
+ and(eq(vaultEntries.group_id, req.params.groupId), isNull(vaultEntries.deleted_at))
29
+ )
30
+ const entries = rows.map(e => {
31
+ const { password, notes = '' } = JSON.parse(
32
+ decrypt(e.encrypted_data, e.iv, e.auth_tag, keyHex)
33
+ )
34
+ return {
35
+ id: e.id, group_id: e.group_id, title: e.title,
36
+ username: e.username, password, notes,
37
+ created_at: e.created_at, updated_at: e.updated_at,
38
+ }
39
+ })
40
+
41
+ // Log sensitive access
42
+ if (entries.length > 0) {
43
+ await writeAudit(db, {
44
+ ...actorFromReq(req),
45
+ action: 'ENTRIES_VIEWED',
46
+ module: 'entry',
47
+ item_label: `Viewed ${entries.length} entries in group ${req.params.groupId}`,
48
+ meta: { group_id: req.params.groupId, vault_id: vault.id }
49
+ })
50
+ }
51
+
52
+ res.json({ entries })
53
+ } catch (err) {
54
+ res.status(err.status || 500).json({ error: err.message || 'Failed to fetch entries' })
55
+ }
56
+ })
57
+
58
+ // POST /groups/:groupId/entries — encrypt and store
59
+ router.post('/', async (req, res) => {
60
+ try {
61
+ const vault = await getVaultForGroup(db, req.params.groupId)
62
+ const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
63
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
64
+
65
+ const { title, username, password, notes = '' } = req.body
66
+ if (!title || !username || !password) {
67
+ return res.status(400).json({ error: 'title, username and password required' })
68
+ }
69
+ const keyHex = vault.key_material
70
+ const enc = encrypt(JSON.stringify({ password, notes }), keyHex)
71
+ const [entry] = await db.insert(vaultEntries).values({
72
+ group_id: req.params.groupId,
73
+ title: title.trim(),
74
+ username: username.trim(),
75
+ ...enc,
76
+ }).returning()
77
+
78
+ await writeAudit(db, {
79
+ ...actorFromReq(req),
80
+ action: 'ENTRY_CREATED',
81
+ module: 'entry',
82
+ item_id: entry.id,
83
+ item_label: entry.title,
84
+ meta: { group_id: req.params.groupId, vault_id: vault.id }
85
+ })
86
+
87
+ res.status(201).json({ entry: { ...entry, password, notes } })
88
+ } catch (err) {
89
+ res.status(err.status || 500).json({ error: err.message || 'Failed to create entry' })
90
+ }
91
+ })
92
+
93
+ // PUT /groups/:groupId/entries/:id — re-encrypt on update
94
+ router.put('/:id', async (req, res) => {
95
+ try {
96
+ const vault = await getVaultForGroup(db, req.params.groupId)
97
+ const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
98
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
99
+
100
+ const [existing] = await db.select().from(vaultEntries).where(
101
+ and(
102
+ eq(vaultEntries.id, req.params.id),
103
+ eq(vaultEntries.group_id, req.params.groupId),
104
+ isNull(vaultEntries.deleted_at)
105
+ )
106
+ )
107
+ if (!existing) return res.status(404).json({ error: 'Entry not found' })
108
+
109
+ const keyHex = vault.key_material
110
+ const old = JSON.parse(decrypt(existing.encrypted_data, existing.iv, existing.auth_tag, keyHex))
111
+
112
+ const merged = {
113
+ password: req.body.password ?? old.password,
114
+ notes: req.body.notes ?? old.notes ?? '',
115
+ }
116
+ const enc = encrypt(JSON.stringify(merged), keyHex)
117
+
118
+ const [entry] = await db.update(vaultEntries).set({
119
+ title: req.body.title ?? existing.title,
120
+ username: req.body.username ?? existing.username,
121
+ ...enc,
122
+ updated_at: new Date(),
123
+ }).where(eq(vaultEntries.id, req.params.id)).returning()
124
+
125
+ await writeAudit(db, {
126
+ ...actorFromReq(req),
127
+ action: 'ENTRY_UPDATED',
128
+ module: 'entry',
129
+ item_id: entry.id,
130
+ item_label: entry.title,
131
+ meta: { group_id: req.params.groupId, vault_id: vault.id }
132
+ })
133
+
134
+ res.json({ entry: { ...entry, ...merged } })
135
+ } catch (err) {
136
+ res.status(err.status || 500).json({ error: err.message || 'Failed to update entry' })
137
+ }
138
+ })
139
+
140
+ // DELETE /groups/:groupId/entries/:id — soft delete only
141
+ router.delete('/:id', async (req, res) => {
142
+ try {
143
+ const vault = await getVaultForGroup(db, req.params.groupId)
144
+ const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
145
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
146
+
147
+ const [entry] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, req.params.id))
148
+ if (!entry) return res.status(404).json({ error: 'Entry not found' })
149
+
150
+ await db.update(vaultEntries).set({ deleted_at: new Date() }).where(
151
+ and(eq(vaultEntries.id, req.params.id), eq(vaultEntries.group_id, req.params.groupId))
152
+ )
153
+
154
+ await writeAudit(db, {
155
+ ...actorFromReq(req),
156
+ action: 'ENTRY_DELETED',
157
+ module: 'entry',
158
+ item_id: req.params.id,
159
+ item_label: entry.title,
160
+ meta: { group_id: req.params.groupId, vault_id: vault.id }
161
+ })
162
+
163
+ res.json({ ok: true })
164
+ } catch (err) {
165
+ res.status(500).json({ error: 'Failed to delete entry' })
166
+ }
167
+ })
168
+
169
+ return router
170
+ }
@@ -0,0 +1,141 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull } from 'drizzle-orm'
3
+ import { vaultGrants } from '../database/schema.mjs'
4
+ import { expiresInToDate } from '../utils/crypto.mjs'
5
+ import { checkVaultAccess, getVaultIdForScope } from '../utils/auth.mjs'
6
+ import { writeAudit, actorFromReq } from '../utils/audit.mjs'
7
+
8
+ export function createGrantsRouter(db) {
9
+ const router = Router()
10
+
11
+ // POST /grants — create or renew a grant
12
+ router.post('/', async (req, res) => {
13
+ try {
14
+ const { scope, scope_id, grantee_id, expires_in } = req.body
15
+ if (!scope || !scope_id || !grantee_id) {
16
+ return res.status(400).json({ error: 'scope, scope_id and grantee_id required' })
17
+ }
18
+ if (!['vault', 'group', 'entry'].includes(scope)) {
19
+ return res.status(400).json({ error: 'scope must be vault, group, or entry' })
20
+ }
21
+
22
+ // Check if grantor is the owner of the vault containing this scope
23
+ const vaultId = await getVaultIdForScope(db, scope, scope_id)
24
+ if (!vaultId) return res.status(404).json({ error: 'Resource not found' })
25
+
26
+ const { access, isOwner } = await checkVaultAccess(db, vaultId, req.user, true)
27
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
28
+
29
+ const expires_at = expiresInToDate(expires_in)
30
+
31
+ // Upsert — if grant already exists (even revoked), renew it
32
+ const [grant] = await db
33
+ .insert(vaultGrants)
34
+ .values({
35
+ scope,
36
+ scope_id,
37
+ grantee_id,
38
+ granted_by_id: req.user.id,
39
+ expires_at,
40
+ revoked_at: null,
41
+ })
42
+ .onConflictDoUpdate({
43
+ target: [vaultGrants.scope, vaultGrants.scope_id, vaultGrants.grantee_id],
44
+ set: {
45
+ granted_by_id: req.user.id,
46
+ expires_at,
47
+ revoked_at: null,
48
+ created_at: new Date(),
49
+ },
50
+ })
51
+ .returning()
52
+
53
+ await writeAudit(db, {
54
+ ...actorFromReq(req),
55
+ action: 'GRANT_CREATED',
56
+ module: 'grant',
57
+ item_id: grant.id,
58
+ item_label: `Access to ${scope} granted to ${grantee_id}`,
59
+ meta: { scope, scope_id, grantee_id, vault_id: vaultId }
60
+ })
61
+
62
+ res.status(201).json({ grant })
63
+ } catch (err) {
64
+ console.error('[vault] POST /grants', err.message)
65
+ res.status(500).json({ error: 'Failed to create grant' })
66
+ }
67
+ })
68
+
69
+ // GET /grants — list grants issued by current user
70
+ router.get('/', async (req, res) => {
71
+ try {
72
+ const grants = await db
73
+ .select()
74
+ .from(vaultGrants)
75
+ .where(
76
+ and(
77
+ eq(vaultGrants.granted_by_id, req.user.id),
78
+ isNull(vaultGrants.revoked_at)
79
+ )
80
+ )
81
+ res.json({ grants })
82
+ } catch (err) {
83
+ res.status(500).json({ error: 'Failed to fetch grants' })
84
+ }
85
+ })
86
+
87
+ // GET /grants/received — grants received by current user
88
+ router.get('/received', async (req, res) => {
89
+ try {
90
+ const grants = await db
91
+ .select()
92
+ .from(vaultGrants)
93
+ .where(
94
+ and(
95
+ eq(vaultGrants.grantee_id, req.user.id),
96
+ isNull(vaultGrants.revoked_at)
97
+ )
98
+ )
99
+ res.json({ grants })
100
+ } catch (err) {
101
+ res.status(500).json({ error: 'Failed to fetch received grants' })
102
+ }
103
+ })
104
+
105
+ // DELETE /grants/:id — revoke
106
+ router.delete('/:id', async (req, res) => {
107
+ try {
108
+ const [grant] = await db
109
+ .select()
110
+ .from(vaultGrants)
111
+ .where(eq(vaultGrants.id, req.params.id))
112
+
113
+ if (!grant) return res.status(404).json({ error: 'Grant not found' })
114
+
115
+ // Only the grantor or an admin can revoke
116
+ if (grant.granted_by_id !== req.user.id && req.user.role !== 'admin') {
117
+ return res.status(403).json({ error: 'Forbidden' })
118
+ }
119
+
120
+ await db
121
+ .update(vaultGrants)
122
+ .set({ revoked_at: new Date() })
123
+ .where(eq(vaultGrants.id, req.params.id))
124
+
125
+ await writeAudit(db, {
126
+ ...actorFromReq(req),
127
+ action: 'GRANT_REVOKED',
128
+ module: 'grant',
129
+ item_id: grant.id,
130
+ item_label: `Access to ${grant.scope} revoked from ${grant.grantee_id}`,
131
+ meta: { scope: grant.scope, scope_id: grant.scope_id, grantee_id: grant.grantee_id }
132
+ })
133
+
134
+ res.json({ ok: true })
135
+ } catch (err) {
136
+ res.status(500).json({ error: 'Failed to revoke grant' })
137
+ }
138
+ })
139
+
140
+ return router
141
+ }
@@ -0,0 +1,127 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull, sql } from 'drizzle-orm'
3
+ import { vaultGroups, vaultEntries } from '../database/schema.mjs'
4
+ import { checkVaultAccess } from '../utils/auth.mjs'
5
+ import { writeAudit, actorFromReq } from '../utils/audit.mjs'
6
+
7
+ export function createGroupsRouter(db) {
8
+ const router = Router({ mergeParams: true })
9
+
10
+ // GET /vaults/:vaultId/groups
11
+ router.get('/', async (req, res) => {
12
+ try {
13
+ const { access } = await checkVaultAccess(db, req.params.vaultId, req.user)
14
+ if (!access) return res.status(403).json({ error: 'Forbidden' })
15
+
16
+ const groups = await db.select().from(vaultGroups)
17
+ .where(eq(vaultGroups.vault_id, req.params.vaultId))
18
+
19
+ // Entry counts per group
20
+ const withCounts = await Promise.all(groups.map(async (g) => {
21
+ const [{ count }] = await db
22
+ .select({ count: sql`count(*)::int` })
23
+ .from(vaultEntries)
24
+ .where(and(eq(vaultEntries.group_id, g.id), isNull(vaultEntries.deleted_at)))
25
+ return { ...g, entry_count: count ?? 0 }
26
+ }))
27
+
28
+ res.json({ groups: withCounts })
29
+ } catch (err) {
30
+ res.status(500).json({ error: 'Failed to fetch groups' })
31
+ }
32
+ })
33
+
34
+ // POST /vaults/:vaultId/groups
35
+ router.post('/', async (req, res) => {
36
+ try {
37
+ const { access, isOwner } = await checkVaultAccess(db, req.params.vaultId, req.user, true)
38
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
39
+
40
+ const { name, domain } = req.body
41
+ if (!name || !domain) return res.status(400).json({ error: 'name and domain required' })
42
+ const [group] = await db.insert(vaultGroups).values({
43
+ vault_id: req.params.vaultId,
44
+ name: name.trim(),
45
+ domain: domain.trim().toLowerCase(),
46
+ }).returning()
47
+
48
+ await writeAudit(db, {
49
+ ...actorFromReq(req),
50
+ action: 'GROUP_CREATED',
51
+ module: 'group',
52
+ item_id: group.id,
53
+ item_label: group.name,
54
+ meta: { vault_id: req.params.vaultId }
55
+ })
56
+
57
+ res.status(201).json({ group: { ...group, entry_count: 0 } })
58
+ } catch (err) {
59
+ if (err.code === '23505') {
60
+ return res.status(409).json({ error: 'This domain already exists in the vault' })
61
+ }
62
+ res.status(500).json({ error: 'Failed to create group' })
63
+ }
64
+ })
65
+
66
+ // PUT /vaults/:vaultId/groups/:id
67
+ router.put('/:id', async (req, res) => {
68
+ try {
69
+ const { access, isOwner } = await checkVaultAccess(db, req.params.vaultId, req.user, true)
70
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
71
+
72
+ const updates = {}
73
+ if (req.body.name) updates.name = req.body.name.trim()
74
+ if (req.body.domain) updates.domain = req.body.domain.trim().toLowerCase()
75
+ if (!Object.keys(updates).length) return res.status(400).json({ error: 'Nothing to update' })
76
+
77
+ const [group] = await db.update(vaultGroups).set(updates)
78
+ .where(and(eq(vaultGroups.id, req.params.id), eq(vaultGroups.vault_id, req.params.vaultId)))
79
+ .returning()
80
+
81
+ if (!group) return res.status(404).json({ error: 'Group not found' })
82
+
83
+ await writeAudit(db, {
84
+ ...actorFromReq(req),
85
+ action: 'GROUP_UPDATED',
86
+ module: 'group',
87
+ item_id: group.id,
88
+ item_label: group.name,
89
+ meta: { vault_id: req.params.vaultId }
90
+ })
91
+
92
+ res.json({ group })
93
+ } catch (err) {
94
+ res.status(500).json({ error: 'Failed to update group' })
95
+ }
96
+ })
97
+
98
+ // DELETE /vaults/:vaultId/groups/:id
99
+ router.delete('/:id', async (req, res) => {
100
+ try {
101
+ const { access, isOwner } = await checkVaultAccess(db, req.params.vaultId, req.user, true)
102
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
103
+
104
+ const [group] = await db.select().from(vaultGroups).where(eq(vaultGroups.id, req.params.id))
105
+ if (!group) return res.status(404).json({ error: 'Group not found' })
106
+
107
+ await db.delete(vaultGroups).where(
108
+ and(eq(vaultGroups.id, req.params.id), eq(vaultGroups.vault_id, req.params.vaultId))
109
+ )
110
+
111
+ await writeAudit(db, {
112
+ ...actorFromReq(req),
113
+ action: 'GROUP_DELETED',
114
+ module: 'group',
115
+ item_id: req.params.id,
116
+ item_label: group.name,
117
+ meta: { vault_id: req.params.vaultId }
118
+ })
119
+
120
+ res.json({ ok: true })
121
+ } catch (err) {
122
+ res.status(500).json({ error: 'Failed to delete group' })
123
+ }
124
+ })
125
+
126
+ return router
127
+ }
@@ -0,0 +1,50 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull, sql } from 'drizzle-orm'
3
+ import { vaultVaults, vaultGrants, vaultEntries } from '../database/schema.mjs'
4
+
5
+ export function createStatsRouter(db) {
6
+ const router = Router()
7
+
8
+ // GET /stats — dashboard widget data for current user
9
+ router.get('/', async (req, res) => {
10
+ try {
11
+ const userId = req.user.id
12
+
13
+ const [{ vault_count }] = await db
14
+ .select({ vault_count: sql`count(*)::int` })
15
+ .from(vaultVaults)
16
+ .where(eq(vaultVaults.owner_id, userId))
17
+
18
+ const [{ active_grants }] = await db
19
+ .select({ active_grants: sql`count(*)::int` })
20
+ .from(vaultGrants)
21
+ .where(
22
+ and(
23
+ eq(vaultGrants.granted_by_id, userId),
24
+ isNull(vaultGrants.revoked_at)
25
+ )
26
+ )
27
+
28
+ const [{ entry_count }] = await db
29
+ .select({ entry_count: sql`count(*)::int` })
30
+ .from(vaultEntries)
31
+ .where(isNull(vaultEntries.deleted_at))
32
+
33
+ res.json({
34
+ vault_count: vault_count ?? 0,
35
+ active_grants: active_grants ?? 0,
36
+ entry_count: entry_count ?? 0,
37
+ })
38
+ } catch (err) {
39
+ console.error('[vault] GET /stats', err.message)
40
+ res.status(500).json({ error: 'Failed to fetch stats' })
41
+ }
42
+ })
43
+
44
+ // GET /me — current user info
45
+ router.get('/me', (req, res) => {
46
+ res.json({ user: req.user })
47
+ })
48
+
49
+ return router
50
+ }
@@ -0,0 +1,156 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull, or, sql, gt } from 'drizzle-orm'
3
+ import { vaultVaults, vaultGrants } from '../database/schema.mjs'
4
+ import { generateVaultKey } from '../utils/crypto.mjs'
5
+ import { checkVaultAccess, getVaultIdForScope } from '../utils/auth.mjs'
6
+ import { writeAudit, actorFromReq } from '../utils/audit.mjs'
7
+
8
+ export function createVaultsRouter(db) {
9
+ const router = Router()
10
+
11
+ // GET /vaults — list owned + shared vaults
12
+ router.get('/', async (req, res) => {
13
+ try {
14
+ const userId = req.user.id
15
+ const owned = await db.select().from(vaultVaults).where(eq(vaultVaults.owner_id, userId))
16
+
17
+ // Find all active grants for the user
18
+ const activeGrants = await db.select().from(vaultGrants).where(
19
+ and(
20
+ eq(vaultGrants.grantee_id, userId),
21
+ isNull(vaultGrants.revoked_at),
22
+ or(
23
+ isNull(vaultGrants.expires_at),
24
+ gt(vaultGrants.expires_at, sql`now()`)
25
+ )
26
+ )
27
+ )
28
+
29
+ // Resolve vault IDs for all grants
30
+ const vaultIds = new Set()
31
+ for (const grant of activeGrants) {
32
+ if (grant.scope === 'vault') {
33
+ vaultIds.add(grant.scope_id)
34
+ } else {
35
+ const vId = await getVaultIdForScope(db, grant.scope, grant.scope_id)
36
+ if (vId) vaultIds.add(vId)
37
+ }
38
+ }
39
+
40
+ let shared = []
41
+ const activeGrantIds = Array.from(vaultIds)
42
+ if (activeGrantIds.length > 0) {
43
+ shared = await db.select().from(vaultVaults).where(
44
+ sql`${vaultVaults.id} = ANY(ARRAY[${sql.raw(activeGrantIds.map(id => `'${id}'`).join(','))}]::uuid[])`
45
+ )
46
+ }
47
+
48
+ const ownedIds = new Set(owned.map(v => v.id))
49
+ const all = [
50
+ ...owned.map(v => ({ ...v, is_owner: true })),
51
+ ...shared.filter(v => !ownedIds.has(v.id)).map(v => ({ ...v, is_owner: false })),
52
+ ].map(({ key_material, ...safe }) => safe) // never send key to client
53
+
54
+ res.json({ vaults: all })
55
+ } catch (err) {
56
+ console.error('[vault] GET /vaults', err.message)
57
+ res.status(500).json({ error: 'Failed to fetch vaults' })
58
+ }
59
+ })
60
+
61
+ // POST /vaults — create vault with fresh AES key
62
+ router.post('/', async (req, res) => {
63
+ try {
64
+ const { name } = req.body
65
+ if (!name?.trim()) return res.status(400).json({ error: 'name required' })
66
+ const [vault] = await db.insert(vaultVaults).values({
67
+ name: name.trim(),
68
+ owner_id: req.user.id,
69
+ key_material: generateVaultKey(),
70
+ }).returning()
71
+
72
+ await writeAudit(db, {
73
+ ...actorFromReq(req),
74
+ action: 'VAULT_CREATED',
75
+ module: 'vault',
76
+ item_id: vault.id,
77
+ item_label: vault.name,
78
+ })
79
+
80
+ const { key_material, ...safe } = vault
81
+ res.status(201).json({ vault: { ...safe, is_owner: true } })
82
+ } catch (err) {
83
+ console.error('[vault] POST /vaults', err.message)
84
+ res.status(500).json({ error: 'Failed to create vault' })
85
+ }
86
+ })
87
+
88
+ // GET /vaults/:id
89
+ router.get('/:id', async (req, res) => {
90
+ try {
91
+ const { access, isOwner, vault } = await checkVaultAccess(db, req.params.id, req.user)
92
+ if (!vault) return res.status(404).json({ error: 'Vault not found' })
93
+ if (!access) return res.status(403).json({ error: 'Forbidden' })
94
+
95
+ const { key_material, ...safe } = vault
96
+ res.json({ vault: { ...safe, is_owner: isOwner } })
97
+ } catch (err) {
98
+ res.status(500).json({ error: 'Failed to fetch vault' })
99
+ }
100
+ })
101
+
102
+ // PUT /vaults/:id
103
+ router.put('/:id', async (req, res) => {
104
+ try {
105
+ const { name } = req.body
106
+ if (!name?.trim()) return res.status(400).json({ error: 'name required' })
107
+
108
+ const { access, isOwner, vault } = await checkVaultAccess(db, req.params.id, req.user, true)
109
+ if (!vault) return res.status(404).json({ error: 'Vault not found' })
110
+ if (!access) return res.status(403).json({ error: 'Forbidden' })
111
+
112
+ const [updated] = await db.update(vaultVaults)
113
+ .set({ name: name.trim() })
114
+ .where(eq(vaultVaults.id, req.params.id))
115
+ .returning()
116
+
117
+ await writeAudit(db, {
118
+ ...actorFromReq(req),
119
+ action: 'VAULT_UPDATED',
120
+ module: 'vault',
121
+ item_id: updated.id,
122
+ item_label: updated.name,
123
+ })
124
+
125
+ const { key_material, ...safe } = updated
126
+ res.json({ vault: { ...safe, is_owner: isOwner } })
127
+ } catch (err) {
128
+ res.status(500).json({ error: 'Failed to update vault' })
129
+ }
130
+ })
131
+
132
+ // DELETE /vaults/:id
133
+ router.delete('/:id', async (req, res) => {
134
+ try {
135
+ const { access, vault } = await checkVaultAccess(db, req.params.id, req.user, true)
136
+ if (!vault) return res.status(404).json({ error: 'Vault not found' })
137
+ if (!access) return res.status(403).json({ error: 'Forbidden' })
138
+
139
+ await db.delete(vaultVaults).where(eq(vaultVaults.id, req.params.id))
140
+
141
+ await writeAudit(db, {
142
+ ...actorFromReq(req),
143
+ action: 'VAULT_DELETED',
144
+ module: 'vault',
145
+ item_id: req.params.id,
146
+ item_label: vault.name,
147
+ })
148
+
149
+ res.json({ ok: true })
150
+ } catch (err) {
151
+ res.status(500).json({ error: 'Failed to delete vault' })
152
+ }
153
+ })
154
+
155
+ return router
156
+ }
@@ -0,0 +1,57 @@
1
+ import { vaultAuditLogs } from '../database/schema.mjs'
2
+
3
+ /**
4
+ * Audit logging utility.
5
+ * Every mutating action in vault-server calls writeAudit().
6
+ * Errors are swallowed so an audit failure never breaks the main request.
7
+ */
8
+
9
+ /**
10
+ * @param {object} db — Drizzle DB instance
11
+ * @param {object} params
12
+ * @param {string} params.actor_id
13
+ * @param {string} params.actor_name
14
+ * @param {string} params.actor_email
15
+ * @param {string} params.action — e.g. 'VAULT_CREATED'
16
+ * @param {string} params.module — 'vault' | 'group' | 'entry' | 'grant'
17
+ * @param {string} params.item_id — UUID of the resource
18
+ * @param {string} params.item_label — human name e.g. vault name, entry title
19
+ * @param {object} [params.meta] — extra context
20
+ */
21
+ export async function writeAudit(db, {
22
+ actor_id,
23
+ actor_name = '',
24
+ actor_email = '',
25
+ action,
26
+ module,
27
+ item_id = '',
28
+ item_label = '',
29
+ meta = null,
30
+ }) {
31
+ try {
32
+ await db.insert(vaultAuditLogs).values({
33
+ actor_id,
34
+ actor_name,
35
+ actor_email,
36
+ action,
37
+ module,
38
+ item_id: String(item_id),
39
+ item_label,
40
+ meta: meta ? JSON.stringify(meta) : null,
41
+ })
42
+ } catch (err) {
43
+ // Audit failures must never break the main request path
44
+ console.error('[vault-audit] write failed:', err.message)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Convenience: extract actor fields from req.user
50
+ */
51
+ export function actorFromReq(req) {
52
+ return {
53
+ actor_id: req.user?.id ?? 'unknown',
54
+ actor_name: req.user?.full_name ?? req.user?.name ?? '',
55
+ actor_email: req.user?.email ?? '',
56
+ }
57
+ }