@vobase/core 0.10.0 → 0.12.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.
Files changed (188) hide show
  1. package/package.json +7 -9
  2. package/src/__tests__/drizzle-introspection.test.ts +77 -0
  3. package/src/__tests__/e2e.test.ts +225 -0
  4. package/src/__tests__/permissions.test.ts +157 -0
  5. package/src/__tests__/rpc-types.test.ts +92 -0
  6. package/src/app.test.ts +99 -0
  7. package/src/app.ts +178 -0
  8. package/src/audit.test.ts +126 -0
  9. package/src/auth.test.ts +74 -0
  10. package/src/contracts/auth.ts +37 -0
  11. package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
  12. package/src/contracts/notify.ts +47 -0
  13. package/src/contracts/permissions.ts +10 -0
  14. package/src/contracts/storage.ts +61 -0
  15. package/src/ctx.test.ts +162 -0
  16. package/src/ctx.ts +64 -0
  17. package/src/db/client.test.ts +75 -0
  18. package/src/db/client.ts +15 -0
  19. package/src/db/helpers.test.ts +147 -0
  20. package/src/db/helpers.ts +51 -0
  21. package/src/db/index.ts +8 -0
  22. package/{dist/index.d.ts → src/index.ts} +103 -6
  23. package/src/infra/circuit-breaker.test.ts +74 -0
  24. package/src/infra/circuit-breaker.ts +57 -0
  25. package/src/infra/errors.test.ts +175 -0
  26. package/src/infra/errors.ts +64 -0
  27. package/src/infra/http-client.test.ts +482 -0
  28. package/src/infra/http-client.ts +221 -0
  29. package/src/infra/index.ts +35 -0
  30. package/src/infra/job.test.ts +85 -0
  31. package/src/infra/job.ts +94 -0
  32. package/src/infra/logger.test.ts +65 -0
  33. package/src/infra/logger.ts +18 -0
  34. package/src/infra/queue.test.ts +46 -0
  35. package/src/infra/queue.ts +147 -0
  36. package/src/infra/throw-proxy.test.ts +34 -0
  37. package/src/infra/throw-proxy.ts +17 -0
  38. package/src/infra/webhooks-schema.ts +17 -0
  39. package/src/infra/webhooks.test.ts +364 -0
  40. package/src/infra/webhooks.ts +146 -0
  41. package/src/mcp/auth.test.ts +129 -0
  42. package/src/mcp/crud.test.ts +128 -0
  43. package/src/mcp/crud.ts +171 -0
  44. package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
  45. package/src/mcp/server.test.ts +153 -0
  46. package/src/mcp/server.ts +178 -0
  47. package/src/middleware/audit.test.ts +169 -0
  48. package/src/module-registry.ts +18 -0
  49. package/src/module.test.ts +168 -0
  50. package/src/module.ts +111 -0
  51. package/src/modules/audit/index.ts +18 -0
  52. package/src/modules/audit/middleware.ts +33 -0
  53. package/src/modules/audit/schema.ts +35 -0
  54. package/src/modules/audit/track-changes.ts +70 -0
  55. package/src/modules/auth/audit-hooks.ts +74 -0
  56. package/src/modules/auth/index.ts +101 -0
  57. package/src/modules/auth/middleware.ts +51 -0
  58. package/src/modules/auth/permissions.ts +46 -0
  59. package/src/modules/auth/schema.ts +184 -0
  60. package/src/modules/credentials/encrypt.ts +95 -0
  61. package/src/modules/credentials/index.ts +15 -0
  62. package/src/modules/credentials/schema.ts +10 -0
  63. package/src/modules/notify/index.ts +90 -0
  64. package/src/modules/notify/notify.test.ts +145 -0
  65. package/src/modules/notify/providers/resend.ts +47 -0
  66. package/src/modules/notify/providers/smtp.ts +117 -0
  67. package/src/modules/notify/providers/waba.ts +82 -0
  68. package/src/modules/notify/schema.ts +27 -0
  69. package/src/modules/notify/service.ts +93 -0
  70. package/src/modules/sequences/index.ts +15 -0
  71. package/src/modules/sequences/next-sequence.ts +48 -0
  72. package/src/modules/sequences/schema.ts +12 -0
  73. package/src/modules/storage/index.ts +44 -0
  74. package/src/modules/storage/providers/local.ts +124 -0
  75. package/src/modules/storage/providers/s3.ts +83 -0
  76. package/src/modules/storage/routes.ts +76 -0
  77. package/src/modules/storage/schema.ts +26 -0
  78. package/src/modules/storage/service.ts +202 -0
  79. package/src/modules/storage/storage.test.ts +225 -0
  80. package/src/schemas.test.ts +44 -0
  81. package/src/schemas.ts +63 -0
  82. package/src/sequence.test.ts +56 -0
  83. package/dist/app.d.ts +0 -37
  84. package/dist/app.d.ts.map +0 -1
  85. package/dist/contracts/auth.d.ts +0 -35
  86. package/dist/contracts/auth.d.ts.map +0 -1
  87. package/dist/contracts/module.d.ts.map +0 -1
  88. package/dist/contracts/notify.d.ts +0 -46
  89. package/dist/contracts/notify.d.ts.map +0 -1
  90. package/dist/contracts/permissions.d.ts +0 -10
  91. package/dist/contracts/permissions.d.ts.map +0 -1
  92. package/dist/contracts/storage.d.ts +0 -54
  93. package/dist/contracts/storage.d.ts.map +0 -1
  94. package/dist/ctx.d.ts +0 -40
  95. package/dist/ctx.d.ts.map +0 -1
  96. package/dist/db/client.d.ts +0 -4
  97. package/dist/db/client.d.ts.map +0 -1
  98. package/dist/db/helpers.d.ts +0 -26
  99. package/dist/db/helpers.d.ts.map +0 -1
  100. package/dist/db/index.d.ts +0 -3
  101. package/dist/db/index.d.ts.map +0 -1
  102. package/dist/index.d.ts.map +0 -1
  103. package/dist/index.js +0 -98611
  104. package/dist/infra/circuit-breaker.d.ts +0 -17
  105. package/dist/infra/circuit-breaker.d.ts.map +0 -1
  106. package/dist/infra/errors.d.ts +0 -26
  107. package/dist/infra/errors.d.ts.map +0 -1
  108. package/dist/infra/http-client.d.ts +0 -31
  109. package/dist/infra/http-client.d.ts.map +0 -1
  110. package/dist/infra/index.d.ts +0 -11
  111. package/dist/infra/index.d.ts.map +0 -1
  112. package/dist/infra/job.d.ts +0 -14
  113. package/dist/infra/job.d.ts.map +0 -1
  114. package/dist/infra/logger.d.ts +0 -7
  115. package/dist/infra/logger.d.ts.map +0 -1
  116. package/dist/infra/queue.d.ts +0 -18
  117. package/dist/infra/queue.d.ts.map +0 -1
  118. package/dist/infra/throw-proxy.d.ts +0 -7
  119. package/dist/infra/throw-proxy.d.ts.map +0 -1
  120. package/dist/infra/webhooks-schema.d.ts +0 -60
  121. package/dist/infra/webhooks-schema.d.ts.map +0 -1
  122. package/dist/infra/webhooks.d.ts +0 -46
  123. package/dist/infra/webhooks.d.ts.map +0 -1
  124. package/dist/mcp/crud.d.ts +0 -12
  125. package/dist/mcp/crud.d.ts.map +0 -1
  126. package/dist/mcp/index.d.ts.map +0 -1
  127. package/dist/mcp/server.d.ts +0 -16
  128. package/dist/mcp/server.d.ts.map +0 -1
  129. package/dist/module-registry.d.ts +0 -3
  130. package/dist/module-registry.d.ts.map +0 -1
  131. package/dist/module.d.ts +0 -29
  132. package/dist/module.d.ts.map +0 -1
  133. package/dist/modules/audit/index.d.ts +0 -5
  134. package/dist/modules/audit/index.d.ts.map +0 -1
  135. package/dist/modules/audit/middleware.d.ts +0 -3
  136. package/dist/modules/audit/middleware.d.ts.map +0 -1
  137. package/dist/modules/audit/schema.d.ts +0 -247
  138. package/dist/modules/audit/schema.d.ts.map +0 -1
  139. package/dist/modules/audit/track-changes.d.ts +0 -3
  140. package/dist/modules/audit/track-changes.d.ts.map +0 -1
  141. package/dist/modules/auth/audit-hooks.d.ts +0 -6
  142. package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
  143. package/dist/modules/auth/index.d.ts +0 -25
  144. package/dist/modules/auth/index.d.ts.map +0 -1
  145. package/dist/modules/auth/middleware.d.ts +0 -15
  146. package/dist/modules/auth/middleware.d.ts.map +0 -1
  147. package/dist/modules/auth/permissions.d.ts +0 -5
  148. package/dist/modules/auth/permissions.d.ts.map +0 -1
  149. package/dist/modules/auth/schema.d.ts +0 -2519
  150. package/dist/modules/auth/schema.d.ts.map +0 -1
  151. package/dist/modules/credentials/encrypt.d.ts +0 -12
  152. package/dist/modules/credentials/encrypt.d.ts.map +0 -1
  153. package/dist/modules/credentials/index.d.ts +0 -4
  154. package/dist/modules/credentials/index.d.ts.map +0 -1
  155. package/dist/modules/credentials/schema.d.ts +0 -56
  156. package/dist/modules/credentials/schema.d.ts.map +0 -1
  157. package/dist/modules/notify/index.d.ts +0 -36
  158. package/dist/modules/notify/index.d.ts.map +0 -1
  159. package/dist/modules/notify/providers/resend.d.ts +0 -7
  160. package/dist/modules/notify/providers/resend.d.ts.map +0 -1
  161. package/dist/modules/notify/providers/smtp.d.ts +0 -18
  162. package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
  163. package/dist/modules/notify/providers/waba.d.ts +0 -12
  164. package/dist/modules/notify/providers/waba.d.ts.map +0 -1
  165. package/dist/modules/notify/schema.d.ts +0 -337
  166. package/dist/modules/notify/schema.d.ts.map +0 -1
  167. package/dist/modules/notify/service.d.ts +0 -22
  168. package/dist/modules/notify/service.d.ts.map +0 -1
  169. package/dist/modules/sequences/index.d.ts +0 -4
  170. package/dist/modules/sequences/index.d.ts.map +0 -1
  171. package/dist/modules/sequences/next-sequence.d.ts +0 -8
  172. package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
  173. package/dist/modules/sequences/schema.d.ts +0 -72
  174. package/dist/modules/sequences/schema.d.ts.map +0 -1
  175. package/dist/modules/storage/index.d.ts +0 -24
  176. package/dist/modules/storage/index.d.ts.map +0 -1
  177. package/dist/modules/storage/providers/local.d.ts +0 -3
  178. package/dist/modules/storage/providers/local.d.ts.map +0 -1
  179. package/dist/modules/storage/providers/s3.d.ts +0 -3
  180. package/dist/modules/storage/providers/s3.d.ts.map +0 -1
  181. package/dist/modules/storage/routes.d.ts +0 -4
  182. package/dist/modules/storage/routes.d.ts.map +0 -1
  183. package/dist/modules/storage/schema.d.ts +0 -273
  184. package/dist/modules/storage/schema.d.ts.map +0 -1
  185. package/dist/modules/storage/service.d.ts +0 -35
  186. package/dist/modules/storage/service.d.ts.map +0 -1
  187. package/dist/schemas.d.ts +0 -19
  188. package/dist/schemas.d.ts.map +0 -1
