@enbox/dwn-server 0.0.7 → 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 (86) 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.map +1 -1
  34. package/dist/esm/src/dwn-server.js +46 -11
  35. package/dist/esm/src/dwn-server.js.map +1 -1
  36. package/dist/esm/src/http-api.d.ts +4 -0
  37. package/dist/esm/src/http-api.d.ts.map +1 -1
  38. package/dist/esm/src/http-api.js +14 -4
  39. package/dist/esm/src/http-api.js.map +1 -1
  40. package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
  41. package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
  42. package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
  43. package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
  44. package/dist/esm/src/migrations/index.d.ts +13 -0
  45. package/dist/esm/src/migrations/index.d.ts.map +1 -0
  46. package/dist/esm/src/migrations/index.js +5 -0
  47. package/dist/esm/src/migrations/index.js.map +1 -0
  48. package/dist/esm/src/registration/registration-store.d.ts +4 -0
  49. package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
  50. package/dist/esm/src/registration/registration-store.js +11 -34
  51. package/dist/esm/src/registration/registration-store.js.map +1 -1
  52. package/dist/esm/src/server-migration-runner.d.ts +23 -0
  53. package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
  54. package/dist/esm/src/server-migration-runner.js +57 -0
  55. package/dist/esm/src/server-migration-runner.js.map +1 -0
  56. package/dist/esm/src/storage.d.ts +15 -0
  57. package/dist/esm/src/storage.d.ts.map +1 -1
  58. package/dist/esm/src/storage.js +135 -17
  59. package/dist/esm/src/storage.js.map +1 -1
  60. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts +11 -1
  61. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +1 -1
  62. package/dist/esm/src/web5-connect/sql-ttl-cache.js +19 -20
  63. package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +1 -1
  64. package/dist/esm/src/web5-connect/web5-connect-server.d.ts +10 -3
  65. package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +1 -1
  66. package/dist/esm/src/web5-connect/web5-connect-server.js +10 -4
  67. package/dist/esm/src/web5-connect/web5-connect-server.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/admin/admin-api.ts +403 -10
  70. package/src/admin/admin-auth.ts +38 -9
  71. package/src/admin/admin-passkey-store.ts +190 -0
  72. package/src/admin/admin-session.ts +116 -0
  73. package/src/admin/audit-log.ts +7 -44
  74. package/src/admin/index.ts +5 -0
  75. package/src/admin/types.ts +28 -0
  76. package/src/admin/webhook-manager.ts +12 -10
  77. package/src/config.ts +21 -0
  78. package/src/dwn-server.ts +49 -11
  79. package/src/http-api.ts +20 -5
  80. package/src/migrations/001-initial-server-schema.ts +114 -0
  81. package/src/migrations/index.ts +18 -0
  82. package/src/registration/registration-store.ts +13 -36
  83. package/src/server-migration-runner.ts +74 -0
  84. package/src/storage.ts +145 -17
  85. package/src/web5-connect/sql-ttl-cache.ts +21 -22
  86. package/src/web5-connect/web5-connect-server.ts +14 -5
