@enbox/dwn-server 0.0.6 → 0.0.8

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 (92) 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/audit-log.d.ts.map +1 -1
  18. package/dist/esm/src/admin/audit-log.js +5 -43
  19. package/dist/esm/src/admin/audit-log.js.map +1 -1
  20. package/dist/esm/src/admin/index.d.ts +5 -1
  21. package/dist/esm/src/admin/index.d.ts.map +1 -1
  22. package/dist/esm/src/admin/index.js +2 -0
  23. package/dist/esm/src/admin/index.js.map +1 -1
  24. package/dist/esm/src/admin/types.d.ts +22 -0
  25. package/dist/esm/src/admin/types.d.ts.map +1 -1
  26. package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
  27. package/dist/esm/src/admin/webhook-manager.js +11 -10
  28. package/dist/esm/src/admin/webhook-manager.js.map +1 -1
  29. package/dist/esm/src/config.d.ts +18 -0
  30. package/dist/esm/src/config.d.ts.map +1 -1
  31. package/dist/esm/src/config.js +18 -0
  32. package/dist/esm/src/config.js.map +1 -1
  33. package/dist/esm/src/dwn-server.d.ts +20 -1
  34. package/dist/esm/src/dwn-server.d.ts.map +1 -1
  35. package/dist/esm/src/dwn-server.js +111 -52
  36. package/dist/esm/src/dwn-server.js.map +1 -1
  37. package/dist/esm/src/http-api.d.ts +4 -0
  38. package/dist/esm/src/http-api.d.ts.map +1 -1
  39. package/dist/esm/src/http-api.js +14 -4
  40. package/dist/esm/src/http-api.js.map +1 -1
  41. package/dist/esm/src/index.d.ts +2 -0
  42. package/dist/esm/src/index.d.ts.map +1 -1
  43. package/dist/esm/src/index.js +2 -0
  44. package/dist/esm/src/index.js.map +1 -1
  45. package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
  46. package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
  47. package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
  48. package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
  49. package/dist/esm/src/migrations/index.d.ts +13 -0
  50. package/dist/esm/src/migrations/index.d.ts.map +1 -0
  51. package/dist/esm/src/migrations/index.js +5 -0
  52. package/dist/esm/src/migrations/index.js.map +1 -0
  53. package/dist/esm/src/registration/registration-store.d.ts +4 -0
  54. package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
  55. package/dist/esm/src/registration/registration-store.js +11 -34
  56. package/dist/esm/src/registration/registration-store.js.map +1 -1
  57. package/dist/esm/src/server-migration-runner.d.ts +23 -0
  58. package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
  59. package/dist/esm/src/server-migration-runner.js +57 -0
  60. package/dist/esm/src/server-migration-runner.js.map +1 -0
  61. package/dist/esm/src/storage.d.ts +15 -0
  62. package/dist/esm/src/storage.d.ts.map +1 -1
  63. package/dist/esm/src/storage.js +135 -17
  64. package/dist/esm/src/storage.js.map +1 -1
  65. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts +11 -1
  66. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +1 -1
  67. package/dist/esm/src/web5-connect/sql-ttl-cache.js +19 -20
  68. package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +1 -1
  69. package/dist/esm/src/web5-connect/web5-connect-server.d.ts +10 -3
  70. package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +1 -1
  71. package/dist/esm/src/web5-connect/web5-connect-server.js +10 -4
  72. package/dist/esm/src/web5-connect/web5-connect-server.js.map +1 -1
  73. package/package.json +3 -2
  74. package/src/admin/admin-api.ts +403 -10
  75. package/src/admin/admin-auth.ts +38 -9
  76. package/src/admin/admin-passkey-store.ts +190 -0
  77. package/src/admin/admin-session.ts +116 -0
  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/dwn-server.ts +137 -55
  84. package/src/http-api.ts +20 -5
  85. package/src/index.ts +2 -0
  86. package/src/migrations/001-initial-server-schema.ts +114 -0
  87. package/src/migrations/index.ts +18 -0
  88. package/src/registration/registration-store.ts +13 -36
  89. package/src/server-migration-runner.ts +74 -0
  90. package/src/storage.ts +145 -17
  91. package/src/web5-connect/sql-ttl-cache.ts +21 -22
  92. package/src/web5-connect/web5-connect-server.ts +14 -5
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
  /**
@@ -52,6 +54,24 @@ export type DwnServerOptions = {
52
54
  * are appended after the DeliveryService hook.
53
55
  */