@@ -0,0 +1,169 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
3
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
4
+ import { Hono } from 'hono';
5
+
6
+ import type { VobaseDb } from '../db';
7
+ import * as schema from '../modules/audit/schema';
8
+ import { requestAuditMiddleware } from '../modules/audit/middleware';
9
+ import { createAuthAuditHooks } from '../modules/auth/audit-hooks';
10
+
11
+ interface AuditRow {
12
+ event: string;
13
+ actorId: string | null;
14
+ actorEmail: string | null;
15
+ ip: string | null;
16
+ details: string | null;
17
+ }
18
+
19
+ describe('audit middleware and hooks', () => {
20
+ let sqlite: Database;
21
+ let db: VobaseDb;
22
+
23
+ beforeEach(() => {
24
+ sqlite = new Database(':memory:');
25
+ sqlite.run('PRAGMA journal_mode=WAL');
26
+ sqlite.exec(`
27
+ CREATE TABLE _audit_log (
28
+ id TEXT PRIMARY KEY,
29
+ event TEXT NOT NULL,
30
+ actor_id TEXT,
31
+ actor_email TEXT,
32
+ ip TEXT,
33
+ details TEXT,
34
+ created_at INTEGER NOT NULL
35
+ )
36
+ `);
37
+ db = drizzle({ client: sqlite, schema }) as unknown as VobaseDb;
38
+ });
39
+
40
+ afterEach(() => {
41
+ sqlite.close();
42
+ });
43
+
44
+ function getRows(): AuditRow[] {
45
+ return sqlite
46
+ .prepare(
47
+ `
48
+ SELECT
49
+ event,
50
+ actor_id AS actorId,
51
+ actor_email AS actorEmail,
52
+ ip,
53
+ details
54
+ FROM _audit_log
55
+ ORDER BY rowid ASC
56
+ `,
57
+ )
58
+ .all() as AuditRow[];
59
+ }
60
+
61
+ it('logs api_mutation for POST requests', async () => {
62
+ const app = new Hono();
63
+ app.use('/api/*', async (c, next) => {
64
+ c.set('user', {
65
+ id: 'user_post',
66
+ email: 'post@example.com',
67
+ name: 'Post User',
68
+ role: 'user',
69
+ });
70
+ await next();
71
+ });
72
+ app.use('/api/*', requestAuditMiddleware(db));
73
+ app.post('/api/test', (c) => c.json({ ok: true }));
74
+
75
+ const response = await app.request('http://localhost/api/test', {
76
+ method: 'POST',
77
+ headers: { 'x-forwarded-for': '203.0.113.10' },
78
+ });
79
+
80
+ expect(response.status).toBe(200);
81
+
82
+ const rows = getRows();
83
+ expect(rows).toHaveLength(1);
84
+ expect(rows[0]).toEqual({
85
+ event: 'api_mutation',
86
+ actorId: 'user_post',
87
+ actorEmail: 'post@example.com',
88
+ ip: '203.0.113.10',
89
+ details: JSON.stringify({ method: 'POST', path: '/api/test' }),
90
+ });
91
+ });
92
+
93
+ it('does not log GET requests', async () => {
94
+ const app = new Hono();
95
+ app.use('/api/*', async (c, next) => {
96
+ c.set('user', {
97
+ id: 'user_get',
98
+ email: 'get@example.com',
99
+ name: 'Get User',
100
+ role: 'user',
101
+ });
102
+ await next();
103
+ });
104
+ app.use('/api/*', requestAuditMiddleware(db));
105
+ app.get('/api/test', (c) => c.json({ ok: true }));
106
+
107
+ const response = await app.request('http://localhost/api/test', {
108
+ method: 'GET',
109
+ headers: { 'x-forwarded-for': '203.0.113.11' },
110
+ });
111
+
112
+ expect(response.status).toBe(200);
113
+ expect(getRows()).toHaveLength(0);
114
+ });
115
+
116
+ it('logs signin events from auth hooks', async () => {
117
+ const hooks = createAuthAuditHooks(db);
118
+
119
+ await hooks.after({
120
+ path: '/sign-in/email',
121
+ method: 'POST',
122
+ headers: new Headers({ 'x-real-ip': '198.51.100.8' }),
123
+ context: {
124
+ newSession: {
125
+ user: { id: 'user_signin', email: 'signin@example.com' },
126
+ },
127
+ session: null,
128
+ },
129
+ returnHeaders: true,
130
+ } as unknown as Parameters<typeof hooks.after>[0]);
131
+
132
+ const rows = getRows();
133
+ expect(rows).toHaveLength(1);
134
+ expect(rows[0]).toEqual({
135
+ event: 'signin',
136
+ actorId: 'user_signin',
137
+ actorEmail: 'signin@example.com',
138
+ ip: '198.51.100.8',
139
+ details: JSON.stringify({ path: '/sign-in/email' }),
140
+ });
141
+ });
142
+
143
+ it('logs signup events from auth hooks', async () => {
144
+ const hooks = createAuthAuditHooks(db);
145
+
146
+ await hooks.after({
147
+ path: '/sign-up/email',
148
+ method: 'POST',
149
+ headers: new Headers({ 'x-forwarded-for': '192.0.2.25, 10.0.0.1' }),
150
+ context: {
151
+ newSession: {
152
+ user: { id: 'user_signup', email: 'signup@example.com' },
153
+ },
154
+ session: null,
155
+ },
156
+ returnHeaders: true,
157
+ } as unknown as Parameters<typeof hooks.after>[0]);
158
+
159
+ const rows = getRows();
160
+ expect(rows).toHaveLength(1);
161
+ expect(rows[0]).toEqual({
162
+ event: 'signup',
163
+ actorId: 'user_signup',
164
+ actorEmail: 'signup@example.com',
165
+ ip: '192.0.2.25',
166
+ details: JSON.stringify({ path: '/sign-up/email' }),
167
+ });
168
+ });
169
+ });
@@ -0,0 +1,18 @@
1
+ import { conflict } from './infra/errors';
2
+ import type { VobaseModule } from './module';
3
+
4
+ export function registerModules(
5
+ modules: VobaseModule[],
6
+ ): Map<string, VobaseModule> {
7
+ const registry = new Map<string, VobaseModule>();
8
+
9
+ for (const module of modules) {
10
+ if (registry.has(module.name)) {
11
+ throw conflict(`Module "${module.name}"`);
12
+ }
13
+
14
+ registry.set(module.name, module);
15
+ }
16
+
17
+ return registry;
18
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { Hono } from 'hono';
3
+
4
+ import { VobaseError } from './infra/errors';
5
+ import { defineBuiltinModule, defineModule } from './module';
6
+ import { registerModules } from './module-registry';
7
+
8
+ function createBaseConfig(name: string) {
9
+ return {
10
+ name,
11
+ schema: { invoices: {} },
12
+ routes: new Hono(),
13
+ };
14
+ }
15
+
16
+ function expectValidationError(fn: () => void): void {
17
+ try {
18
+ fn();
19
+ throw new Error('Expected defineModule to throw a VobaseError');
20
+ } catch (error) {
21
+ expect(error).toBeInstanceOf(VobaseError);
22
+ expect((error as VobaseError).code).toBe('VALIDATION');
23
+ }
24
+ }
25
+
26
+ describe('defineModule()', () => {
27
+ it("returns a frozen VobaseModule for name='invoicing'", () => {
28
+ const module = defineModule(createBaseConfig('invoicing'));
29
+
30
+ expect(module.name).toBe('invoicing');
31
+ expect(module.schema).toEqual({ invoices: {} });
32
+ expect(Object.isFrozen(module)).toBe(true);
33
+ });
34
+
35
+ it("throws VobaseError for name='auth'", () => {
36
+ expectValidationError(() => {
37
+ defineModule(createBaseConfig('auth'));
38
+ });
39
+ });
40
+
41
+ it("throws VobaseError for name='mcp'", () => {
42
+ expectValidationError(() => {
43
+ defineModule(createBaseConfig('mcp'));
44
+ });
45
+ });
46
+
47
+ it("throws VobaseError for name='health'", () => {
48
+ expectValidationError(() => {
49
+ defineModule(createBaseConfig('health'));
50
+ });
51
+ });
52
+
53
+ it("throws VobaseError for name='api'", () => {
54
+ expectValidationError(() => {
55
+ defineModule(createBaseConfig('api'));
56
+ });
57
+ });
58
+
59
+ it("allows name='system'", () => {
60
+ const module = defineModule(createBaseConfig('system'));
61
+ expect(module.name).toBe('system');
62
+ });
63
+
64
+ it("throws for name='AUTH'", () => {
65
+ expectValidationError(() => {
66
+ defineModule(createBaseConfig('AUTH'));
67
+ });
68
+ });
69
+
70
+ it("allows name='invoice-2024'", () => {
71
+ const module = defineModule(createBaseConfig('invoice-2024'));
72
+ expect(module.name).toBe('invoice-2024');
73
+ });
74
+
75
+ it('allows empty schema for modules without tables', () => {
76
+ const module = defineModule({
77
+ ...createBaseConfig('billing'),
78
+ schema: {},
79
+ });
80
+ expect(module.name).toBe('billing');
81
+ });
82
+ });
83
+
84
+ describe('defineBuiltinModule()', () => {
85
+ it("succeeds for name='_audit'", () => {
86
+ const module = defineBuiltinModule({
87
+ name: '_audit',
88
+ schema: { auditLog: {} },
89
+ routes: new Hono(),
90
+ });
91
+ expect(module.name).toBe('_audit');
92
+ expect(Object.isFrozen(module)).toBe(true);
93
+ });
94
+
95
+ it("succeeds for name='_sequences'", () => {
96
+ const module = defineBuiltinModule({
97
+ name: '_sequences',
98
+ schema: { sequences: {} },
99
+ routes: new Hono(),
100
+ });
101
+ expect(module.name).toBe('_sequences');
102
+ });
103
+
104
+ it('accepts an init hook', () => {
105
+ const module = defineBuiltinModule({
106
+ name: '_credentials',
107
+ schema: { credentials: {} },
108
+ routes: new Hono(),
109
+ init: () => {},
110
+ });
111
+ expect(module.init).toBeDefined();
112
+ });
113
+
114
+ it('allows empty schema for built-in modules', () => {
115
+ const module = defineBuiltinModule({
116
+ name: '_system-proxy',
117
+ schema: {},
118
+ routes: new Hono(),
119
+ });
120
+ expect(module.name).toBe('_system-proxy');
121
+ });
122
+
123
+ it("throws for name without _ prefix (e.g., 'audit')", () => {
124
+ expectValidationError(() => {
125
+ defineBuiltinModule({
126
+ name: 'audit',
127
+ schema: { auditLog: {} },
128
+ routes: new Hono(),
129
+ });
130
+ });
131
+ });
132
+
133
+ it("throws for name with uppercase (e.g., '_Audit')", () => {
134
+ expectValidationError(() => {
135
+ defineBuiltinModule({
136
+ name: '_Audit',
137
+ schema: { auditLog: {} },
138
+ routes: new Hono(),
139
+ });
140
+ });
141
+ });
142
+ });
143
+
144
+ describe('defineModule rejects _ prefix', () => {
145
+ it("throws for name='_audit' via defineModule", () => {
146
+ expectValidationError(() => {
147
+ defineModule(createBaseConfig('_audit'));
148
+ });
149
+ });
150
+ });
151
+
152
+ describe('registerModules()', () => {
153
+ it('throws when duplicate module names are provided', () => {
154
+ const first = defineModule(createBaseConfig('orders'));
155
+ const second = defineModule({
156
+ ...createBaseConfig('orders'),
157
+ schema: { payments: {} },
158
+ });
159
+
160
+ try {
161
+ registerModules([first, second]);
162
+ throw new Error('Expected registerModules to throw for duplicate names');
163
+ } catch (error) {
164
+ expect(error).toBeInstanceOf(VobaseError);
165
+ expect((error as VobaseError).code).toBe('CONFLICT');
166
+ }
167
+ });
168
+ });
package/src/module.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { Hono } from 'hono';
2
+
3
+ import type { ModuleInitContext } from './contracts/module';
4
+ import { validation } from './infra/errors';
5
+ import type { JobDefinition } from './infra/job';
6
+
7
+ const MODULE_NAME_PATTERN = /^[a-z0-9-]+$/;
8
+ const BUILTIN_NAME_PATTERN = /^_[a-z0-9-]+$/;
9
+ const RESERVED_MODULE_NAMES = new Set(['auth', 'mcp', 'health', 'api']);
10
+
11
+ export interface VobaseModule {
12
+ name: string;
13
+ schema: Record<string, unknown>;
14
+ routes: Hono;
15
+ jobs?: JobDefinition[];
16
+ pages?: Record<string, string>;
17
+ seed?: () => Promise<void>;
18
+ init?: (ctx: ModuleInitContext) => Promise<void> | void;
19
+ }
20
+
21
+ export interface DefineModuleConfig {
22
+ name: string;
23
+ schema: Record<string, unknown>;
24
+ routes: Hono;
25
+ jobs?: JobDefinition[];
26
+ pages?: Record<string, string>;
27
+ seed?: () => Promise<void>;
28
+ init?: (ctx: ModuleInitContext) => Promise<void> | void;
29
+ }
30
+
31
+ export function defineModule(config: DefineModuleConfig): VobaseModule {
32
+ if (!config.name.trim()) {
33
+ throw validation(
34
+ { name: config.name },
35
+ 'Module name must be a non-empty string',
36
+ );
37
+ }
38
+
39
+ if (!MODULE_NAME_PATTERN.test(config.name)) {
40
+ throw validation(
41
+ { name: config.name },
42
+ 'Module name must use lowercase alphanumeric characters and hyphens only',
43
+ );
44
+ }
45
+
46
+ if (RESERVED_MODULE_NAMES.has(config.name)) {
47
+ throw validation(
48
+ { name: config.name },
49
+ `Module name "${config.name}" is reserved`,
50
+ );
51
+ }
52
+
53
+ if (
54
+ typeof config.schema !== 'object' ||
55
+ config.schema === null ||
56
+ Array.isArray(config.schema)
57
+ ) {
58
+ throw validation(
59
+ { schema: config.schema },
60
+ 'Module schema must be an object',
61
+ );
62
+ }
63
+
64
+ if (
65
+ typeof config.routes !== 'object' ||
66
+ config.routes === null ||
67
+ typeof config.routes.get !== 'function'
68
+ ) {
69
+ throw validation(
70
+ { routes: config.routes },
71
+ 'Module routes must be a Hono router instance',
72
+ );
73
+ }
74
+
75
+ return Object.freeze({ ...config });
76
+ }
77
+
78
+ /**
79
+ * Internal-only factory for built-in modules. Bypasses user-facing name
80
+ * validation to allow the `_` prefix convention (e.g., `_audit`, `_sequences`).
81
+ * Not exported in the public API (index.ts).
82
+ */
83
+ export function defineBuiltinModule(config: DefineModuleConfig): VobaseModule {
84
+ if (!config.name.trim()) {
85
+ throw validation(
86
+ { name: config.name },
87
+ 'Module name must be a non-empty string',
88
+ );
89
+ }
90
+
91
+ if (!BUILTIN_NAME_PATTERN.test(config.name)) {
92
+ throw validation(
93
+ { name: config.name },
94
+ 'Built-in module name must start with _ and use lowercase alphanumeric characters and hyphens',
95
+ );
96
+ }
97
+
98
+ // Built-in modules may have empty schemas (e.g., if they only read from other module tables)
99
+ if (
100
+ typeof config.routes !== 'object' ||
101
+ config.routes === null ||
102
+ typeof config.routes.get !== 'function'
103
+ ) {
104
+ throw validation(
105
+ { routes: config.routes },
106
+ 'Module routes must be a Hono router instance',
107
+ );
108
+ }
109
+
110
+ return Object.freeze({ ...config });
111
+ }
@@ -0,0 +1,18 @@
1
+ import { Hono } from 'hono';
2
+
3
+ import { defineBuiltinModule } from '../../module';
4
+
5
+ import { auditLog, recordAudits } from './schema';
6
+
7
+ export { auditLog, recordAudits } from './schema';
8
+ export { trackChanges } from './track-changes';
9
+ export { requestAuditMiddleware } from './middleware';
10
+
11
+ export function createAuditModule() {
12
+ return defineBuiltinModule({
13
+ name: '_audit',
14
+ schema: { auditLog, recordAudits },
15
+ routes: new Hono(),
16
+ init: () => {},
17
+ });
18
+ }
@@ -0,0 +1,33 @@
1
+ import { createMiddleware } from 'hono/factory';
2
+
3
+ import type { VobaseDb } from '../../db/client';
4
+
5
+ import { auditLog } from './schema';
6
+
7
+ const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
8
+
9
+ function getRequestIp(headers: Headers | undefined): string {
10
+ const forwardedFor = headers?.get('x-forwarded-for')?.split(',')[0]?.trim();
11
+ return forwardedFor || headers?.get('x-real-ip') || 'unknown';
12
+ }
13
+
14
+ export function requestAuditMiddleware(db: VobaseDb) {
15
+ return createMiddleware(async (c, next) => {
16
+ try {
17
+ await next();
18
+ } finally {
19
+ if (MUTATION_METHODS.has(c.req.method)) {
20
+ const user = c.get('user');
21
+ db.insert(auditLog)
22
+ .values({
23
+ event: 'api_mutation',
24
+ actorId: user?.id ?? null,
25
+ actorEmail: user?.email ?? null,
26
+ ip: getRequestIp(c.req.raw.headers),
27
+ details: JSON.stringify({ method: c.req.method, path: c.req.path }),
28
+ })
29
+ .run();
30
+ }
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,35 @@
1
+ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
2
+
3
+ import { nanoidPrimaryKey } from '../../db/helpers';
4
+
5
+ /**
6
+ * Audit log table for tracking system events (sign-in, sign-up, role changes, etc.)
7
+ * Events are immutable - no updatedAt column
8
+ */
9
+ export const auditLog = sqliteTable('_audit_log', {
10
+ id: nanoidPrimaryKey(),
11
+ event: text('event').notNull(),
12
+ actorId: text('actor_id'),
13
+ actorEmail: text('actor_email'),
14
+ ip: text('ip'),
15
+ details: text('details'), // JSON string
16
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
17
+ .notNull()
18
+ .$defaultFn(() => new Date()),
19
+ });
20
+
21
+ /**
22
+ * Record audits table for tracking changes to individual records
23
+ * Stores before/after data for data change auditing
24
+ */
25
+ export const recordAudits = sqliteTable('_record_audits', {
26
+ id: nanoidPrimaryKey(),
27
+ tableName: text('table_name').notNull(),
28
+ recordId: text('record_id').notNull(),
29
+ oldData: text('old_data'), // JSON string
30
+ newData: text('new_data'), // JSON string
31
+ changedBy: text('changed_by'), // user ID
32
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
33
+ .notNull()
34
+ .$defaultFn(() => new Date()),
35
+ });
@@ -0,0 +1,70 @@
1
+ import type { VobaseDb } from '../../db/client';
2
+
3
+ import { recordAudits } from './schema';
4
+
5
+ function valuesAreEqual(left: unknown, right: unknown): boolean {
6
+ if (Object.is(left, right)) {
7
+ return true;
8
+ }
9
+
10
+ if (
11
+ typeof left === 'object' &&
12
+ left !== null &&
13
+ typeof right === 'object' &&
14
+ right !== null
15
+ ) {
16
+ return JSON.stringify(left) === JSON.stringify(right);
17
+ }
18
+
19
+ return false;
20
+ }
21
+
22
+ export function trackChanges(
23
+ db: VobaseDb,
24
+ tableName: string,
25
+ recordId: string,
26
+ oldData: Record<string, unknown> | null,
27
+ newData: Record<string, unknown> | null,
28
+ userId?: string,
29
+ ): void {
30
+ if (oldData === null && newData === null) {
31
+ return;
32
+ }
33
+
34
+ let oldDiff: Record<string, unknown> | null = null;
35
+ let newDiff: Record<string, unknown> | null = null;
36
+
37
+ if (oldData === null) {
38
+ newDiff = newData;
39
+ } else if (newData === null) {
40
+ oldDiff = oldData;
41
+ } else {
42
+ oldDiff = {};
43
+ newDiff = {};
44
+
45
+ const keys = new Set([...Object.keys(oldData), ...Object.keys(newData)]);
46
+ for (const key of keys) {
47
+ const previousValue = oldData[key];
48
+ const nextValue = newData[key];
49
+
50
+ if (!valuesAreEqual(previousValue, nextValue)) {
51
+ oldDiff[key] = previousValue;
52
+ newDiff[key] = nextValue;
53
+ }
54
+ }
55
+
56
+ if (Object.keys(oldDiff).length === 0) {
57
+ return;
58
+ }
59
+ }
60
+
61
+ db.insert(recordAudits)
62
+ .values({
63
+ tableName,
64
+ recordId,
65
+ oldData: oldDiff === null ? null : JSON.stringify(oldDiff),
66
+ newData: newDiff === null ? null : JSON.stringify(newDiff),
67
+ changedBy: userId ?? null,
68
+ })
69
+ .run();
70
+ }
@@ -0,0 +1,74 @@
1
+ import { createAuthMiddleware } from 'better-auth/api';
2
+
3
+ import type { VobaseDb } from '../../db/client';
4
+ import { auditLog } from '../audit/schema';
5
+
6
+ const AUTH_EVENT_BY_PATH = {
7
+ '/sign-in/email': 'signin',
8
+ '/sign-up/email': 'signup',
9
+ '/sign-out': 'signout',
10
+ } as const;
11
+
12
+ function getRequestIp(headers: Headers | undefined): string {
13
+ const forwardedFor = headers?.get('x-forwarded-for')?.split(',')[0]?.trim();
14
+ return forwardedFor || headers?.get('x-real-ip') || 'unknown';
15
+ }
16
+
17
+ function writeAuditLog(
18
+ db: VobaseDb,
19
+ event: string,
20
+ actorId: string | null,
21
+ actorEmail: string | null,
22
+ ip: string,
23
+ details: Record<string, string>,
24
+ ): void {
25
+ db.insert(auditLog)
26
+ .values({
27
+ event,
28
+ actorId,
29
+ actorEmail,
30
+ ip,
31
+ details: JSON.stringify(details),
32
+ })
33
+ .run();
34
+ }
35
+
36
+ export function createAuthAuditHooks(db: VobaseDb) {
37
+ // Store user info before signout destroys the session
38
+ const pendingSignout = new WeakMap<Headers, { id: string; email: string }>();
39
+
40
+ return {
41
+ before: createAuthMiddleware(async (ctx) => {
42
+ if (ctx.path === '/sign-out') {
43
+ const user = ctx.context.session?.user;
44
+ if (user && ctx.headers) {
45
+ pendingSignout.set(ctx.headers, { id: user.id, email: user.email });
46
+ }
47
+ }
48
+ }),
49
+ after: createAuthMiddleware(async (ctx) => {
50
+ const event =
51
+ AUTH_EVENT_BY_PATH[ctx.path as keyof typeof AUTH_EVENT_BY_PATH];
52
+ if (!event) {
53
+ return;
54
+ }
55
+
56
+ let actor = (ctx.context.newSession ?? ctx.context.session)?.user;
57
+
58
+ // For signout, retrieve the user captured in the before hook
59
+ if (!actor && ctx.path === '/sign-out' && ctx.headers) {
60
+ actor = pendingSignout.get(ctx.headers) as typeof actor;
61
+ pendingSignout.delete(ctx.headers);
62
+ }
63
+
64
+ writeAuditLog(
65
+ db,
66
+ event,
67
+ actor?.id ?? null,
68
+ actor?.email ?? null,
69
+ getRequestIp(ctx.headers),
70
+ { path: ctx.path },
71
+ );
72
+ }),
73
+ };
74
+ }