@enbox/dwn-server 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/esm/src/admin/admin-api.d.ts +5 -1
  2. package/dist/esm/src/admin/admin-api.d.ts.map +1 -1
  3. package/dist/esm/src/admin/admin-api.js +327 -7
  4. package/dist/esm/src/admin/admin-api.js.map +1 -1
  5. package/dist/esm/src/admin/admin-auth.d.ts +21 -3
  6. package/dist/esm/src/admin/admin-auth.d.ts.map +1 -1
  7. package/dist/esm/src/admin/admin-auth.js +17 -9
  8. package/dist/esm/src/admin/admin-auth.js.map +1 -1
  9. package/dist/esm/src/admin/admin-passkey-store.d.ts +68 -0
  10. package/dist/esm/src/admin/admin-passkey-store.d.ts.map +1 -0
  11. package/dist/esm/src/admin/admin-passkey-store.js +132 -0
  12. package/dist/esm/src/admin/admin-passkey-store.js.map +1 -0
  13. package/dist/esm/src/admin/admin-session.d.ts +35 -0
  14. package/dist/esm/src/admin/admin-session.d.ts.map +1 -0
  15. package/dist/esm/src/admin/admin-session.js +91 -0
  16. package/dist/esm/src/admin/admin-session.js.map +1 -0
  17. package/dist/esm/src/admin/admin-store.d.ts +4 -0
  18. package/dist/esm/src/admin/admin-store.d.ts.map +1 -1
  19. package/dist/esm/src/admin/admin-store.js +6 -2
  20. package/dist/esm/src/admin/admin-store.js.map +1 -1
  21. package/dist/esm/src/admin/audit-log.d.ts.map +1 -1
  22. package/dist/esm/src/admin/audit-log.js +5 -43
  23. package/dist/esm/src/admin/audit-log.js.map +1 -1
  24. package/dist/esm/src/admin/index.d.ts +5 -1
  25. package/dist/esm/src/admin/index.d.ts.map +1 -1
  26. package/dist/esm/src/admin/index.js +2 -0
  27. package/dist/esm/src/admin/index.js.map +1 -1
  28. package/dist/esm/src/admin/types.d.ts +22 -0
  29. package/dist/esm/src/admin/types.d.ts.map +1 -1
  30. package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
  31. package/dist/esm/src/admin/webhook-manager.js +11 -10
  32. package/dist/esm/src/admin/webhook-manager.js.map +1 -1
  33. package/dist/esm/src/config.d.ts +18 -0
  34. package/dist/esm/src/config.d.ts.map +1 -1
  35. package/dist/esm/src/config.js +18 -0
  36. package/dist/esm/src/config.js.map +1 -1
  37. package/dist/esm/src/connect/connect-server.d.ts +75 -0
  38. package/dist/esm/src/connect/connect-server.d.ts.map +1 -0
  39. package/dist/esm/src/{web5-connect/web5-connect-server.js → connect/connect-server.js} +32 -24
  40. package/dist/esm/src/connect/connect-server.js.map +1 -0
  41. package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.d.ts +11 -1
  42. package/dist/esm/src/connect/sql-ttl-cache.d.ts.map +1 -0
  43. package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.js +19 -20
  44. package/dist/esm/src/connect/sql-ttl-cache.js.map +1 -0
  45. package/dist/esm/src/dwn-server.d.ts.map +1 -1
  46. package/dist/esm/src/dwn-server.js +46 -11
  47. package/dist/esm/src/dwn-server.js.map +1 -1
  48. package/dist/esm/src/http-api.d.ts +6 -2
  49. package/dist/esm/src/http-api.d.ts.map +1 -1
  50. package/dist/esm/src/http-api.js +31 -17
  51. package/dist/esm/src/http-api.js.map +1 -1
  52. package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
  53. package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
  54. package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
  55. package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
  56. package/dist/esm/src/migrations/index.d.ts +13 -0
  57. package/dist/esm/src/migrations/index.d.ts.map +1 -0
  58. package/dist/esm/src/migrations/index.js +5 -0
  59. package/dist/esm/src/migrations/index.js.map +1 -0
  60. package/dist/esm/src/registration/registration-store.d.ts +4 -0
  61. package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
  62. package/dist/esm/src/registration/registration-store.js +11 -34
  63. package/dist/esm/src/registration/registration-store.js.map +1 -1
  64. package/dist/esm/src/server-migration-runner.d.ts +23 -0
  65. package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
  66. package/dist/esm/src/server-migration-runner.js +57 -0
  67. package/dist/esm/src/server-migration-runner.js.map +1 -0
  68. package/dist/esm/src/storage.d.ts +15 -0
  69. package/dist/esm/src/storage.d.ts.map +1 -1
  70. package/dist/esm/src/storage.js +135 -17
  71. package/dist/esm/src/storage.js.map +1 -1
  72. package/package.json +8 -27
  73. package/src/admin/admin-api.ts +403 -10
  74. package/src/admin/admin-auth.ts +38 -9
  75. package/src/admin/admin-passkey-store.ts +190 -0
  76. package/src/admin/admin-session.ts +116 -0
  77. package/src/admin/admin-store.ts +6 -2
  78. package/src/admin/audit-log.ts +7 -44
  79. package/src/admin/index.ts +5 -0
  80. package/src/admin/types.ts +28 -0
  81. package/src/admin/webhook-manager.ts +12 -10
  82. package/src/config.ts +21 -0
  83. package/src/connect/connect-server.ts +150 -0
  84. package/src/{web5-connect → connect}/sql-ttl-cache.ts +21 -22
  85. package/src/dwn-server.ts +49 -11
  86. package/src/http-api.ts +37 -18
  87. package/src/migrations/001-initial-server-schema.ts +114 -0
  88. package/src/migrations/index.ts +18 -0
  89. package/src/registration/registration-store.ts +13 -36
  90. package/src/server-migration-runner.ts +74 -0
  91. package/src/storage.ts +145 -17
  92. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +0 -1
  93. package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +0 -1
  94. package/dist/esm/src/web5-connect/web5-connect-server.d.ts +0 -58
  95. package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +0 -1
  96. package/dist/esm/src/web5-connect/web5-connect-server.js.map +0 -1
  97. package/src/web5-connect/web5-connect-server.ts +0 -123