54
56
  messageProcessedHooks?: MessageProcessedHook[];
57
+
58
+ /**
59
+ * An externally created RegistrationManager to use as the tenant gate and
60
+ * registration endpoint handler. When a pre-built `dwn` is provided, the
61
+ * server cannot create its own RegistrationManager (because it doesn't
62
+ * control the DWN's TenantGate). Pass one here to enable registration
63
+ * endpoints (`POST /registration`, etc.) with a pre-built DWN.
64
+ */
65
+ registrationManager?: RegistrationManager;
66
+
67
+ /**
68
+ * An externally created OpenAuthHandler for the built-in provider-auth
69
+ * endpoints (`/provider-auth/authorize`, `/provider-auth/token`,
70
+ * `/provider-auth/refresh`). When a pre-built `dwn` is provided, the
71
+ * server skips creating this handler. Pass one here to enable the
72
+ * open-auth flow with a pre-built DWN.
73
+ */
74
+ openAuthHandler?: OpenAuthHandler;
55
75
  };
56
76
 
57
77
  /**
@@ -79,10 +99,16 @@ export class DwnServer {
79
99
  #ipRateLimiter: RateLimiter | undefined;
80
100
  #tenantRateLimiter: RateLimiter | undefined;
81
101
  #auditLog: AuditLog | undefined;
102
+ #passkeyStore: AdminPasskeyStore | undefined;
103
+ #sessionManager: AdminSessionManager | undefined;
82
104
  #externalHooks: MessageProcessedHook[];
105
+ #externalRegistrationManager: RegistrationManager | undefined;
106
+ #externalOpenAuthHandler: OpenAuthHandler | undefined;
83
107
 
84
108
  /**
85
- * @param options.dwn - Dwn instance to use as an override. Registration endpoint will not be enabled if this is provided.
109
+ * @param options.dwn - Dwn instance to use as an override.
110
+ * @param options.registrationManager - External RegistrationManager to use with a pre-built DWN.
111
+ * @param options.openAuthHandler - External OpenAuthHandler to use with a pre-built DWN.
86
112
  */
