@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.
- package/dist/esm/src/admin/admin-api.d.ts +5 -1
- package/dist/esm/src/admin/admin-api.d.ts.map +1 -1
- package/dist/esm/src/admin/admin-api.js +327 -7
- package/dist/esm/src/admin/admin-api.js.map +1 -1
- package/dist/esm/src/admin/admin-auth.d.ts +21 -3
- package/dist/esm/src/admin/admin-auth.d.ts.map +1 -1
- package/dist/esm/src/admin/admin-auth.js +17 -9
- package/dist/esm/src/admin/admin-auth.js.map +1 -1
- package/dist/esm/src/admin/admin-passkey-store.d.ts +68 -0
- package/dist/esm/src/admin/admin-passkey-store.d.ts.map +1 -0
- package/dist/esm/src/admin/admin-passkey-store.js +132 -0
- package/dist/esm/src/admin/admin-passkey-store.js.map +1 -0
- package/dist/esm/src/admin/admin-session.d.ts +35 -0
- package/dist/esm/src/admin/admin-session.d.ts.map +1 -0
- package/dist/esm/src/admin/admin-session.js +91 -0
- package/dist/esm/src/admin/admin-session.js.map +1 -0
- package/dist/esm/src/admin/admin-store.d.ts +4 -0
- package/dist/esm/src/admin/admin-store.d.ts.map +1 -1
- package/dist/esm/src/admin/admin-store.js +6 -2
- package/dist/esm/src/admin/admin-store.js.map +1 -1
- package/dist/esm/src/admin/audit-log.d.ts.map +1 -1
- package/dist/esm/src/admin/audit-log.js +5 -43
- package/dist/esm/src/admin/audit-log.js.map +1 -1
- package/dist/esm/src/admin/index.d.ts +5 -1
- package/dist/esm/src/admin/index.d.ts.map +1 -1
- package/dist/esm/src/admin/index.js +2 -0
- package/dist/esm/src/admin/index.js.map +1 -1
- package/dist/esm/src/admin/types.d.ts +22 -0
- package/dist/esm/src/admin/types.d.ts.map +1 -1
- package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
- package/dist/esm/src/admin/webhook-manager.js +11 -10
- package/dist/esm/src/admin/webhook-manager.js.map +1 -1
- package/dist/esm/src/config.d.ts +18 -0
- package/dist/esm/src/config.d.ts.map +1 -1
- package/dist/esm/src/config.js +18 -0
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/connect/connect-server.d.ts +75 -0
- package/dist/esm/src/connect/connect-server.d.ts.map +1 -0
- package/dist/esm/src/{web5-connect/web5-connect-server.js → connect/connect-server.js} +32 -24
- package/dist/esm/src/connect/connect-server.js.map +1 -0
- package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.d.ts +11 -1
- package/dist/esm/src/connect/sql-ttl-cache.d.ts.map +1 -0
- package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.js +19 -20
- package/dist/esm/src/connect/sql-ttl-cache.js.map +1 -0
- package/dist/esm/src/dwn-server.d.ts.map +1 -1
- package/dist/esm/src/dwn-server.js +46 -11
- package/dist/esm/src/dwn-server.js.map +1 -1
- package/dist/esm/src/http-api.d.ts +6 -2
- package/dist/esm/src/http-api.d.ts.map +1 -1
- package/dist/esm/src/http-api.js +31 -17
- package/dist/esm/src/http-api.js.map +1 -1
- package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
- package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
- package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
- package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
- package/dist/esm/src/migrations/index.d.ts +13 -0
- package/dist/esm/src/migrations/index.d.ts.map +1 -0
- package/dist/esm/src/migrations/index.js +5 -0
- package/dist/esm/src/migrations/index.js.map +1 -0
- package/dist/esm/src/registration/registration-store.d.ts +4 -0
- package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
- package/dist/esm/src/registration/registration-store.js +11 -34
- package/dist/esm/src/registration/registration-store.js.map +1 -1
- package/dist/esm/src/server-migration-runner.d.ts +23 -0
- package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
- package/dist/esm/src/server-migration-runner.js +57 -0
- package/dist/esm/src/server-migration-runner.js.map +1 -0
- package/dist/esm/src/storage.d.ts +15 -0
- package/dist/esm/src/storage.d.ts.map +1 -1
- package/dist/esm/src/storage.js +135 -17
- package/dist/esm/src/storage.js.map +1 -1
- package/package.json +8 -27
- package/src/admin/admin-api.ts +403 -10
- package/src/admin/admin-auth.ts +38 -9
- package/src/admin/admin-passkey-store.ts +190 -0
- package/src/admin/admin-session.ts +116 -0
- package/src/admin/admin-store.ts +6 -2
- package/src/admin/audit-log.ts +7 -44
- package/src/admin/index.ts +5 -0
- package/src/admin/types.ts +28 -0
- package/src/admin/webhook-manager.ts +12 -10
- package/src/config.ts +21 -0
- package/src/connect/connect-server.ts +150 -0
- package/src/{web5-connect → connect}/sql-ttl-cache.ts +21 -22
- package/src/dwn-server.ts +49 -11
- package/src/http-api.ts +37 -18
- package/src/migrations/001-initial-server-schema.ts +114 -0
- package/src/migrations/index.ts +18 -0
- package/src/registration/registration-store.ts +13 -36
- package/src/server-migration-runner.ts +74 -0
- package/src/storage.ts +145 -17
- package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +0 -1
- package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +0 -1
- package/dist/esm/src/web5-connect/web5-connect-server.d.ts +0 -58
- package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +0 -1
- package/dist/esm/src/web5-connect/web5-connect-server.js.map +0 -1
- 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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
233
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
325
|
-
if (
|
|
326
|
-
return
|
|
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
|
|
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
|
-
// ---
|
|
388
|
-
const connectResponse = await this.#
|
|
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
|
-
//
|
|
813
|
+
// Connect routes
|
|
795
814
|
// ---------------------------------------------------------------------------
|
|
796
815
|
|
|
797
|
-
async #
|
|
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.
|
|
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
|
|
847
|
+
log.info(`Retrieving Connect Request object of ID: ${requestId}...`);
|
|
829
848
|
|
|
830
|
-
const requestObjectJwt = await this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
.
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|