@enbox/dwn-server 0.0.7 → 0.0.9

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 (97) hide show
  1. package/dist/esm/src/admin/admin-api.d.ts +5 -1
  2. package/dist/esm/src/admin/admin-api.d.ts.map +1 -1
  3. package/dist/esm/src/admin/admin-api.js +327 -7
  4. package/dist/esm/src/admin/admin-api.js.map +1 -1
  5. package/dist/esm/src/admin/admin-auth.d.ts +21 -3
  6. package/dist/esm/src/admin/admin-auth.d.ts.map +1 -1
  7. package/dist/esm/src/admin/admin-auth.js +17 -9
  8. package/dist/esm/src/admin/admin-auth.js.map +1 -1
  9. package/dist/esm/src/admin/admin-passkey-store.d.ts +68 -0
  10. package/dist/esm/src/admin/admin-passkey-store.d.ts.map +1 -0
  11. package/dist/esm/src/admin/admin-passkey-store.js +132 -0
  12. package/dist/esm/src/admin/admin-passkey-store.js.map +1 -0
  13. package/dist/esm/src/admin/admin-session.d.ts +35 -0
  14. package/dist/esm/src/admin/admin-session.d.ts.map +1 -0
  15. package/dist/esm/src/admin/admin-session.js +91 -0
  16. package/dist/esm/src/admin/admin-session.js.map +1 -0
  17. package/dist/esm/src/admin/admin-store.d.ts +4 -0
  18. package/dist/esm/src/admin/admin-store.d.ts.map +1 -1
  19. package/dist/esm/src/admin/admin-store.js +6 -2
  20. package/dist/esm/src/admin/admin-store.js.map +1 -1
  21. package/dist/esm/src/admin/audit-log.d.ts.map +1 -1
  22. package/dist/esm/src/admin/audit-log.js +5 -43
  23. package/dist/esm/src/admin/audit-log.js.map +1 -1
  24. package/dist/esm/src/admin/index.d.ts +5 -1
  25. package/dist/esm/src/admin/index.d.ts.map +1 -1
  26. package/dist/esm/src/admin/index.js +2 -0
  27. package/dist/esm/src/admin/index.js.map +1 -1
  28. package/dist/esm/src/admin/types.d.ts +22 -0
  29. package/dist/esm/src/admin/types.d.ts.map +1 -1
  30. package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
  31. package/dist/esm/src/admin/webhook-manager.js +11 -10
  32. package/dist/esm/src/admin/webhook-manager.js.map +1 -1
  33. package/dist/esm/src/config.d.ts +18 -0
  34. package/dist/esm/src/config.d.ts.map +1 -1
  35. package/dist/esm/src/config.js +18 -0
  36. package/dist/esm/src/config.js.map +1 -1
  37. package/dist/esm/src/connect/connect-server.d.ts +75 -0
  38. package/dist/esm/src/connect/connect-server.d.ts.map +1 -0
  39. package/dist/esm/src/{web5-connect/web5-connect-server.js → connect/connect-server.js} +32 -24
  40. package/dist/esm/src/connect/connect-server.js.map +1 -0
  41. package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.d.ts +11 -1
  42. package/dist/esm/src/connect/sql-ttl-cache.d.ts.map +1 -0
  43. package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.js +19 -20
  44. package/dist/esm/src/connect/sql-ttl-cache.js.map +1 -0
  45. package/dist/esm/src/dwn-server.d.ts.map +1 -1
  46. package/dist/esm/src/dwn-server.js +46 -11
  47. package/dist/esm/src/dwn-server.js.map +1 -1
  48. package/dist/esm/src/http-api.d.ts +6 -2
  49. package/dist/esm/src/http-api.d.ts.map +1 -1
  50. package/dist/esm/src/http-api.js +31 -17
  51. package/dist/esm/src/http-api.js.map +1 -1
  52. package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
  53. package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
  54. package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
  55. package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
  56. package/dist/esm/src/migrations/index.d.ts +13 -0
  57. package/dist/esm/src/migrations/index.d.ts.map +1 -0
  58. package/dist/esm/src/migrations/index.js +5 -0
  59. package/dist/esm/src/migrations/index.js.map +1 -0
  60. package/dist/esm/src/registration/registration-store.d.ts +4 -0
  61. package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
  62. package/dist/esm/src/registration/registration-store.js +11 -34
  63. package/dist/esm/src/registration/registration-store.js.map +1 -1
  64. package/dist/esm/src/server-migration-runner.d.ts +23 -0
  65. package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
  66. package/dist/esm/src/server-migration-runner.js +57 -0
  67. package/dist/esm/src/server-migration-runner.js.map +1 -0
  68. package/dist/esm/src/storage.d.ts +15 -0
  69. package/dist/esm/src/storage.d.ts.map +1 -1
  70. package/dist/esm/src/storage.js +135 -17
  71. package/dist/esm/src/storage.js.map +1 -1
  72. package/package.json +8 -27
  73. package/src/admin/admin-api.ts +403 -10
  74. package/src/admin/admin-auth.ts +38 -9
  75. package/src/admin/admin-passkey-store.ts +190 -0
  76. package/src/admin/admin-session.ts +116 -0
  77. package/src/admin/admin-store.ts +6 -2
  78. package/src/admin/audit-log.ts +7 -44
  79. package/src/admin/index.ts +5 -0
  80. package/src/admin/types.ts +28 -0
  81. package/src/admin/webhook-manager.ts +12 -10
  82. package/src/config.ts +21 -0
  83. package/src/connect/connect-server.ts +150 -0
  84. package/src/{web5-connect → connect}/sql-ttl-cache.ts +21 -22
  85. package/src/dwn-server.ts +49 -11
  86. package/src/http-api.ts +37 -18
  87. package/src/migrations/001-initial-server-schema.ts +114 -0
  88. package/src/migrations/index.ts +18 -0
  89. package/src/registration/registration-store.ts +13 -36
  90. package/src/server-migration-runner.ts +74 -0
  91. package/src/storage.ts +145 -17
  92. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +0 -1
  93. package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +0 -1
  94. package/dist/esm/src/web5-connect/web5-connect-server.d.ts +0 -58
  95. package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +0 -1
  96. package/dist/esm/src/web5-connect/web5-connect-server.js.map +0 -1
  97. package/src/web5-connect/web5-connect-server.ts +0 -123