@@ -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
+ }
@@ -161,10 +161,14 @@ export class AdminStore {
161
161
 
162
162
  /**
163
163
  * Returns the total data storage in bytes for a tenant.
164
+ *
165
+ * Uses `messageStoreMessages.dataSize` rather than `dataRefs.dataSize` so that
166
+ * **all** data is accounted for — including small payloads (<=30 KB) that the
167
+ * DWN SDK stores inline as `encodedData` and never writes to the data store.
164
168
  */
165
169
  public async getTenantStorageSize(did: string): Promise<number> {
166
170
  const result = await this.db
167
- .selectFrom('dataRefs')
171
+ .selectFrom('messageStoreMessages')
168
172
  .select(this.db.fn.sum<number>('dataSize').as('totalBytes'))
169
173
  .where('tenant', '=', did)
170
174
  .executeTakeFirstOrThrow();
@@ -212,7 +216,7 @@ export class AdminStore {
212
216
  .select(this.db.fn.countAll<number>().as('count'))
213
217
  .executeTakeFirstOrThrow(),
214
218
  this.db
215
- .selectFrom('dataRefs')
219
+ .selectFrom('messageStoreMessages')
216
220
  .select(this.db.fn.sum<number>('dataSize').as('totalBytes'))
217
221
  .executeTakeFirstOrThrow(),
218
222
  this.db
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,150 @@
1
+ import type { Dialect } from '@enbox/dwn-sql-store';
2
+
3
+ import { CryptoUtils } from '@enbox/crypto';
4
+
5
+ import { SqlTtlCache } from './sql-ttl-cache.js';
6
+
7
+ /**
8
+ * The Connect Request object.
9
+ */
10
+ export type ConnectRequest = any; // TODO: define type in common repo for reuse (https://github.com/enboxorg/enbox/issues/138)
11
+
12
+ /**
13
+ * The Connect Response object, which is also an OIDC ID token
14
+ */
15
+ export type ConnectResponse = any; // TODO: define type in common repo for reuse (https://github.com/enboxorg/enbox/issues/138)
16
+
17
+ /**
18
+ * The result of the setConnectRequest() method.
19
+ */
20
+ export type SetConnectRequestResult = {
21
+ /**
22
+ * The Request URI that the wallet should use to retrieve the request object.
23
+ */
24
+ request_uri: string;
25
+
26
+ /**
27
+ * The time in seconds that the Request URI is valid for.
28
+ */
29
+ expires_in: number;
30
+ };
31
+
32
+ /**
33
+ * The Connect Server is responsible for handling the DWeb Connect flow.
34
+ */
35
+ export class ConnectServer {
36
+ public static readonly ttlInSeconds = 600;
37
+
38
+ private baseUrl: string;
39
+ private cache: SqlTtlCache;
40
+
41
+ /**
42
+ * Creates a new instance of the Connect Server.
43
+ * @param params.baseUrl The the base URL of the connect server including the port.
44
+ * This is given to the Identity Provider (wallet) to fetch the Connect Request object.
45
+ * @param params.sqlDialect The SQL dialect to use for the TTL cache. Must point to a database
46
+ * where server migrations have already been run.
47
+ */
48
+ public static async create({ baseUrl, sqlDialect }: {
49
+ baseUrl: string;
50
+ sqlDialect: Dialect;
51
+ }): Promise<ConnectServer> {
52
+ const connectServer = new ConnectServer({ baseUrl });
53
+
54
+ // Initialize TTL cache.
55
+ connectServer.cache = await SqlTtlCache.create(sqlDialect);
56
+
57
+ return connectServer;
58
+ }
59
+
60
+ private constructor({ baseUrl }: {
61
+ baseUrl: string;
62
+ }) {
63
+ this.baseUrl = baseUrl;
64
+ }
65
+
66
+ /**
67
+ * Stores the given Connect Request object, which is also an OAuth 2 Pushed Authorization Request (PAR) object.
68
+ * This is the initial call to the connect server to start the DWeb Connect flow.
69
+ */
70
+ public async setConnectRequest(request: ConnectRequest): Promise<SetConnectRequestResult> {
71
+ // Generate a request URI
72
+ const requestId = CryptoUtils.randomUuid();
73
+ const request_uri = `${this.baseUrl}/connect/authorize/${requestId}.jwt`;
74
+
75
+ // Store the Request Object.
76
+ this.cache.insert(`request:${requestId}`, request, ConnectServer.ttlInSeconds);
77
+
78
+ return {
79
+ request_uri,
80
+ expires_in: ConnectServer.ttlInSeconds,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Returns the Connect Request object. The request ID can only be used once.
86
+ */
87
+ public async getConnectRequest(requestId: string): Promise<ConnectRequest | undefined> {
88
+ const request = await this.cache.get(`request:${requestId}`);
89
+
90
+ // Delete the Request Object from cache once it has been retrieved.
91
+ // IMPORTANT: only delete if the object exists, otherwise there could be a race condition
92
+ // where the object does not exist in this call but becomes available immediately after,
93
+ // we would end up deleting it before it is successfully retrieved.
94
+ if (request !== undefined) {
95
+ this.cache.delete(`request:${requestId}`);
96
+ }
97
+
98
+ return request;
99
+ }
100
+
101
+ /**
102
+ * Sets the Connect Response object, which is also an OIDC ID token.
103
+ */
104
+ public async setConnectResponse(state: string, response: ConnectResponse): Promise<any> {
105
+ this.cache.insert(`response:${state}`, response, ConnectServer.ttlInSeconds);
106
+ }
107
+
108
+ /**
109
+ * Gets the Connect Response object. The `state` string can only be used once.
110
+ */
111
+ public async getConnectResponse(state: string): Promise<ConnectResponse | undefined> {
112
+ const response = await this.cache.get(`response:${state}`);
113
+
114
+ // Delete the Response object from the cache once it has been retrieved.
115
+ // IMPORTANT: only delete if the object exists, otherwise there could be a race condition
116
+ // where the object does not exist in this call but becomes available immediately after,
117
+ // we would end up deleting it before it is successfully retrieved.
118
+ if (response !== undefined) {
119
+ this.cache.delete(`response:${state}`);
120
+ }
121
+
122
+ return response;
123
+ }
124
+
125
+ /**
126
+ * Stops the TTL cache cleanup timer. Must be called during shutdown to
127
+ * prevent leaked timers.
128
+ */
129
+ public close(): void {
130
+ this.cache.close();
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Deprecated aliases — migration aid
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** @deprecated Use {@link ConnectRequest} instead. Will be removed in a future version. */
139
+ export type Web5ConnectRequest = ConnectRequest;
140
+
141
+ /** @deprecated Use {@link ConnectResponse} instead. Will be removed in a future version. */
142
+ export type Web5ConnectResponse = ConnectResponse;
143
+
144
+ /** @deprecated Use {@link SetConnectRequestResult} instead. Will be removed in a future version. */
145
+ export type SetWeb5ConnectRequestResult = SetConnectRequestResult;
146
+
147
+ /** @deprecated Use {@link ConnectServer} instead. Will be removed in a future version. */
148
+ export const Web5ConnectServer = ConnectServer;
149
+ /** @deprecated Use {@link ConnectServer} instead. Will be removed in a future version. */
150
+ export type Web5ConnectServer = ConnectServer;