@enbox/dwn-server 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/esm/src/admin/admin-api.d.ts +5 -1
  2. package/dist/esm/src/admin/admin-api.d.ts.map +1 -1
  3. package/dist/esm/src/admin/admin-api.js +327 -7
  4. package/dist/esm/src/admin/admin-api.js.map +1 -1
  5. package/dist/esm/src/admin/admin-auth.d.ts +21 -3
  6. package/dist/esm/src/admin/admin-auth.d.ts.map +1 -1
  7. package/dist/esm/src/admin/admin-auth.js +17 -9
  8. package/dist/esm/src/admin/admin-auth.js.map +1 -1
  9. package/dist/esm/src/admin/admin-passkey-store.d.ts +68 -0
  10. package/dist/esm/src/admin/admin-passkey-store.d.ts.map +1 -0
  11. package/dist/esm/src/admin/admin-passkey-store.js +132 -0
  12. package/dist/esm/src/admin/admin-passkey-store.js.map +1 -0
  13. package/dist/esm/src/admin/admin-session.d.ts +35 -0
  14. package/dist/esm/src/admin/admin-session.d.ts.map +1 -0
  15. package/dist/esm/src/admin/admin-session.js +91 -0
  16. package/dist/esm/src/admin/admin-session.js.map +1 -0
  17. package/dist/esm/src/admin/audit-log.d.ts.map +1 -1
  18. package/dist/esm/src/admin/audit-log.js +5 -43
  19. package/dist/esm/src/admin/audit-log.js.map +1 -1
  20. package/dist/esm/src/admin/index.d.ts +5 -1
  21. package/dist/esm/src/admin/index.d.ts.map +1 -1
  22. package/dist/esm/src/admin/index.js +2 -0
  23. package/dist/esm/src/admin/index.js.map +1 -1
  24. package/dist/esm/src/admin/types.d.ts +22 -0
  25. package/dist/esm/src/admin/types.d.ts.map +1 -1
  26. package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
  27. package/dist/esm/src/admin/webhook-manager.js +11 -10
  28. package/dist/esm/src/admin/webhook-manager.js.map +1 -1
  29. package/dist/esm/src/config.d.ts +18 -0
  30. package/dist/esm/src/config.d.ts.map +1 -1
  31. package/dist/esm/src/config.js +18 -0
  32. package/dist/esm/src/config.js.map +1 -1
  33. package/dist/esm/src/dwn-server.d.ts +20 -1
  34. package/dist/esm/src/dwn-server.d.ts.map +1 -1
  35. package/dist/esm/src/dwn-server.js +111 -52
  36. package/dist/esm/src/dwn-server.js.map +1 -1
  37. package/dist/esm/src/http-api.d.ts +4 -0
  38. package/dist/esm/src/http-api.d.ts.map +1 -1
  39. package/dist/esm/src/http-api.js +14 -4
  40. package/dist/esm/src/http-api.js.map +1 -1
  41. package/dist/esm/src/index.d.ts +2 -0
  42. package/dist/esm/src/index.d.ts.map +1 -1
  43. package/dist/esm/src/index.js +2 -0
  44. package/dist/esm/src/index.js.map +1 -1
  45. package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
  46. package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
  47. package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
  48. package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
  49. package/dist/esm/src/migrations/index.d.ts +13 -0
  50. package/dist/esm/src/migrations/index.d.ts.map +1 -0
  51. package/dist/esm/src/migrations/index.js +5 -0
  52. package/dist/esm/src/migrations/index.js.map +1 -0
  53. package/dist/esm/src/registration/registration-store.d.ts +4 -0
  54. package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
  55. package/dist/esm/src/registration/registration-store.js +11 -34
  56. package/dist/esm/src/registration/registration-store.js.map +1 -1
  57. package/dist/esm/src/server-migration-runner.d.ts +23 -0
  58. package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
  59. package/dist/esm/src/server-migration-runner.js +57 -0
  60. package/dist/esm/src/server-migration-runner.js.map +1 -0
  61. package/dist/esm/src/storage.d.ts +15 -0
  62. package/dist/esm/src/storage.d.ts.map +1 -1
  63. package/dist/esm/src/storage.js +135 -17
  64. package/dist/esm/src/storage.js.map +1 -1
  65. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts +11 -1
  66. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +1 -1
  67. package/dist/esm/src/web5-connect/sql-ttl-cache.js +19 -20
  68. package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +1 -1
  69. package/dist/esm/src/web5-connect/web5-connect-server.d.ts +10 -3
  70. package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +1 -1
  71. package/dist/esm/src/web5-connect/web5-connect-server.js +10 -4
  72. package/dist/esm/src/web5-connect/web5-connect-server.js.map +1 -1
  73. package/package.json +3 -2
  74. package/src/admin/admin-api.ts +403 -10
  75. package/src/admin/admin-auth.ts +38 -9
  76. package/src/admin/admin-passkey-store.ts +190 -0
  77. package/src/admin/admin-session.ts +116 -0
  78. package/src/admin/audit-log.ts +7 -44
  79. package/src/admin/index.ts +5 -0
  80. package/src/admin/types.ts +28 -0
  81. package/src/admin/webhook-manager.ts +12 -10
  82. package/src/config.ts +21 -0
  83. package/src/dwn-server.ts +137 -55
  84. package/src/http-api.ts +20 -5
  85. package/src/index.ts +2 -0
  86. package/src/migrations/001-initial-server-schema.ts +114 -0
  87. package/src/migrations/index.ts +18 -0
  88. package/src/registration/registration-store.ts +13 -36
  89. package/src/server-migration-runner.ts +74 -0
  90. package/src/storage.ts +145 -17
  91. package/src/web5-connect/sql-ttl-cache.ts +21 -22
  92. package/src/web5-connect/web5-connect-server.ts +14 -5
@@ -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
  // ---------------------------------------------------------------------------