@@ -1,5 +1,5 @@
1
1
  import type { Dialect } from '@enbox/dwn-sql-store';
2
- import { Kysely } from 'kysely';
2
+ import { Kysely, sql } from 'kysely';
3
3
 
4
4
  /**
5
5
  * The SqlTtlCache is responsible for storing and retrieving cache data with TTL (Time-to-Live).
@@ -8,13 +8,11 @@ export class SqlTtlCache {
8
8
  private static readonly cacheTableName = 'cacheEntries';
9
9
  private static readonly cleanupIntervalInSeconds = 60;
10
10
 
11
- private sqlDialect: Dialect;
12
11
  private db: Kysely<CacheDatabase>;
13
12
  private cleanupTimer: NodeJS.Timeout;
14
13
 
15
14
  private constructor(sqlDialect: Dialect) {
16
15
  this.db = new Kysely<CacheDatabase>({ dialect: sqlDialect });
17
- this.sqlDialect = sqlDialect;
18
16
  }
19
17
 
20
18
  /**
@@ -28,26 +26,17 @@ export class SqlTtlCache {
28
26
  return cacheManager;
29
27
  }
30
28
 
29
+ /**
30
+ * Verifies that the required table exists and starts the cleanup timer.
31
+ * Throws a clear error directing the caller to run server migrations first.
32
+ */
31
33
  private async initialize(): Promise<void> {
32
-
33
- // create table if it doesn't exist
34
- const tableExists = await this.sqlDialect.hasTable(this.db, SqlTtlCache.cacheTableName);
35
- if (!tableExists) {
36
- await this.db.schema
37
- .createTable(SqlTtlCache.cacheTableName)
38
- .ifNotExists() // kept to show supported by all dialects in contrast to ifNotExists() below, though not needed due to tableExists check above
39
- // 512 chars to accommodate potentially large `state` in Web5 Connect flow
40
- .addColumn('key', 'varchar(512)', (column) => column.primaryKey())
41
- .addColumn('value', 'text', (column) => column.notNull())
42
- .addColumn('expiry', 'bigint', (column) => column.notNull())
43
- .execute();
44
-
45
- await this.db.schema
46
- .createIndex('index_expiry')
47
- // .ifNotExists() // intentionally kept commented out code to show that it is not supported by all dialects (ie. MySQL)
48
- .on(SqlTtlCache.cacheTableName)
49
- .column('expiry')
50
- .execute();
34
+ try {
35
+ await sql`SELECT 1 FROM ${sql.table(SqlTtlCache.cacheTableName)} LIMIT 0`.execute(this.db);
36
+ } catch {
37
+ throw new Error(
38
+ `SqlTtlCache: table '${SqlTtlCache.cacheTableName}' does not exist. Run server migrations before starting.`
39
+ );
51
40
  }
52
41
 
53
42
  // Start the cleanup timer
@@ -131,6 +120,16 @@ export class SqlTtlCache {
131
120
  .where('expiry', '<', Date.now())
132
121
  .execute();
133
122
  }
123
+
124
+ /**
125
+ * Stops the background cleanup timer. The underlying database connection is
126
+ * NOT destroyed here because the dialect is shared with other components
127
+ * (e.g. DWN stores, registration store) and its lifecycle is managed by the
128
+ * dialect owner.
129
+ */
130
+ public close(): void {
131
+ clearInterval(this.cleanupTimer);
132
+ }
134
133
  }
135
134
 
136
135
  interface CacheEntry {
package/src/dwn-server.ts CHANGED
@@ -16,6 +16,8 @@ import type { ProviderAuthPlugin } from './registration/provider-auth-plugin.js'
16
16
 
17
17
  import { ActivityLog } from './admin/activity-log.js';
18
18
  import { AdminApi } from './admin/admin-api.js';
19
+ import { AdminPasskeyStore } from './admin/admin-passkey-store.js';
20
+ import { AdminSessionManager } from './admin/admin-session.js';
19
21
  import { AdminStore } from './admin/admin-store.js';
20
22
  import { AuditLog } from './admin/audit-log.js';
21
23
  import { config as defaultConfig } from './config.js';
@@ -29,7 +31,7 @@ import { RateLimiter } from './rate-limiter.js';
29
31
  import { RegistrationManager } from './registration/registration-manager.js';
30
32
  import { WebhookManager } from './admin/webhook-manager.js';
31
33
  import { WsApi } from './ws-api.js';
32
- import { getDialectFromUrl, getDwnConfig } from './storage.js';
34
+ import { getDialectFromUrl, getDwnConfig, runServerMigrationsIfNeeded } from './storage.js';
33
35
  import { removeProcessHandlers, setProcessHandlers } from './process-handlers.js';
34
36
 
35
37
  /**
@@ -97,6 +99,8 @@ export class DwnServer {
97
99
  #ipRateLimiter: RateLimiter | undefined;
98
100
  #tenantRateLimiter: RateLimiter | undefined;
99
101
  #auditLog: AuditLog | undefined;
102
+ #passkeyStore: AdminPasskeyStore | undefined;
103
+ #sessionManager: AdminSessionManager | undefined;
100
104
  #externalHooks: MessageProcessedHook[];
101
105
  #externalRegistrationManager: RegistrationManager | undefined;
102
106
  #externalOpenAuthHandler: OpenAuthHandler | undefined;
@@ -140,6 +144,13 @@ export class DwnServer {
140
144
  */
141
145
  async #setupServer(): Promise<void> {
142
146
 
147
+ // Run server migrations (admin stores, registration, TTL cache) FIRST,
148
+ // before creating any stores that depend on the server schema. The
149
+ // returned dialect is reused for the TTL cache and admin stores so that
150
+ // in-memory SQLite shares a single database instance across migrations
151
+ // and stores.
152
+ const serverDialect = await runServerMigrationsIfNeeded(this.config);
153
+
143
154
  let registrationManager: RegistrationManager | undefined;
144
155
 
145
156
  if (!this.dwn) {
@@ -223,34 +234,45 @@ export class DwnServer {
223
234
  let adminStore: AdminStore | undefined;
224
235
  let auditLog: AuditLog | undefined;
225
236
  let webhookManager: WebhookManager | undefined;
237
+ let passkeyStore: AdminPasskeyStore | undefined;
238
+ let sessionManager: AdminSessionManager | undefined;
226
239
 
227
240
  if (this.config.adminToken) {
228
241
  const storageUrl = this.config.messageStore;
229
242
  adminStore = AdminStore.create(storageUrl);
230
243
  activityLog = new ActivityLog(this.config.adminActivityLogCapacity);
231
244
 
232
- // Create the persistent audit log using the registration store's dialect
233
- // (same DB) or the message store URL as fallback.
245
+ // Reuse the dialect returned by server migrations when available — this
246
+ // is critical for in-memory SQLite where every `getDialectFromUrl` call
247
+ // creates a separate database. For Postgres, `serverDialect` points at
248
+ // the same shared pool, so reusing it also avoids pool proliferation.
234
249
  if (this.config.registrationStoreUrl) {
250
+ const adminDialect = serverDialect ?? getDialectFromUrl(new URL(this.config.registrationStoreUrl));
251
+
235
252
  try {
236
- const auditDialect = getDialectFromUrl(new URL(this.config.registrationStoreUrl));
237
- auditLog = await AuditLog.create(auditDialect, {
253
+ auditLog = await AuditLog.create(adminDialect, {
238
254
  maxAgeDays : this.config.auditLogMaxAgeDays,
239
255
  maxRows : this.config.auditLogMaxRows,
240
256
  });
241
257
  } catch (err) {
242
258
  log.warn('Failed to create audit log:', err);
243
259
  }
244
- }
245
260
 
246
- // Create webhook manager using the same dialect as the audit log.
247
- if (this.config.registrationStoreUrl) {
248
261
  try {
249
- const webhookDialect = getDialectFromUrl(new URL(this.config.registrationStoreUrl));
250
- webhookManager = await WebhookManager.create(webhookDialect);
262
+ webhookManager = await WebhookManager.create(adminDialect);
251
263
  } catch (err) {
252
264
  log.warn('Failed to create webhook manager:', err);
253
265
  }
266
+
267
+ // Create passkey store and session manager for WebAuthn admin auth.
268
+ // @see https://github.com/enboxorg/enbox/issues/546
269
+ try {
270
+ passkeyStore = await AdminPasskeyStore.create(adminDialect);
271
+ sessionManager = new AdminSessionManager(this.config.adminSessionTtlSeconds);
272
+ log.info('Admin passkey authentication enabled');
273
+ } catch (err) {
274
+ log.warn('Failed to create passkey store:', err);
275
+ }
254
276
  }
255
277
 
256
278
  adminApi = AdminApi.create({
@@ -264,6 +286,8 @@ export class DwnServer {
264
286
  ipRateLimiter,
265
287
  tenantRateLimiter,
266
288
  webhookManager,
289
+ passkeyStore,
290
+ sessionManager,
267
291
  });
268
292
 
269
293
  // Record server start event in audit log.
@@ -279,6 +303,8 @@ export class DwnServer {
279
303
  this.#ipRateLimiter = ipRateLimiter;
280
304
  this.#tenantRateLimiter = tenantRateLimiter;
281
305
  this.#auditLog = auditLog;
306
+ this.#passkeyStore = passkeyStore;
307
+ this.#sessionManager = sessionManager;
282
308
 
283
309
  // Create open-auth handler if provider auth is enabled with a JWT secret
284
310
  // and authorize/token URLs point to this server (or are not set — defaulting to built-in).
@@ -307,7 +333,11 @@ export class DwnServer {
307
333
 
308
334
  this.#httpApi = await HttpApi.create(
309
335
  this.config, this.dwn, registrationManager, adminApi, activityLog,
310
- { adminStore, registrationStore, ipRateLimiter, tenantRateLimiter, messageProcessedHooks, openAuthHandler },
336
+ {
337
+ adminStore, registrationStore, ipRateLimiter, tenantRateLimiter,
338
+ messageProcessedHooks, openAuthHandler, sessionManager,
339
+ ttlCacheDialect: serverDialect,
340
+ },
311
341
  );
312
342
 
313
343
  await this.#httpApi.start(this.config.port);
@@ -400,6 +430,14 @@ export class DwnServer {
400
430
  await this.#auditLog.close();
401
431
  }
402
432
 
433
+ // Clean up passkey store and session manager.
434
+ if (this.#passkeyStore) {
435
+ await this.#passkeyStore.close();
436
+ }
437
+ if (this.#sessionManager) {
438
+ this.#sessionManager.destroy();
439
+ }
440
+
403
441
  // Clean up rate limiters (stops their interval timers).
404
442
  if (this.#ipRateLimiter) {
405
443
  this.#ipRateLimiter.destroy();
package/src/http-api.ts CHANGED
@@ -3,8 +3,11 @@ import type { RecordsReadReply } from '@enbox/dwn-sdk-js';
3
3
  import type { ServerInfo } from '@enbox/dwn-clients';
4
4
  import type { Server, ServerWebSocket } from 'bun';
5
5
 
6
+ import type { Dialect } from '@enbox/dwn-sql-store';
7
+
6
8
  import type { ActivityLog } from './admin/activity-log.js';
7
9
  import type { AdminApi } from './admin/admin-api.js';
10
+ import type { AdminSessionManager } from './admin/admin-session.js';
8
11
  import type { AdminStore } from './admin/admin-store.js';
9
12
  import type { DwnServerConfig } from './config.js';
10
13
  import type { DwnServerError } from './dwn-error.js';
@@ -27,9 +30,10 @@ import { existsSync, readFileSync } from 'fs';
27
30
  import { join, resolve } from 'path';
28
31
 
29
32
  import { config } from './config.js';
33
+ import { ConnectServer } from './connect/connect-server.js';
34
+ import { getDialectFromUrl } from './storage.js';
30
35
  import { jsonRpcRouter } from './json-rpc-api.js';
31
36
  import { validateAdminAuth } from './admin/admin-auth.js';
32
- import { Web5ConnectServer } from './web5-connect/web5-connect-server.js';
33
37
  import { requestCounter, responseHistogram } from './metrics.js';
34
38
 
35
39
  /** Property names that must never be used as keys when building objects from user input. */
@@ -62,8 +66,9 @@ export class HttpApi {
62
66
  #tenantRateLimiter: RateLimiter | undefined;
63
67
  #messageProcessedHooks: MessageProcessedHook[];
64
68
  #openAuthHandler: OpenAuthHandler | undefined;
69
+ #sessionManager: AdminSessionManager | undefined;
65
70
  #adminUiPath: string | undefined;
66
- web5ConnectServer: Web5ConnectServer;
71
+ connectServer: ConnectServer;
67
72
  registrationManager: RegistrationManager;
68
73
  dwn: Dwn;
69
74
 
@@ -82,6 +87,8 @@ export class HttpApi {
82
87
  tenantRateLimiter? : RateLimiter;
83
88
  messageProcessedHooks? : MessageProcessedHook[];
84
89
  openAuthHandler? : OpenAuthHandler;
90
+ sessionManager? : AdminSessionManager;
91
+ ttlCacheDialect? : Dialect;
85
92
  },
86
93
  ): Promise<HttpApi> {
87
94
  const httpApi = new HttpApi();
@@ -119,15 +126,20 @@ export class HttpApi {
119
126
  httpApi.#tenantRateLimiter = options?.tenantRateLimiter;
120
127
  httpApi.#messageProcessedHooks = options?.messageProcessedHooks ?? [];
121
128
  httpApi.#openAuthHandler = options?.openAuthHandler;
129
+ httpApi.#sessionManager = options?.sessionManager;
122
130
  httpApi.#adminUiPath = resolvedAdminUiPath;
123
131
 
124
132
  if (registrationManager !== undefined) {
125
133
  httpApi.registrationManager = registrationManager;
126
134
  }
127
135
 
128
- httpApi.web5ConnectServer = await Web5ConnectServer.create({
129
- baseUrl : config.baseUrl,
130
- sqlTtlCacheUrl : config.ttlCacheUrl,
136
+ // Use an externally provided dialect when available (required for
137
+ // in-memory SQLite so that migrations and the TTL cache share the same
138
+ // database instance). Falls back to creating a dialect from the URL.
139
+ const ttlDialect = options?.ttlCacheDialect ?? getDialectFromUrl(new URL(config.ttlCacheUrl));
140
+ httpApi.connectServer = await ConnectServer.create({
141
+ baseUrl : config.baseUrl,
142
+ sqlDialect : ttlDialect,
131
143
  });
132
144
 
133
145
  return httpApi;
@@ -211,6 +223,10 @@ export class HttpApi {
211
223
  response.headers.set('access-control-allow-methods', 'GET, POST, OPTIONS');
212
224
  response.headers.set('access-control-allow-headers', '*');
213
225
  response.headers.set('access-control-expose-headers', 'dwn-response');
226
+ // Cache preflight responses for 24 hours to reduce OPTIONS round-trips,
227
+ // which is especially important for local DWN discovery that probes
228
+ // multiple ports from the browser (e.g. `probeLocalDwn()` in @enbox/auth).
229
+ response.headers.set('access-control-max-age', '86400');
214
230
  }
215
231
 
216
232
  // --- Response-time metrics ---
@@ -258,6 +274,9 @@ export class HttpApi {
258
274
  if (this.#openAuthHandler) {
259
275
  this.#openAuthHandler.destroy();
260
276
  }
277
+ if (this.connectServer) {
278
+ this.connectServer.close();
279
+ }
261
280
  if (this.#server) {
262
281
  this.#server.stop(true); // close all connections immediately
263
282
  }
@@ -321,9 +340,9 @@ export class HttpApi {
321
340
  if (method === 'GET' && path === '/metrics') {
322
341
  // Metrics require admin authentication when an admin token is configured.
323
342
  if (this.#config.adminToken) {
324
- const authError = validateAdminAuth(req, this.#config);
325
- if (authError) {
326
- return authError;
343
+ const authResult = validateAdminAuth(req, this.#config, this.#sessionManager);
344
+ if (authResult.error) {
345
+ return authResult.error;
327
346
  }
328
347
  }
329
348
  try {
@@ -338,7 +357,7 @@ export class HttpApi {
338
357
 
339
358
  if (method === 'GET' && path === '/') {
340
359
  return new Response(
341
- 'please use am enbox client, for example: https://github.com/enboxorg/enbox ',
360
+ 'please use an enbox client, for example: https://github.com/enboxorg/enbox ',
342
361
  { headers: { 'content-type': 'text/plain' } },
343
362
  );
344
363
  }
@@ -384,8 +403,8 @@ export class HttpApi {
384
403
  return registrationResponse;
385
404
  }
386
405
 
387
- // --- Web5 Connect routes ---
388
- const connectResponse = await this.#matchWeb5ConnectRoutes(req, path, method);
406
+ // --- Connect routes ---
407
+ const connectResponse = await this.#matchConnectRoutes(req, path, method);
389
408
  if (connectResponse) {
390
409
  return connectResponse;
391
410
  }
@@ -791,10 +810,10 @@ export class HttpApi {
791
810
  }
792
811
 
793
812
  // ---------------------------------------------------------------------------
794
- // Web5 Connect routes
813
+ // Connect routes
795
814
  // ---------------------------------------------------------------------------
796
815
 
797
- async #matchWeb5ConnectRoutes(
816
+ async #matchConnectRoutes(
798
817
  req: Request, path: string, method: string
799
818
  ): Promise<Response | null> {
800
819
  // POST /connect/par
@@ -816,7 +835,7 @@ export class HttpApi {
816
835
  }, { status: 400 });
817
836
  }
818
837
 
819
- const result = await this.web5ConnectServer.setWeb5ConnectRequest(body.request);
838
+ const result = await this.connectServer.setConnectRequest(body.request);
820
839
  return Response.json(result, { status: 201 });
821
840
  }
822
841
 
@@ -825,9 +844,9 @@ export class HttpApi {
825
844
  const match = path.match(/^\/connect\/authorize\/([^/]+)\.jwt$/);
826
845
  if (match && method === 'GET') {
827
846
  const requestId = match[1];
828
- log.info(`Retrieving Web5 Connect Request object of ID: ${requestId}...`);
847
+ log.info(`Retrieving Connect Request object of ID: ${requestId}...`);
829
848
 
830
- const requestObjectJwt = await this.web5ConnectServer.getWeb5ConnectRequest(requestId);
849
+ const requestObjectJwt = await this.connectServer.getConnectRequest(requestId);
831
850
  if (!requestObjectJwt) {
832
851
  return Response.json({
833
852
  ok : false,
@@ -852,7 +871,7 @@ export class HttpApi {
852
871
  const state = body.state;
853
872
 
854
873
  if (idToken !== undefined && state != undefined) {
855
- await this.web5ConnectServer.setWeb5ConnectResponse(state, idToken);
874
+ await this.connectServer.setConnectResponse(state, idToken);
856
875
  return Response.json({
857
876
  ok : true,
858
877
  status : { code: 201, message: 'Created' },
@@ -872,7 +891,7 @@ export class HttpApi {
872
891
  const state = match[1];
873
892
  log.info(`Retrieving ID token for state: ${state}...`);
874
893
 
875
- const idToken = await this.web5ConnectServer.getWeb5ConnectResponse(state);
894
+ const idToken = await this.connectServer.getConnectResponse(state);
876
895
  if (!idToken) {
877
896
  return Response.json({
878
897
  ok : false,
@@ -0,0 +1,114 @@
1
+ import type { Dialect } from '@enbox/dwn-sql-store';
2
+ import type { Kysely, Migration } from 'kysely';
3
+
4
+ /**
5
+ * Factory type for server migrations. Mirrors the `DwnMigrationFactory` from
6
+ * `@enbox/dwn-sql-store` — receives the {@link Dialect} so migrations can use
7
+ * dialect-specific helpers like `addAutoIncrementingColumn()`.
8
+ */
9
+ export type ServerMigrationFactory = (dialect: Dialect) => Migration;
10
+
11
+ /**
12
+ * Baseline server migration: creates all DWN server admin and cache tables.
13
+ *
14
+ * Tables:
15
+ * - `registeredTenants`: tenant registration data
16
+ * - `tenantQuotas`: per-tenant storage quotas
17
+ * - `adminAuditLog`: append-only admin event log
18
+ * - `adminWebhooks`: webhook registrations
19
+ * - `adminPasskeys`: WebAuthn admin passkeys
20
+ * - `cacheEntries`: TTL-based key/value cache (Web5 Connect state, etc.)
21
+ */
22
+ export const migration001InitialServerSchema: ServerMigrationFactory = (dialect): Migration => ({
23
+
24
+ async up(db: Kysely<any>): Promise<void> {
25
+
26
+ // ─── registeredTenants ────────────────────────────────────────────
27
+ await db.schema
28
+ .createTable('registeredTenants')
29
+ .ifNotExists()
30
+ .addColumn('did', 'text', (col) => col.primaryKey())
31
+ .addColumn('termsOfServiceHash', 'text')
32
+ .addColumn('suspended', 'integer', (col) => col.defaultTo(0))
33
+ .addColumn('accountId', 'text')
34
+ .addColumn('registrationType', 'text')
35
+ .addColumn('registeredAt', 'text')
36
+ .addColumn('metadata', 'text')
37
+ .execute();
38
+
39
+ // ─── tenantQuotas ─────────────────────────────────────────────────
40
+ await db.schema
41
+ .createTable('tenantQuotas')
42
+ .ifNotExists()
43
+ .addColumn('did', 'text', (col) => col.primaryKey())
44
+ .addColumn('maxMessages', 'integer', (col) => col.defaultTo(0))
45
+ .addColumn('maxStorageBytes', 'bigint', (col) => col.defaultTo(0))
46
+ .execute();
47
+
48
+ // ─── adminAuditLog ────────────────────────────────────────────────
49
+ let auditTable = db.schema
50
+ .createTable('adminAuditLog')
51
+ .ifNotExists()
52
+ .addColumn('timestamp', 'text', (col) => col.notNull())
53
+ .addColumn('actor', 'text', (col) => col.notNull())
54
+ .addColumn('action', 'text', (col) => col.notNull())
55
+ .addColumn('target', 'text')
56
+ .addColumn('detail', 'text');
57
+
58
+ auditTable = dialect.addAutoIncrementingColumn(auditTable, 'id', (col) => col.primaryKey());
59
+ await auditTable.execute();
60
+
61
+ try {
62
+ await db.schema.createIndex('index_audit_timestamp')
63
+ .ifNotExists().on('adminAuditLog').column('timestamp').execute();
64
+ } catch { /* index already exists */ }
65
+
66
+ try {
67
+ await db.schema.createIndex('index_audit_target')
68
+ .ifNotExists().on('adminAuditLog').column('target').execute();
69
+ } catch { /* index already exists */ }
70
+
71
+ try {
72
+ await db.schema.createIndex('index_audit_action')
73
+ .ifNotExists().on('adminAuditLog').column('action').execute();
74
+ } catch { /* index already exists */ }
75
+
76
+ // ─── adminWebhooks ────────────────────────────────────────────────
77
+ await db.schema
78
+ .createTable('adminWebhooks')
79
+ .ifNotExists()
80
+ .addColumn('id', 'text', (col) => col.primaryKey())
81
+ .addColumn('url', 'text', (col) => col.notNull())
82
+ .addColumn('events', 'text', (col) => col.notNull())
83
+ .addColumn('secret', 'text')
84
+ .addColumn('createdAt', 'text', (col) => col.notNull())
85
+ .execute();
86
+
87
+ // ─── adminPasskeys ────────────────────────────────────────────────
88
+ await db.schema
89
+ .createTable('adminPasskeys')
90
+ .ifNotExists()
91
+ .addColumn('id', 'text', (col) => col.primaryKey())
92
+ .addColumn('name', 'text', (col) => col.notNull())
93
+ .addColumn('publicKey', 'text', (col) => col.notNull())
94
+ .addColumn('counter', 'integer', (col) => col.notNull().defaultTo(0))
95
+ .addColumn('transports', 'text', (col) => col.notNull().defaultTo('[]'))
96
+ .addColumn('createdAt', 'text', (col) => col.notNull())
97
+ .addColumn('lastUsedAt', 'text')
98
+ .execute();
99
+
100
+ // ─── cacheEntries (TTL cache) ─────────────────────────────────────
101
+ await db.schema
102
+ .createTable('cacheEntries')
103
+ .ifNotExists()
104
+ .addColumn('key', 'varchar(512)', (col) => col.primaryKey())
105
+ .addColumn('value', 'text', (col) => col.notNull())
106
+ .addColumn('expiry', 'bigint', (col) => col.notNull())
107
+ .execute();
108
+
109
+ try {
110
+ await db.schema.createIndex('index_expiry')
111
+ .ifNotExists().on('cacheEntries').column('expiry').execute();
112
+ } catch { /* index already exists */ }
113
+ },
114
+ });
@@ -0,0 +1,18 @@
1
+ import type { ServerMigrationFactory } from './001-initial-server-schema.js';
2
+
3
+ import { migration001InitialServerSchema } from './001-initial-server-schema.js';
4
+
5
+ /**
6
+ * All DWN server migrations in sequential order.
7
+ *
8
+ * Each entry is a `[name, factory]` tuple where the factory receives the
9
+ * `Dialect` and returns a standard Kysely `Migration`. This mirrors the
10
+ * pattern used by DWN store migrations in `@enbox/dwn-sql-store`.
11
+ *
12
+ * **Ordering contract:** Entries MUST be sorted by name (lexicographic).
13
+ */
14
+ export type { ServerMigrationFactory };
15
+
16
+ export const allServerMigrations: ReadonlyArray<readonly [name: string, factory: ServerMigrationFactory]> = [
17
+ ['001-initial-server-schema', migration001InitialServerSchema],
18
+ ];
@@ -29,47 +29,24 @@ export class RegistrationStore {
29
29
  return store;
30
30
  }
31
31
 
32
+ /**
33
+ * Verifies that the required tables exist. Throws a clear error directing
34
+ * the caller to run server migrations first.
35
+ */
32
36
  private async initialize(): Promise<void> {
33
- await this.db.schema
34
- .createTable(RegistrationStore.registeredTenantTableName)
35
- .ifNotExists()
36
- .addColumn('did', 'text', (column) => column.primaryKey())
37
- .addColumn('termsOfServiceHash', 'text')
38
- .addColumn('suspended', 'integer', (column) => column.defaultTo(0))
39
- .execute();
40
-
41
- // Add the `suspended` column to existing tables that don't have it yet.
42
- // Kysely doesn't support `ADD COLUMN IF NOT EXISTS` across all dialects, so we
43
- // catch and ignore the "column already exists" error.
44
- try {
45
- await this.db.schema
46
- .alterTable(RegistrationStore.registeredTenantTableName)
47
- .addColumn('suspended', 'integer', (column) => column.defaultTo(0))
48
- .execute();
49
- } catch {
50
- // Column already exists — expected for new installations.
51
- }
52
-
53
- // Add provider-auth columns (idempotent migration). https://github.com/enboxorg/enbox/issues/404
54
- for (const col of ['accountId', 'registrationType', 'registeredAt', 'metadata']) {
37
+ const tables = [
38
+ RegistrationStore.registeredTenantTableName,
39
+ RegistrationStore.tenantQuotasTableName,
40
+ ];
41
+ for (const table of tables) {
55
42
  try {
56
- await this.db.schema
57
- .alterTable(RegistrationStore.registeredTenantTableName)
58
- .addColumn(col, 'text')
59
- .execute();
43
+ await sql`SELECT 1 FROM ${sql.table(table)} LIMIT 0`.execute(this.db);
60
44
  } catch {
61
- // Column already exists — expected.
45
+ throw new Error(
46
+ `RegistrationStore: table '${table}' does not exist. Run server migrations before starting.`
47
+ );
62
48
  }
63
49
  }
64
-
65
- // Per-tenant storage quotas table.
66
- await this.db.schema
67
- .createTable(RegistrationStore.tenantQuotasTableName)
68
- .ifNotExists()
69
- .addColumn('did', 'text', (column) => column.primaryKey())
70
- .addColumn('maxMessages', 'integer', (column) => column.defaultTo(0))
71
- .addColumn('maxStorageBytes', 'bigint', (column) => column.defaultTo(0))
72
- .execute();
73
50
  }
74
51
 
75
52
  /**
@@ -0,0 +1,74 @@
1
+ import type { Dialect } from '@enbox/dwn-sql-store';
2
+ import type { ServerMigrationFactory } from './migrations/index.js';
3
+ import type { Kysely, Migration, MigrationProvider, MigrationResultSet } from 'kysely';
4
+
5
+ import { allServerMigrations } from './migrations/index.js';
6
+ import { Migrator } from 'kysely';
7
+
8
+ /**
9
+ * {@link MigrationProvider} for server migrations. Wraps an ordered list of
10
+ * `(name, factory)` pairs. At resolution time each factory is called with the
11
+ * dialect, producing the concrete Kysely {@link Migration} objects.
12
+ */
13
+ class ServerMigrationProvider implements MigrationProvider {
14
+ #dialect: Dialect;
15
+ #factories: ReadonlyArray<readonly [name: string, factory: ServerMigrationFactory]>;
16
+
17
+ constructor(
18
+ dialect: Dialect,
19
+ factories: ReadonlyArray<readonly [name: string, factory: ServerMigrationFactory]>,
20
+ ) {
21
+ this.#dialect = dialect;
22
+ this.#factories = factories;
23
+ }
24
+
25
+ public async getMigrations(): Promise<Record<string, Migration>> {
26
+ const migrations: Record<string, Migration> = {};
27
+ for (const [name, factory] of this.#factories) {
28
+ migrations[name] = factory(this.#dialect);
29
+ }
30
+ return migrations;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Runs all pending DWN server migrations against the given database.
36
+ *
37
+ * Uses Kysely's native {@link Migrator} with a separate migration table
38
+ * (`dwn_server_migration`) to avoid collisions with the DWN store
39
+ * migrations that use the default `kysely_migration` table.
40
+ *
41
+ * Call this once during server startup, before creating admin stores,
42
+ * registration stores, or the TTL cache.
43
+ *
44
+ * @param db - An open Kysely instance connected to the target database.
45
+ * @param dialect - The dialect for the target database. Passed to each
46
+ * migration factory so it can use dialect-specific DDL helpers.
47
+ * @param migrations - Optional custom migration list; defaults to the
48
+ * built-in {@link allServerMigrations}.
49
+ * @returns The names of newly applied migrations (empty if already up-to-date).
50
+ * @throws If any migration fails.
51
+ */
52
+ export async function runServerMigrations(
53
+ db: Kysely<any>,
54
+ dialect: Dialect,
55
+ migrations?: ReadonlyArray<readonly [name: string, factory: ServerMigrationFactory]>,
56
+ ): Promise<string[]> {
57
+ const provider = new ServerMigrationProvider(dialect, migrations ?? allServerMigrations);
58
+ const migrator = new Migrator({
59
+ db,
60
+ provider,
61
+ migrationTableName : 'dwn_server_migration',
62
+ migrationLockTableName : 'dwn_server_migration_lock',
63
+ });
64
+
65
+ const resultSet: MigrationResultSet = await migrator.migrateToLatest();
66
+
67
+ if (resultSet.error) {
68
+ throw resultSet.error;
69
+ }
70
+
71
+ return (resultSet.results ?? [])
72
+ .filter((r) => r.status === 'Success')
73
+ .map((r) => r.migrationName);
74
+ }