@@ -0,0 +1,190 @@
1
+ import type { Dialect } from '@enbox/dwn-sql-store';
2
+
3
+ import { Kysely, sql } from 'kysely';
4
+
5
+ /**
6
+ * A registered WebAuthn credential (passkey) for admin access.
7
+ */
8
+ export type AdminPasskeyRecord = {
9
+ /** Base64url-encoded credential ID. */
10
+ id : string;
11
+ /** Human-readable name for the passkey (e.g. "MacBook Touch ID"). */
12
+ name : string;
13
+ /** Base64url-encoded COSE public key. */
14
+ publicKey : string;
15
+ /** Monotonically increasing counter for replay protection. */
16
+ counter : number;
17
+ /** WebAuthn transports the credential supports (JSON-encoded string array). */
18
+ transports : string;
19
+ /** ISO-8601 timestamp when the passkey was registered. */
20
+ createdAt : string;
21
+ /** ISO-8601 timestamp of the most recent successful authentication. */
22
+ lastUsedAt : string | null;
23
+ };
24
+
25
+ /**
26
+ * SQL-backed credential store for WebAuthn passkeys used by the admin panel.
27
+ *
28
+ * Follows the same Kysely + Dialect pattern as {@link AuditLog} and
29
+ * {@link WebhookManager}. The `adminPasskeys` table is created automatically
30
+ * on first use.
31
+ *
32
+ * Future: migrate to DID/DWN-based auth (see https://github.com/enboxorg/enbox/issues/546).
33
+ */
34
+ export class AdminPasskeyStore {
35
+ static readonly #tableName = 'adminPasskeys';
36
+
37
+ #db: Kysely<PasskeyDatabase>;
38
+
39
+ private constructor(dialect: Dialect) {
40
+ this.#db = new Kysely<PasskeyDatabase>({ dialect });
41
+ }
42
+
43
+ /**
44
+ * Creates and initializes an `AdminPasskeyStore` instance.
45
+ * Creates the `adminPasskeys` table if it does not already exist.
46
+ */
47
+ public static async create(dialect: Dialect): Promise<AdminPasskeyStore> {
48
+ const store = new AdminPasskeyStore(dialect);
49
+ await store.#initialize();
50
+ return store;
51
+ }
52
+
53
+ /**
54
+ * Verifies that the required table exists. Throws a clear error directing
55
+ * the caller to run server migrations first.
56
+ */
57
+ async #initialize(): Promise<void> {
58
+ try {
59
+ await sql`SELECT 1 FROM ${sql.table(AdminPasskeyStore.#tableName)} LIMIT 0`.execute(this.#db);
60
+ } catch {
61
+ throw new Error(
62
+ `AdminPasskeyStore: table '${AdminPasskeyStore.#tableName}' does not exist. Run server migrations before starting.`
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Stores a new passkey credential.
69
+ */
70
+ public async save(record: AdminPasskeyRecord): Promise<void> {
71
+ await this.#db
72
+ .insertInto(AdminPasskeyStore.#tableName)
73
+ .values({
74
+ id : record.id,
75
+ name : record.name,
76
+ publicKey : record.publicKey,
77
+ counter : record.counter,
78
+ transports : record.transports,
79
+ createdAt : record.createdAt,
80
+ lastUsedAt : record.lastUsedAt ?? null,
81
+ })
82
+ .execute();
83
+ }
84
+
85
+ /**
86
+ * Retrieves a passkey by its credential ID.
87
+ */
88
+ public async getById(id: string): Promise<AdminPasskeyRecord | undefined> {
89
+ const row = await this.#db
90
+ .selectFrom(AdminPasskeyStore.#tableName)
91
+ .selectAll()
92
+ .where('id', '=', id)
93
+ .executeTakeFirst();
94
+
95
+ if (!row) {
96
+ return undefined;
97
+ }
98
+
99
+ return this.#toRecord(row);
100
+ }
101
+
102
+ /**
103
+ * Returns all registered passkeys, ordered by creation date (newest first).
104
+ */
105
+ public async list(): Promise<AdminPasskeyRecord[]> {
106
+ const rows = await this.#db
107
+ .selectFrom(AdminPasskeyStore.#tableName)
108
+ .selectAll()
109
+ .orderBy('createdAt', 'desc')
110
+ .execute();
111
+
112
+ return rows.map((row): AdminPasskeyRecord => this.#toRecord(row));
113
+ }
114
+
115
+ /**
116
+ * Returns the count of registered passkeys.
117
+ */
118
+ public async count(): Promise<number> {
119
+ const result = await this.#db
120
+ .selectFrom(AdminPasskeyStore.#tableName)
121
+ .select(this.#db.fn.countAll<number>().as('count'))
122
+ .executeTakeFirstOrThrow();
123
+
124
+ return Number(result.count);
125
+ }
126
+
127
+ /**
128
+ * Updates the counter and last-used timestamp after a successful authentication.
129
+ */
130
+ public async updateCounter(id: string, counter: number): Promise<void> {
131
+ await this.#db
132
+ .updateTable(AdminPasskeyStore.#tableName)
133
+ .set({
134
+ counter,
135
+ lastUsedAt: new Date().toISOString(),
136
+ })
137
+ .where('id', '=', id)
138
+ .execute();
139
+ }
140
+
141
+ /**
142
+ * Deletes a passkey by its credential ID.
143
+ * @returns `true` if the passkey was found and deleted.
144
+ */
145
+ public async delete(id: string): Promise<boolean> {
146
+ const result = await this.#db
147
+ .deleteFrom(AdminPasskeyStore.#tableName)
148
+ .where('id', '=', id)
149
+ .executeTakeFirstOrThrow();
150
+
151
+ return Number(result.numDeletedRows) > 0;
152
+ }
153
+
154
+ /**
155
+ * Closes the underlying database connection.
156
+ */
157
+ public async close(): Promise<void> {
158
+ await this.#db.destroy();
159
+ }
160
+
161
+ #toRecord(row: PasskeyRow): AdminPasskeyRecord {
162
+ return {
163
+ id : row.id,
164
+ name : row.name,
165
+ publicKey : row.publicKey,
166
+ counter : Number(row.counter),
167
+ transports : row.transports,
168
+ createdAt : row.createdAt,
169
+ lastUsedAt : row.lastUsedAt ?? null,
170
+ };
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Kysely type definitions
176
+ // ---------------------------------------------------------------------------
177
+
178
+ interface PasskeyRow {
179
+ id : string;
180
+ name : string;
181
+ publicKey : string;
182
+ counter : number;
183
+ transports : string;
184
+ createdAt : string;
185
+ lastUsedAt : string | null;
186
+ }
187
+
188
+ interface PasskeyDatabase {
189
+ adminPasskeys : PasskeyRow;
190
+ }
@@ -0,0 +1,116 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ /**
4
+ * A lightweight in-memory session entry.
5
+ */
6
+ type SessionEntry = {
7
+ /** Opaque session token (hex string). */
8
+ token : string;
9
+ /** Timestamp (ms) when the session was created. */
10
+ createdAt : number;
11
+ /** Timestamp (ms) when the session expires. */
12
+ expiresAt : number;
13
+ };
14
+
15
+ /**
16
+ * Manages short-lived sessions for admin passkey authentication.
17
+ *
18
+ * Sessions are opaque hex tokens stored in an in-memory `Map`. They are
19
+ * validated in {@link validateAdminAuth} as an alternative to the static
20
+ * bearer token. Expired sessions are lazily pruned on every create/validate
21
+ * call and periodically via a cleanup interval.
22
+ *
23
+ * Future: migrate to DID/DWN-based auth (see https://github.com/enboxorg/enbox/issues/546).
24
+ */
25
+ export class AdminSessionManager {
26
+ /** Default session time-to-live: 24 hours (in seconds). */
27
+ static readonly #DEFAULT_TTL_SECONDS = 86400;
28
+ /** Cleanup runs every 10 minutes. */
29
+ static readonly #CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
30
+
31
+ #sessions: Map<string, SessionEntry> = new Map();
32
+ #ttlMs: number;
33
+ #cleanupInterval: ReturnType<typeof setInterval> | undefined;
34
+
35
+ constructor(ttlSeconds?: number) {
36
+ this.#ttlMs = (ttlSeconds ?? AdminSessionManager.#DEFAULT_TTL_SECONDS) * 1000;
37
+ this.#startCleanup();
38
+ }
39
+
40
+ /**
41
+ * Creates a new session and returns the opaque token.
42
+ */
43
+ public create(): string {
44
+ this.#prune();
45
+
46
+ const token = randomBytes(32).toString('hex');
47
+ const now = Date.now();
48
+ this.#sessions.set(token, {
49
+ token,
50
+ createdAt : now,
51
+ expiresAt : now + this.#ttlMs,
52
+ });
53
+
54
+ return token;
55
+ }
56
+
57
+ /**
58
+ * Validates a session token. Returns `true` if the token is valid and not expired.
59
+ */
60
+ public validate(token: string): boolean {
61
+ const entry = this.#sessions.get(token);
62
+ if (!entry) {
63
+ return false;
64
+ }
65
+
66
+ if (Date.now() >= entry.expiresAt) {
67
+ this.#sessions.delete(token);
68
+ return false;
69
+ }
70
+
71
+ return true;
72
+ }
73
+
74
+ /**
75
+ * Revokes a session token.
76
+ */
77
+ public revoke(token: string): void {
78
+ this.#sessions.delete(token);
79
+ }
80
+
81
+ /**
82
+ * Returns the number of active (non-expired) sessions.
83
+ */
84
+ public get size(): number {
85
+ return this.#sessions.size;
86
+ }
87
+
88
+ /**
89
+ * Stops the periodic cleanup timer.
90
+ */
91
+ public destroy(): void {
92
+ if (this.#cleanupInterval) {
93
+ clearInterval(this.#cleanupInterval);
94
+ this.#cleanupInterval = undefined;
95
+ }
96
+ this.#sessions.clear();
97
+ }
98
+
99
+ /**
100
+ * Removes all expired sessions.
101
+ */
102
+ #prune(): void {
103
+ const now = Date.now();
104
+ for (const [token, entry] of this.#sessions) {
105
+ if (now >= entry.expiresAt) {
106
+ this.#sessions.delete(token);
107
+ }
108
+ }
109
+ }
110
+
111
+ #startCleanup(): void {
112
+ this.#cleanupInterval = setInterval((): void => {
113
+ this.#prune();
114
+ }, AdminSessionManager.#CLEANUP_INTERVAL_MS);
115
+ }
116
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Dialect } from '@enbox/dwn-sql-store';
2
2
 
3
- import { Kysely } from 'kysely';
4
3
  import log from 'loglevel';
4
+ import { Kysely, sql } from 'kysely';
5
5
 
6
6
  import { escapeLikeWildcards } from '../lib/sql-utils.js';
7
7
 
@@ -96,53 +96,16 @@ export class AuditLog {
96
96
  }
97
97
 
98
98
  /**
99
- * Creates the audit log table and indices if they do not exist.
99
+ * Verifies that the required table exists. Throws a clear error directing
100
+ * the caller to run server migrations first.
100
101
  */
101
102
  async #initialize(): Promise<void> {
102
- await this.#db.schema
103
- .createTable(AuditLog.#tableName)
104
- .ifNotExists()
105
- .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
106
- .addColumn('timestamp', 'text', (col) => col.notNull())
107
- .addColumn('actor', 'text', (col) => col.notNull())
108
- .addColumn('action', 'text', (col) => col.notNull())
109
- .addColumn('target', 'text')
110
- .addColumn('detail', 'text')
111
- .execute();
112
-
113
- // Indices for common query patterns. Wrapped in try/catch because
114
- // `CREATE INDEX IF NOT EXISTS` syntax varies across dialects.
115
- try {
116
- await this.#db.schema
117
- .createIndex('index_audit_timestamp')
118
- .ifNotExists()
119
- .on(AuditLog.#tableName)
120
- .column('timestamp')
121
- .execute();
122
- } catch {
123
- // Index already exists.
124
- }
125
-
126
- try {
127
- await this.#db.schema
128
- .createIndex('index_audit_target')
129
- .ifNotExists()
130
- .on(AuditLog.#tableName)
131
- .column('target')
132
- .execute();
133
- } catch {
134
- // Index already exists.
135
- }
136
-
137
103
  try {
138
- await this.#db.schema
139
- .createIndex('index_audit_action')
140
- .ifNotExists()
141
- .on(AuditLog.#tableName)
142
- .column('action')
143
- .execute();
104
+ await sql`SELECT 1 FROM ${sql.table(AuditLog.#tableName)} LIMIT 0`.execute(this.#db);
144
105
  } catch {
145
- // Index already exists.
106
+ throw new Error(
107
+ `AuditLog: table '${AuditLog.#tableName}' does not exist. Run server migrations before starting.`
108
+ );
146
109
  }
147
110
  }
148
111
 
@@ -1,5 +1,7 @@
1
1
  export { ActivityLog } from './activity-log.js';
2
2
  export { AdminApi } from './admin-api.js';
3
+ export { AdminPasskeyStore } from './admin-passkey-store.js';
4
+ export { AdminSessionManager } from './admin-session.js';
3
5
  export { AdminStore } from './admin-store.js';
4
6
  export { AuditLog } from './audit-log.js';
5
7
  export { validateAdminAuth } from './admin-auth.js';
@@ -9,6 +11,7 @@ export type {
9
11
  AdminConnectionSnapshot,
10
12
  AdminHealthCheck,
11
13
  AdminMessageSummary,
14
+ AdminPasskeySummary,
12
15
  AdminProtocolSummary,
13
16
  AdminServerStats,
14
17
  AdminSubscriptionSnapshot,
@@ -30,5 +33,7 @@ export type {
30
33
  TenantQuotaStatus,
31
34
  TenantStats,
32
35
  } from './types.js';
36
+ export type { AdminAuthResult } from './admin-auth.js';
37
+ export type { AdminPasskeyRecord } from './admin-passkey-store.js';
33
38
  export type { AuditEvent, AuditEventInput, AuditQueryOptions, AuditRetentionConfig } from './audit-log.js';
34
39
  export type { WebhookPayload } from './webhook-manager.js';
@@ -350,3 +350,31 @@ export type AdminWebhookInput = {
350
350
  events : string[];
351
351
  secret? : string;
352
352
  };
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Passkey (WebAuthn) authentication
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Summary of a registered passkey for the admin API response.
360
+ *
361
+ * Future: migrate to DID/DWN-based auth (see https://github.com/enboxorg/enbox/issues/546).
362
+ */
363
+ export type AdminPasskeySummary = {
364
+ /** Base64url-encoded credential ID. */
365
+ id : string;
366
+ /** Human-readable name (e.g. "MacBook Touch ID"). */
367
+ name : string;
368
+ /** ISO-8601 timestamp when the passkey was registered. */
369
+ createdAt : string;
370
+ /** ISO-8601 timestamp of the most recent successful authentication. */
371
+ lastUsedAt : string | null;
372
+ };
373
+
374
+ /**
375
+ * Request body for registering a new passkey.
376
+ */
377
+ export type AdminPasskeyRegisterInput = {
378
+ /** Human-readable name for the passkey. */
379
+ name? : string;
380
+ };
@@ -2,9 +2,9 @@ import type { Dialect } from '@enbox/dwn-sql-store';
2
2
  import type { AdminWebhook, AdminWebhookInput } from './types.js';
3
3
 
4
4
  import { createHmac } from 'crypto';
5
- import { Kysely } from 'kysely';
6
5
  import log from 'loglevel';
7
6
  import { v4 as uuidv4 } from 'uuid';
7
+ import { Kysely, sql } from 'kysely';
8
8
 
9
9
  /**
10
10
  * Payload delivered to webhook endpoints.
@@ -50,16 +50,18 @@ export class WebhookManager {
50
50
  return manager;
51
51
  }
52
52
 
53
+ /**
54
+ * Verifies that the required table exists. Throws a clear error directing
55
+ * the caller to run server migrations first.
56
+ */
53
57
  async #initialize(): Promise<void> {
54
- await this.#db.schema
55
- .createTable(WebhookManager.#tableName)
56
- .ifNotExists()
57
- .addColumn('id', 'text', (col) => col.primaryKey())
58
- .addColumn('url', 'text', (col) => col.notNull())
59
- .addColumn('events', 'text', (col) => col.notNull()) // JSON array
60
- .addColumn('secret', 'text')
61
- .addColumn('createdAt', 'text', (col) => col.notNull())
62
- .execute();
58
+ try {
59
+ await sql`SELECT 1 FROM ${sql.table(WebhookManager.#tableName)} LIMIT 0`.execute(this.#db);
60
+ } catch {
61
+ throw new Error(
62
+ `WebhookManager: table '${WebhookManager.#tableName}' does not exist. Run server migrations before starting.`
63
+ );
64
+ }
63
65
  }
64
66
 
65
67
  // ---------------------------------------------------------------------------
package/src/config.ts CHANGED
@@ -122,6 +122,27 @@ export const config = {
122
122
  */
123
123
  adminMetricsUpdateIntervalSeconds: parseInt(process.env.DWN_ADMIN_METRICS_UPDATE_INTERVAL || '30'),
124
124
 
125
+ /**
126
+ * WebAuthn Relying Party ID for admin passkey authentication. Typically
127
+ * the hostname of the DWN server (e.g. `dev.aws.dwn.enbox.id`). When not
128
+ * set, the hostname is extracted from `DWN_BASE_URL` at runtime.
129
+ *
130
+ * @see https://github.com/enboxorg/enbox/issues/546
131
+ */
132
+ adminWebAuthnRpId: process.env.DWN_ADMIN_WEBAUTHN_RP_ID || undefined,
133
+
134
+ /**
135
+ * Human-readable Relying Party name shown during passkey registration.
136
+ * Defaults to `"DWN Admin"`.
137
+ */
138
+ adminWebAuthnRpName: process.env.DWN_ADMIN_WEBAUTHN_RP_NAME || 'DWN Admin',
139
+
140
+ /**
141
+ * Session time-to-live (in seconds) for passkey-authenticated sessions.
142
+ * Defaults to 86400 (24 hours).
143
+ */
144
+ adminSessionTtlSeconds: parseInt(process.env.DWN_ADMIN_SESSION_TTL || '86400'),
145
+
125
146
  // ---------------------------------------------------------------------------
126
147
  // Per-tenant storage quotas
127
148
  // ---------------------------------------------------------------------------
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();