@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.
- 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/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/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 +4 -0
- package/dist/esm/src/http-api.d.ts.map +1 -1
- package/dist/esm/src/http-api.js +14 -4
- 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/dist/esm/src/web5-connect/sql-ttl-cache.d.ts +11 -1
- package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +1 -1
- package/dist/esm/src/web5-connect/sql-ttl-cache.js +19 -20
- package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +1 -1
- package/dist/esm/src/web5-connect/web5-connect-server.d.ts +10 -3
- package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +1 -1
- package/dist/esm/src/web5-connect/web5-connect-server.js +10 -4
- package/dist/esm/src/web5-connect/web5-connect-server.js.map +1 -1
- package/package.json +3 -2
- 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/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/dwn-server.ts +49 -11
- package/src/http-api.ts +20 -5
- 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/src/web5-connect/sql-ttl-cache.ts +21 -22
- 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
|
+
}
|
package/src/admin/audit-log.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
106
|
+
throw new Error(
|
|
107
|
+
`AuditLog: table '${AuditLog.#tableName}' does not exist. Run server migrations before starting.`
|
|
108
|
+
);
|
|
146
109
|
}
|
|
147
110
|
}
|
|
148
111
|
|
package/src/admin/index.ts
CHANGED
|
@@ -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';
|
package/src/admin/types.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
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();
|