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.
@@ -0,0 +1,217 @@
1
+ import { Router } from 'express'
2
+ import { eq, and, isNull } from 'drizzle-orm'
3
+ import { vaultVaults, 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 getVault(db, vaultId) {
9
+ const [vault] = await db.select().from(vaultVaults).where(eq(vaultVaults.id, vaultId))
10
+ if (!vault) throw Object.assign(new Error('Vault not found'), { status: 404 })
11
+ return vault
12
+ }
13
+
14
+ export function createEntriesRouter(db) {
15
+ const router = Router({ mergeParams: true })
16
+
17
+ // GET /vaults/:vaultId/entries — decrypt all entries
18
+ router.get('/', async (req, res) => {
19
+ try {
20
+ const vault = await getVault(db, req.params.vaultId)
21
+ const { access } = await checkVaultAccess(db, vault.id, req.user)
22
+ if (!access) return res.status(403).json({ error: 'Forbidden' })
23
+
24
+ const keyHex = vault.key_material
25
+ const rows = await db.select().from(vaultEntries).where(
26
+ and(eq(vaultEntries.vault_id, req.params.vaultId), isNull(vaultEntries.deleted_at))
27
+ )
28
+ const entries = rows.map(e => {
29
+ let password = null
30
+ let notes = ''
31
+
32
+ if (e.encrypted_data) {
33
+ try {
34
+ const data = JSON.parse(decrypt(e.encrypted_data, e.iv, e.auth_tag, keyHex))
35
+ password = data.password
36
+ notes = data.notes || ''
37
+ } catch (err) {
38
+ console.error('[vault] Decrypt error for entry', e.id, err.message)
39
+ }
40
+ }
41
+
42
+ return {
43
+ id: e.id,
44
+ vault_id: e.vault_id,
45
+ domain: e.domain || '',
46
+ username: e.username || '',
47
+ password,
48
+ notes,
49
+ alias: e.alias || '',
50
+ created_at: e.created_at,
51
+ updated_at: e.updated_at,
52
+ }
53
+ })
54
+
55
+ // Log sensitive access
56
+ if (entries.length > 0) {
57
+ await writeAudit(db, {
58
+ ...actorFromReq(req),
59
+ action: 'ENTRIES_VIEWED',
60
+ module: 'entry',
61
+ item_label: `Viewed ${entries.length} entries in vault ${req.params.vaultId}`,
62
+ meta: { vault_id: vault.id }
63
+ })
64
+ }
65
+
66
+ res.json({ entries })
67
+ } catch (err) {
68
+ res.status(err.status || 500).json({ error: err.message || 'Failed to fetch entries' })
69
+ }
70
+ })
71
+
72
+ // POST /vaults/:vaultId/entries — encrypt and store
73
+ router.post('/', async (req, res) => {
74
+ try {
75
+ const vault = await getVault(db, req.params.vaultId)
76
+ const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
77
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
78
+
79
+ const { alias, username, password, domain, notes = '' } = req.body
80
+
81
+ // Validation: at least one of domain, username, or password
82
+ if (!domain?.trim() && !username?.trim() && !password?.trim()) {
83
+ return res.status(400).json({ error: 'At least one field (Domain, Username, or Password) must have a value' })
84
+ }
85
+
86
+ const keyHex = vault.key_material
87
+ let enc = { encrypted_data: null, iv: null, auth_tag: null }
88
+
89
+ if (password?.trim() || notes?.trim()) {
90
+ enc = encrypt(JSON.stringify({ password: password || null, notes }), keyHex)
91
+ }
92
+
93
+ const [entry] = await db.insert(vaultEntries).values({
94
+ vault_id: req.params.vaultId,
95
+ domain: domain?.trim() || null,
96
+ username: username?.trim() || null,
97
+ alias: alias?.trim() || null,
98
+ ...enc,
99
+ }).returning()
100
+
101
+ await writeAudit(db, {
102
+ ...actorFromReq(req),
103
+ action: 'ENTRY_CREATED',
104
+ module: 'entry',
105
+ item_id: entry.id,
106
+ item_label: entry.alias || entry.domain || entry.username || 'Unnamed Entry',
107
+ meta: { vault_id: vault.id, domain: entry.domain }
108
+ })
109
+
110
+ res.status(201).json({ entry: { ...entry, password, notes } })
111
+ } catch (err) {
112
+ console.error('[vault] POST /entries', err.message)
113
+ res.status(err.status || 500).json({ error: err.message || 'Failed to create entry' })
114
+ }
115
+ })
116
+
117
+ // PUT /vaults/:vaultId/entries/:id — re-encrypt on update
118
+ router.put('/:id', async (req, res) => {
119
+ try {
120
+ const vault = await getVault(db, req.params.vaultId)
121
+ const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
122
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
123
+
124
+ const [existing] = await db.select().from(vaultEntries).where(
125
+ and(
126
+ eq(vaultEntries.id, req.params.id),
127
+ eq(vaultEntries.vault_id, req.params.vaultId),
128
+ isNull(vaultEntries.deleted_at)
129
+ )
130
+ )
131
+ if (!existing) return res.status(404).json({ error: 'Entry not found' })
132
+
133
+ const keyHex = vault.key_material
134
+ let old = { password: null, notes: '' }
135
+ if (existing.encrypted_data) {
136
+ try {
137
+ old = JSON.parse(decrypt(existing.encrypted_data, existing.iv, existing.auth_tag, keyHex))
138
+ } catch (err) {
139
+ console.error('[vault] Decrypt error for update', existing.id)
140
+ }
141
+ }
142
+
143
+ const newPassword = req.body.password !== undefined ? req.body.password : old.password
144
+ const newNotes = req.body.notes !== undefined ? req.body.notes : old.notes || ''
145
+ const newDomain = req.body.domain !== undefined ? req.body.domain : existing.domain
146
+ const newUsername = req.body.username !== undefined ? req.body.username : existing.username
147
+ const newAlias = req.body.alias !== undefined ? req.body.alias : existing.alias
148
+
149
+ // Validation: at least one of domain, username, or password
150
+ if (!newDomain?.trim() && !newUsername?.trim() && !newPassword?.trim()) {
151
+ return res.status(400).json({ error: 'At least one field (Domain, Username, or Password) must have a value' })
152
+ }
153
+
154
+ let enc = { encrypted_data: existing.encrypted_data, iv: existing.iv, auth_tag: existing.auth_tag }
155
+ if (req.body.password !== undefined || req.body.notes !== undefined) {
156
+ if (newPassword?.trim() || newNotes?.trim()) {
157
+ enc = encrypt(JSON.stringify({ password: newPassword || null, notes: newNotes }), keyHex)
158
+ } else {
159
+ enc = { encrypted_data: null, iv: null, auth_tag: null }
160
+ }
161
+ }
162
+
163
+ const [entry] = await db.update(vaultEntries).set({
164
+ alias: newAlias?.trim() || null,
165
+ username: newUsername?.trim() || null,
166
+ domain: newDomain?.trim() || null,
167
+ ...enc,
168
+ updated_at: new Date(),
169
+ }).where(eq(vaultEntries.id, req.params.id)).returning()
170
+
171
+ await writeAudit(db, {
172
+ ...actorFromReq(req),
173
+ action: 'ENTRY_UPDATED',
174
+ module: 'entry',
175
+ item_id: entry.id,
176
+ item_label: entry.alias || entry.domain || entry.username || 'Unnamed Entry',
177
+ meta: { vault_id: vault.id, domain: entry.domain }
178
+ })
179
+
180
+ res.json({ entry: { ...entry, password: newPassword, notes: newNotes } })
181
+ } catch (err) {
182
+ console.error('[vault] PUT /entries', err.message)
183
+ res.status(err.status || 500).json({ error: err.message || 'Failed to update entry' })
184
+ }
185
+ })
186
+
187
+ // DELETE /vaults/:vaultId/entries/:id — soft delete only
188
+ router.delete('/:id', async (req, res) => {
189
+ try {
190
+ const vault = await getVault(db, req.params.vaultId)
191
+ const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
192
+ if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
193
+
194
+ const [entry] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, req.params.id))
195
+ if (!entry) return res.status(404).json({ error: 'Entry not found' })
196
+
197
+ await db.update(vaultEntries).set({ deleted_at: new Date() }).where(
198
+ and(eq(vaultEntries.id, req.params.id), eq(vaultEntries.vault_id, req.params.vaultId))
199
+ )
200
+
201
+ await writeAudit(db, {
202
+ ...actorFromReq(req),
203
+ action: 'ENTRY_DELETED',
204
+ module: 'entry',
205
+ item_id: req.params.id,
206
+ item_label: entry.alias || entry.domain || entry.username || 'Unnamed Entry',
207
+ meta: { vault_id: vault.id }
208
+ })
209
+
210
+ res.json({ ok: true })
211
+ } catch (err) {
212
+ res.status(500).json({ error: 'Failed to delete entry' })
213
+ }
214
+ })
215
+
216
+ return router
217
+ }
@@ -0,0 +1,136 @@
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', 'entry'].includes(scope)) {
19
+ return res.status(400).json({ error: 'scope must be vault 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
+ // Note: we don't have a unique constraint on vaultGrants in schema.mjs (it doesn't specify one)
33
+ // but the logic here assumes one.
34
+ // Actually, drizzle onConflict requires a target.
35
+
36
+ const [grant] = await db
37
+ .insert(vaultGrants)
38
+ .values({
39
+ scope,
40
+ scope_id,
41
+ grantee_id,
42
+ granted_by_id: req.user.id,
43
+ expires_at,
44
+ revoked_at: null,
45
+ })
46
+ .returning()
47
+
48
+ await writeAudit(db, {
49
+ ...actorFromReq(req),
50
+ action: 'GRANT_CREATED',
51
+ module: 'grant',
52
+ item_id: grant.id,
53
+ item_label: `Access to ${scope} granted to ${grantee_id}`,
54
+ meta: { scope, scope_id, grantee_id, vault_id: vaultId }
55
+ })
56
+
57
+ res.status(201).json({ grant })
58
+ } catch (err) {
59
+ console.error('[vault] POST /grants', err.message)
60
+ res.status(500).json({ error: 'Failed to create grant' })
61
+ }
62
+ })
63
+
64
+ // GET /grants — list grants issued by current user
65
+ router.get('/', async (req, res) => {
66
+ try {
67
+ const grants = await db
68
+ .select()
69
+ .from(vaultGrants)
70
+ .where(
71
+ and(
72
+ eq(vaultGrants.granted_by_id, req.user.id),
73
+ isNull(vaultGrants.revoked_at)
74
+ )
75
+ )
76
+ res.json({ grants })
77
+ } catch (err) {
78
+ res.status(500).json({ error: 'Failed to fetch grants' })
79
+ }
80
+ })
81
+
82
+ // GET /grants/received — grants received by current user
83
+ router.get('/received', async (req, res) => {
84
+ try {
85
+ const grants = await db
86
+ .select()
87
+ .from(vaultGrants)
88
+ .where(
89
+ and(
90
+ eq(vaultGrants.grantee_id, req.user.id),
91
+ isNull(vaultGrants.revoked_at)
92
+ )
93
+ )
94
+ res.json({ grants })
95
+ } catch (err) {
96
+ res.status(500).json({ error: 'Failed to fetch received grants' })
97
+ }
98
+ })
99
+
100
+ // DELETE /grants/:id — revoke
101
+ router.delete('/:id', async (req, res) => {
102
+ try {
103
+ const [grant] = await db
104
+ .select()
105
+ .from(vaultGrants)
106
+ .where(eq(vaultGrants.id, req.params.id))
107
+
108
+ if (!grant) return res.status(404).json({ error: 'Grant not found' })
109
+
110
+ // Only the grantor or an admin can revoke
111
+ if (grant.granted_by_id !== req.user.id && req.user.role !== 'admin') {
112
+ return res.status(403).json({ error: 'Forbidden' })
113
+ }
114
+
115
+ await db
116
+ .update(vaultGrants)
117
+ .set({ revoked_at: new Date() })
118
+ .where(eq(vaultGrants.id, req.params.id))
119
+
120
+ await writeAudit(db, {
121
+ ...actorFromReq(req),
122
+ action: 'GRANT_REVOKED',
123
+ module: 'grant',
124
+ item_id: grant.id,
125
+ item_label: `Access to ${grant.scope} revoked from ${grant.grantee_id}`,
126
+ meta: { scope: grant.scope, scope_id: grant.scope_id, grantee_id: grant.grantee_id }
127
+ })
128
+
129
+ res.json({ ok: true })
130
+ } catch (err) {
131
+ res.status(500).json({ error: 'Failed to delete grant' })
132
+ }
133
+ })
134
+
135
+ return router
136
+ }
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ import { eq, and, isNull, or, sql, gt } from 'drizzle-orm'
2
+ import { vaultVaults, vaultGrants, vaultEntries } from '../database/schema.mjs'
3
+
4
+ /**
5
+ * Checks if a user has access to a vault.
6
+ * @returns {Promise<{ access: boolean, isOwner: boolean, vault: any }>}
7
+ */
8
+ export async function checkVaultAccess(db, vaultId, user, requireOwner = false) {
9
+ const [vault] = await db.select().from(vaultVaults).where(eq(vaultVaults.id, vaultId))
10
+ if (!vault) return { access: false, isOwner: false, vault: null }
11
+
12
+ const isOwner = vault.owner_id === user.id || user.role === 'admin'
13
+ if (isOwner) return { access: true, isOwner: true, vault }
14
+
15
+ if (requireOwner) return { access: false, isOwner: false, vault }
16
+
17
+ // Check for any active grant for this user that covers this vault or any children
18
+ const activeGrants = await db.select().from(vaultGrants).where(
19
+ and(
20
+ eq(vaultGrants.grantee_id, user.id),
21
+ isNull(vaultGrants.revoked_at),
22
+ or(
23
+ isNull(vaultGrants.expires_at),
24
+ gt(vaultGrants.expires_at, sql`now()`)
25
+ )
26
+ )
27
+ )
28
+
29
+ // Filter grants that belong to this vault
30
+ for (const grant of activeGrants) {
31
+ if (grant.scope === 'vault' && grant.scope_id === vaultId) return { access: true, isOwner: false, vault }
32
+
33
+ // Check if it's a child resource of this vault
34
+ const grantVaultId = await getVaultIdForScope(db, grant.scope, grant.scope_id)
35
+ if (grantVaultId === vaultId) return { access: true, isOwner: false, vault }
36
+ }
37
+
38
+ return { access: false, isOwner: false, vault }
39
+ }
40
+
41
+ /**
42
+ * Finds the vaultId for a given scope and id.
43
+ */
44
+ export async function getVaultIdForScope(db, scope, id) {
45
+ if (scope === 'vault') return id
46
+ // 'group' scope removed
47
+ if (scope === 'entry') {
48
+ const [e] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, id))
49
+ return e?.vault_id || null
50
+ }
51
+ return null
52
+ }