87
113
  constructor(options: DwnServerOptions = {}) {
88
114
  this.config = options.config ?? defaultConfig;
@@ -90,6 +116,8 @@ export class DwnServer {
90
116
  this.didResolver = options.didResolver;
91
117
  this.dwn = options.dwn;
92
118
  this.#externalHooks = options.messageProcessedHooks ?? [];
119
+ this.#externalRegistrationManager = options.registrationManager;
120
+ this.#externalOpenAuthHandler = options.openAuthHandler;
93
121
 
94
122
  log.setLevel(this.config.logLevel as log.LogLevelDesc);
95
123
 
@@ -116,49 +144,18 @@ export class DwnServer {
116
144
  */
117
145
  async #setupServer(): Promise<void> {
118
146
 
119
- let registrationManager: RegistrationManager;
120
- if (!this.dwn) {
121
- // Load provider auth plugin if configured.
122
- let providerAuthPlugin: ProviderAuthPlugin | undefined;
123
- if (this.config.providerAuthEnabled) {
124
- if (this.config.providerAuthPluginPath) {
125
- // Custom external plugin.
126
- providerAuthPlugin = await loadProviderAuthPlugin(this.config.providerAuthPluginPath);
127
- log.info('Provider auth plugin loaded from path');
128
- } else if (this.config.providerAuthJwtSecret || this.config.providerAuthJwtJwksUrl) {
129
- // Built-in JWT plugin.
130
- providerAuthPlugin = await JwtProviderAuthPlugin.create({
131
- secret : this.config.providerAuthJwtSecret,
132
- jwksUrl : this.config.providerAuthJwtJwksUrl,
133
- issuer : this.config.baseUrl,
134
- audience : this.config.baseUrl,
135
- });
136
- log.info('Built-in JWT provider auth plugin created');
137
- }
138
- }
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);
139
153
 
140
- // undefined registrationStoreUrl is used as a signal that there is no need for tenant registration, DWN is open for all.
141
- registrationManager = await RegistrationManager.create({
142
- registrationStoreUrl : this.config.registrationStoreUrl,
143
- termsOfServiceFilePath : this.config.termsOfServiceFilePath,
144
- proofOfWorkChallengeNonceSeed : this.config.registrationProofOfWorkSeed,
145
- proofOfWorkInitialMaximumAllowedHash : this.config.registrationProofOfWorkInitialMaxHash,
146
- providerAuthPlugin,
147
- });
154
+ let registrationManager: RegistrationManager | undefined;
148
155
 
149
- // Warn if the tenant gate is active but no registration method is enabled.
150
- // This is almost certainly a misconfiguration new tenants will be rejected
151
- // with 401 and have no way to register.
152
- if (this.config.registrationStoreUrl
153
- && !this.config.registrationProofOfWorkEnabled
154
- && !providerAuthPlugin) {
155
- log.warn(
156
- '*** WARNING: DWN_REGISTRATION_STORE_URL is set (tenant gate active) but neither ' +
157
- 'proof-of-work (DWN_REGISTRATION_PROOF_OF_WORK_ENABLED) nor provider auth ' +
158
- '(DWN_PROVIDER_AUTH_ENABLED + secret/plugin) is configured. ' +
159
- 'New tenants will be unable to register. ***',
160
- );
161
- }
156
+ if (!this.dwn) {
157
+ // No pre-built DWN create everything from scratch including registration.
158
+ registrationManager = await this.#createRegistrationManager();
162
159
 
163
160
  let eventLog: EventLog | undefined;
164
161
  if (this.config.webSocketSupport) {
@@ -186,6 +183,11 @@ export class DwnServer {
186
183
  eventLog,
187
184
  });
188
185
  this.dwn = await Dwn.create(dwnConfig);
186
+ } else if (this.#externalRegistrationManager) {
187
+ // Pre-built DWN with an externally-provided RegistrationManager.
188
+ // The caller is responsible for passing this RegistrationManager as the
189
+ // TenantGate when creating the DWN instance.
190
+ registrationManager = this.#externalRegistrationManager;
189
191
  }
190
192
 
191
193
  // Assemble message-processed hooks.
@@ -232,34 +234,45 @@ export class DwnServer {
232
234
  let adminStore: AdminStore | undefined;
233
235
  let auditLog: AuditLog | undefined;
234
236
  let webhookManager: WebhookManager | undefined;
237
+ let passkeyStore: AdminPasskeyStore | undefined;
238
+ let sessionManager: AdminSessionManager | undefined;
235
239
 
236
240
  if (this.config.adminToken) {
237
241
  const storageUrl = this.config.messageStore;
238
242
  adminStore = AdminStore.create(storageUrl);
239
243
  activityLog = new ActivityLog(this.config.adminActivityLogCapacity);
240
244
 
241
- // Create the persistent audit log using the registration store's dialect
242
- // (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.
243
249
  if (this.config.registrationStoreUrl) {
250
+ const adminDialect = serverDialect ?? getDialectFromUrl(new URL(this.config.registrationStoreUrl));
251
+
244
252
  try {
245
- const auditDialect = getDialectFromUrl(new URL(this.config.registrationStoreUrl));
246
- auditLog = await AuditLog.create(auditDialect, {
253
+ auditLog = await AuditLog.create(adminDialect, {
247
254
  maxAgeDays : this.config.auditLogMaxAgeDays,
248
255
  maxRows : this.config.auditLogMaxRows,
249
256
  });
250
257
  } catch (err) {
251
258
  log.warn('Failed to create audit log:', err);
252
259
  }
253
- }
254
260
 
255
- // Create webhook manager using the same dialect as the audit log.
256
- if (this.config.registrationStoreUrl) {
257
261
  try {
258
- const webhookDialect = getDialectFromUrl(new URL(this.config.registrationStoreUrl));
259
- webhookManager = await WebhookManager.create(webhookDialect);
262
+ webhookManager = await WebhookManager.create(adminDialect);
260
263
  } catch (err) {
261
264
  log.warn('Failed to create webhook manager:', err);
262
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
+ }
263
276
  }
264
277
 
265
278
  adminApi = AdminApi.create({
@@ -273,6 +286,8 @@ export class DwnServer {
273
286
  ipRateLimiter,
274
287
  tenantRateLimiter,
275
288
  webhookManager,
289
+ passkeyStore,
290
+ sessionManager,
276
291
  });
277
292
 
278
293
  // Record server start event in audit log.
@@ -288,17 +303,22 @@ export class DwnServer {
288
303
  this.#ipRateLimiter = ipRateLimiter;
289
304
  this.#tenantRateLimiter = tenantRateLimiter;
290
305
  this.#auditLog = auditLog;
306
+ this.#passkeyStore = passkeyStore;
307
+ this.#sessionManager = sessionManager;
291
308
 
292
309
  // Create open-auth handler if provider auth is enabled with a JWT secret
293
310
  // and authorize/token URLs point to this server (or are not set — defaulting to built-in).
294
- let openAuthHandler: OpenAuthHandler | undefined;
295
- if (this.config.providerAuthEnabled && this.config.providerAuthJwtSecret && !this.config.providerAuthPluginPath) {
311
+ // An externally-provided handler (e.g. from the relay) takes precedence.
312
+ let openAuthHandler: OpenAuthHandler | undefined = this.#externalOpenAuthHandler;
313
+ if (!openAuthHandler && this.config.providerAuthEnabled && this.config.providerAuthJwtSecret && !this.config.providerAuthPluginPath) {
296
314
  openAuthHandler = OpenAuthHandler.create(
297
315
  this.config.providerAuthJwtSecret,
298
316
  this.config.baseUrl,
299
317
  );
300
318
  log.info('Built-in open-auth endpoints enabled');
319
+ }
301
320
 
321
+ if (openAuthHandler) {
302
322
  // Auto-configure authorize/token/refresh URLs if not explicitly set.
303
323
  if (!this.config.providerAuthAuthorizeUrl) {
304
324
  this.config.providerAuthAuthorizeUrl = `${this.config.baseUrl}/provider-auth/authorize`;
@@ -313,7 +333,11 @@ export class DwnServer {
313
333
 
314
334
  this.#httpApi = await HttpApi.create(
315
335
  this.config, this.dwn, registrationManager, adminApi, activityLog,
316
- { adminStore, registrationStore, ipRateLimiter, tenantRateLimiter, messageProcessedHooks, openAuthHandler },
336
+ {
337
+ adminStore, registrationStore, ipRateLimiter, tenantRateLimiter,
338
+ messageProcessedHooks, openAuthHandler, sessionManager,
339
+ ttlCacheDialect: serverDialect,
340
+ },
317
341
  );
318
342
 
319
343
  await this.#httpApi.start(this.config.port);
@@ -339,6 +363,56 @@ export class DwnServer {
339
363
  }
340
364
  }
341
365
 
366
+ /**
367
+ * Creates a RegistrationManager based on the server config. Factored out of
368
+ * `#setupServer()` so the same logic can be reused regardless of whether the
369
+ * DWN is created internally or externally.
370
+ */
371
+ async #createRegistrationManager(): Promise<RegistrationManager> {
372
+ // Load provider auth plugin if configured.
373
+ let providerAuthPlugin: ProviderAuthPlugin | undefined;
374
+ if (this.config.providerAuthEnabled) {
375
+ if (this.config.providerAuthPluginPath) {
376
+ // Custom external plugin.
377
+ providerAuthPlugin = await loadProviderAuthPlugin(this.config.providerAuthPluginPath);
378
+ log.info('Provider auth plugin loaded from path');
379
+ } else if (this.config.providerAuthJwtSecret || this.config.providerAuthJwtJwksUrl) {
380
+ // Built-in JWT plugin.
381
+ providerAuthPlugin = await JwtProviderAuthPlugin.create({
382
+ secret : this.config.providerAuthJwtSecret,
383
+ jwksUrl : this.config.providerAuthJwtJwksUrl,
384
+ issuer : this.config.baseUrl,
385
+ audience : this.config.baseUrl,
386
+ });
387
+ log.info('Built-in JWT provider auth plugin created');
388
+ }
389
+ }
390
+
391
+ // undefined registrationStoreUrl is used as a signal that there is no need
392
+ // for tenant registration, DWN is open for all.
393
+ const registrationManager = await RegistrationManager.create({
394
+ registrationStoreUrl : this.config.registrationStoreUrl,
395
+ termsOfServiceFilePath : this.config.termsOfServiceFilePath,
396
+ proofOfWorkChallengeNonceSeed : this.config.registrationProofOfWorkSeed,
397
+ proofOfWorkInitialMaximumAllowedHash : this.config.registrationProofOfWorkInitialMaxHash,
398
+ providerAuthPlugin,
399
+ });
400
+
401
+ // Warn if the tenant gate is active but no registration method is enabled.
402
+ if (this.config.registrationStoreUrl
403
+ && !this.config.registrationProofOfWorkEnabled
404
+ && !providerAuthPlugin) {
405
+ log.warn(
406
+ '*** WARNING: DWN_REGISTRATION_STORE_URL is set (tenant gate active) but neither ' +
407
+ 'proof-of-work (DWN_REGISTRATION_PROOF_OF_WORK_ENABLED) nor provider auth ' +
408
+ '(DWN_PROVIDER_AUTH_ENABLED + secret/plugin) is configured. ' +
409
+ 'New tenants will be unable to register. ***',
410
+ );
411
+ }
412
+
413
+ return registrationManager;
414
+ }
415
+
342
416
  /**
343
417
  * Stops the DWN server.
344
418
  */
@@ -356,6 +430,14 @@ export class DwnServer {
356
430
  await this.#auditLog.close();
357
431
  }
358
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
+
359
441
  // Clean up rate limiters (stops their interval timers).
360
442
  if (this.#ipRateLimiter) {
361
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,6 +30,7 @@ import { existsSync, readFileSync } from 'fs';
27
30
  import { join, resolve } from 'path';
28
31
 
29
32
  import { config } from './config.js';
33
+ import { getDialectFromUrl } from './storage.js';
30
34
  import { jsonRpcRouter } from './json-rpc-api.js';
31
35
  import { validateAdminAuth } from './admin/admin-auth.js';
32
36
  import { Web5ConnectServer } from './web5-connect/web5-connect-server.js';
@@ -62,6 +66,7 @@ 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
71
  web5ConnectServer: Web5ConnectServer;
67
72
  registrationManager: RegistrationManager;
@@ -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
 
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));
128
140
  httpApi.web5ConnectServer = await Web5ConnectServer.create({
129
- baseUrl : config.baseUrl,
130
- sqlTtlCacheUrl : config.ttlCacheUrl,
141
+ baseUrl : config.baseUrl,
142
+ sqlDialect : ttlDialect,
131
143
  });
132
144
 
133
145
  return httpApi;
@@ -258,6 +270,9 @@ export class HttpApi {
258
270
  if (this.#openAuthHandler) {
259
271
  this.#openAuthHandler.destroy();
260
272
  }
273
+ if (this.web5ConnectServer) {
274
+ this.web5ConnectServer.close();
275
+ }
261
276
  if (this.#server) {
262
277
  this.#server.stop(true); // close all connections immediately
263
278
  }
@@ -321,9 +336,9 @@ export class HttpApi {
321
336
  if (method === 'GET' && path === '/metrics') {
322
337
  // Metrics require admin authentication when an admin token is configured.
323
338
  if (this.#config.adminToken) {
324
- const authError = validateAdminAuth(req, this.#config);
325
- if (authError) {
326
- return authError;
339
+ const authResult = validateAdminAuth(req, this.#config, this.#sessionManager);
340
+ if (authResult.error) {
341
+ return authResult.error;
327
342
  }
328
343
  }
329
344
  try {
package/src/index.ts CHANGED
@@ -13,5 +13,7 @@ export { DwnServer, DwnServerOptions } from './dwn-server.js';
13
13
  export { HttpApi } from './http-api.js';
14
14
  export { jsonRpcRouter } from './json-rpc-api.js';
15
15
  export type { MessageProcessedContext, MessageProcessedHook } from './message-processed-hook.js';
16
+ export { OpenAuthHandler } from './registration/open-auth-handler.js';
17
+ export { RegistrationManager } from './registration/registration-manager.js';
16
18
  export { getDwnConfig, StoreType, BackendTypes, DwnStore } from './storage.js';
17
19
  export { WsApi } from './ws-api.js';
@@ -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
+ }