@pablozaiden/webapp 0.0.1 → 0.2.0

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.
@@ -1,8 +1,19 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
- import type { ApiKeyRecord, DeviceAuthRequestRecord, RefreshSessionRecord, SigningKeyRecord, StoredPasskey, WebAppStore } from "./store";
5
- import type { LogLevelName, ThemePreference } from "../../contracts";
4
+ import type {
5
+ ApiKeyRecord,
6
+ AuditEventRecord,
7
+ DeviceAuthRequestRecord,
8
+ RefreshSessionRecord,
9
+ SigningKeyRecord,
10
+ StoredPasskey,
11
+ UserRecord,
12
+ UserSetupLinkRecord,
13
+ WebAppStore,
14
+ } from "./store";
15
+ import type { LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
16
+ import { createUserRecord } from "./users";
6
17
 
7
18
  type Row = Record<string, unknown>;
8
19
 
@@ -35,15 +46,71 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
35
46
  mkdirSync(dirname(dbPath), { recursive: true });
36
47
  const db = new Database(dbPath);
37
48
 
38
- function initialize(): void {
49
+ function tableExists(table: string): boolean {
50
+ const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as Row | null;
51
+ return Boolean(row);
52
+ }
53
+
54
+ function tableColumns(table: string): string[] {
55
+ return (db.query(`PRAGMA table_info(${table})`).all() as Row[]).map((row) => text(row["name"]));
56
+ }
57
+
58
+ function hasColumn(table: string, column: string): boolean {
59
+ return tableExists(table) && tableColumns(table).includes(column);
60
+ }
61
+
62
+ function countRows(table: string, where = ""): number {
63
+ if (!tableExists(table)) {
64
+ return 0;
65
+ }
66
+ const row = db.query(`SELECT COUNT(*) AS count FROM ${table}${where}`).get() as Row | null;
67
+ return Number(row?.["count"] ?? 0);
68
+ }
69
+
70
+ function createSchema(): void {
39
71
  db.exec(`
72
+ CREATE TABLE IF NOT EXISTS webapp_users (
73
+ id TEXT PRIMARY KEY,
74
+ username TEXT NOT NULL UNIQUE,
75
+ role TEXT NOT NULL,
76
+ auth_version INTEGER NOT NULL,
77
+ created_at TEXT NOT NULL,
78
+ updated_at TEXT NOT NULL,
79
+ last_login_at TEXT,
80
+ disabled_at TEXT
81
+ );
40
82
  CREATE TABLE IF NOT EXISTS webapp_preferences (
41
- key TEXT PRIMARY KEY,
83
+ key TEXT NOT NULL,
84
+ user_id TEXT NOT NULL,
42
85
  value TEXT NOT NULL,
43
- updated_at TEXT NOT NULL
86
+ updated_at TEXT NOT NULL,
87
+ PRIMARY KEY (key, user_id)
88
+ );
89
+ CREATE TABLE IF NOT EXISTS webapp_user_setup_links (
90
+ id TEXT PRIMARY KEY,
91
+ user_id TEXT NOT NULL,
92
+ token_hash TEXT NOT NULL UNIQUE,
93
+ kind TEXT NOT NULL,
94
+ created_by_user_id TEXT,
95
+ created_at TEXT NOT NULL,
96
+ expires_at TEXT NOT NULL,
97
+ consumed_at TEXT,
98
+ FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE,
99
+ FOREIGN KEY (created_by_user_id) REFERENCES webapp_users(id) ON DELETE SET NULL
100
+ );
101
+ CREATE TABLE IF NOT EXISTS webapp_audit_events (
102
+ id TEXT PRIMARY KEY,
103
+ event_type TEXT NOT NULL,
104
+ actor_user_id TEXT,
105
+ target_user_id TEXT,
106
+ metadata TEXT NOT NULL,
107
+ created_at TEXT NOT NULL,
108
+ FOREIGN KEY (actor_user_id) REFERENCES webapp_users(id) ON DELETE SET NULL,
109
+ FOREIGN KEY (target_user_id) REFERENCES webapp_users(id) ON DELETE SET NULL
44
110
  );
45
111
  CREATE TABLE IF NOT EXISTS webapp_passkeys (
46
112
  id TEXT PRIMARY KEY,
113
+ user_id TEXT NOT NULL UNIQUE,
47
114
  name TEXT NOT NULL,
48
115
  credential_id TEXT NOT NULL UNIQUE,
49
116
  public_key BLOB NOT NULL,
@@ -53,17 +120,20 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
53
120
  transports TEXT NOT NULL,
54
121
  created_at TEXT NOT NULL,
55
122
  updated_at TEXT NOT NULL,
56
- last_used_at TEXT
123
+ last_used_at TEXT,
124
+ FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE
57
125
  );
58
126
  CREATE TABLE IF NOT EXISTS webapp_api_keys (
59
127
  id TEXT PRIMARY KEY,
128
+ user_id TEXT NOT NULL,
60
129
  name TEXT NOT NULL,
61
130
  prefix TEXT NOT NULL,
62
131
  token_hash TEXT NOT NULL UNIQUE,
63
132
  scopes TEXT NOT NULL,
64
133
  created_at TEXT NOT NULL,
65
134
  last_used_at TEXT,
66
- expires_at TEXT
135
+ expires_at TEXT,
136
+ FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE
67
137
  );
68
138
  CREATE TABLE IF NOT EXISTS webapp_device_auth_requests (
69
139
  device_code_hash TEXT PRIMARY KEY,
@@ -71,9 +141,11 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
71
141
  client_id TEXT NOT NULL,
72
142
  scope TEXT NOT NULL,
73
143
  status TEXT NOT NULL,
144
+ approved_by_user_id TEXT,
74
145
  created_at TEXT NOT NULL,
75
146
  updated_at TEXT NOT NULL,
76
- expires_at TEXT NOT NULL
147
+ expires_at TEXT NOT NULL,
148
+ FOREIGN KEY (approved_by_user_id) REFERENCES webapp_users(id) ON DELETE SET NULL
77
149
  );
78
150
  CREATE TABLE IF NOT EXISTS webapp_signing_keys (
79
151
  kid TEXT PRIMARY KEY,
@@ -84,6 +156,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
84
156
  );
85
157
  CREATE TABLE IF NOT EXISTS webapp_refresh_sessions (
86
158
  id TEXT PRIMARY KEY,
159
+ user_id TEXT NOT NULL,
87
160
  family_id TEXT NOT NULL,
88
161
  client_id TEXT NOT NULL,
89
162
  scope TEXT NOT NULL,
@@ -92,30 +165,184 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
92
165
  updated_at TEXT NOT NULL,
93
166
  expires_at TEXT NOT NULL,
94
167
  last_used_at TEXT,
95
- revoked_at TEXT
168
+ revoked_at TEXT,
169
+ FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE
96
170
  );
171
+ CREATE INDEX IF NOT EXISTS idx_webapp_users_username ON webapp_users(username);
172
+ CREATE INDEX IF NOT EXISTS idx_webapp_passkeys_user ON webapp_passkeys(user_id);
173
+ CREATE INDEX IF NOT EXISTS idx_webapp_api_keys_user ON webapp_api_keys(user_id);
174
+ CREATE INDEX IF NOT EXISTS idx_webapp_refresh_user ON webapp_refresh_sessions(user_id);
175
+ CREATE INDEX IF NOT EXISTS idx_webapp_audit_created ON webapp_audit_events(created_at);
97
176
  `);
98
177
  }
99
178
 
100
- function getPreference(key: string): string | undefined {
101
- const row = db.query("SELECT value FROM webapp_preferences WHERE key = ?").get(key) as Row | null;
179
+ function migrateSingleUserSchema(): void {
180
+ const legacySources = [
181
+ ["webapp_preferences", tableExists("webapp_preferences") && !hasColumn("webapp_preferences", "user_id")],
182
+ ["webapp_passkeys", tableExists("webapp_passkeys") && !hasColumn("webapp_passkeys", "user_id")],
183
+ ["webapp_api_keys", tableExists("webapp_api_keys") && !hasColumn("webapp_api_keys", "user_id")],
184
+ ["webapp_device_auth_requests", tableExists("webapp_device_auth_requests") && !hasColumn("webapp_device_auth_requests", "approved_by_user_id")],
185
+ ["webapp_refresh_sessions", tableExists("webapp_refresh_sessions") && !hasColumn("webapp_refresh_sessions", "user_id")],
186
+ ] as const;
187
+ const legacyTables = legacySources.filter(([, legacy]) => legacy).map(([table]) => table);
188
+ if (legacyTables.length === 0) {
189
+ return;
190
+ }
191
+
192
+ const legacyName = (table: string) => `${table}_legacy_single_user`;
193
+ const transaction = db.transaction(() => {
194
+ for (const table of legacyTables) {
195
+ db.exec(`DROP TABLE IF EXISTS ${legacyName(table)};`);
196
+ db.exec(`ALTER TABLE ${table} RENAME TO ${legacyName(table)};`);
197
+ }
198
+
199
+ createSchema();
200
+
201
+ const legacyPasskeys = legacyName("webapp_passkeys");
202
+ const legacyApiKeys = legacyName("webapp_api_keys");
203
+ const legacyDeviceRequests = legacyName("webapp_device_auth_requests");
204
+ const legacyRefreshSessions = legacyName("webapp_refresh_sessions");
205
+ const hasOwnerData =
206
+ countRows(legacyPasskeys) > 0 ||
207
+ countRows(legacyApiKeys) > 0 ||
208
+ countRows(legacyRefreshSessions) > 0 ||
209
+ countRows(legacyDeviceRequests, " WHERE status IN ('approved', 'consumed')") > 0;
210
+ const owner = hasOwnerData ? createUserRecord({ username: "owner", role: "owner" }) : undefined;
211
+ if (owner) {
212
+ db.query(`
213
+ INSERT INTO webapp_users (id, username, role, auth_version, created_at, updated_at, last_login_at, disabled_at)
214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
215
+ `).run(owner.id, owner.username, owner.role, owner.authVersion, owner.createdAt, owner.updatedAt, owner.lastLoginAt ?? null, owner.disabledAt ?? null);
216
+ }
217
+
218
+ if (tableExists(legacyName("webapp_preferences"))) {
219
+ db.exec(`
220
+ INSERT INTO webapp_preferences (key, user_id, value, updated_at)
221
+ SELECT key, '', value, updated_at FROM ${legacyName("webapp_preferences")}
222
+ `);
223
+ if (owner) {
224
+ db.query(`
225
+ INSERT OR IGNORE INTO webapp_preferences (key, user_id, value, updated_at)
226
+ SELECT key, ?, value, updated_at FROM ${legacyName("webapp_preferences")} WHERE key = 'theme'
227
+ `).run(owner.id);
228
+ }
229
+ }
230
+
231
+ if (owner && tableExists(legacyPasskeys)) {
232
+ db.query(`
233
+ INSERT INTO webapp_passkeys
234
+ (id, user_id, name, credential_id, public_key, counter, device_type, backed_up, transports, created_at, updated_at, last_used_at)
235
+ SELECT id, ?, name, credential_id, public_key, counter, device_type, backed_up, transports, created_at, updated_at, last_used_at
236
+ FROM ${legacyPasskeys}
237
+ ORDER BY created_at ASC
238
+ LIMIT 1
239
+ `).run(owner.id);
240
+ }
241
+
242
+ if (owner && tableExists(legacyApiKeys)) {
243
+ db.query(`
244
+ INSERT INTO webapp_api_keys (id, user_id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at)
245
+ SELECT id, ?, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at FROM ${legacyApiKeys}
246
+ `).run(owner.id);
247
+ }
248
+
249
+ if (tableExists(legacyDeviceRequests)) {
250
+ db.query(`
251
+ INSERT INTO webapp_device_auth_requests
252
+ (device_code_hash, user_code, client_id, scope, status, approved_by_user_id, created_at, updated_at, expires_at)
253
+ SELECT device_code_hash, user_code, client_id, scope, status,
254
+ CASE WHEN status IN ('approved', 'consumed') THEN ? ELSE NULL END,
255
+ created_at, updated_at, expires_at
256
+ FROM ${legacyDeviceRequests}
257
+ `).run(owner?.id ?? null);
258
+ }
259
+
260
+ if (owner && tableExists(legacyRefreshSessions)) {
261
+ db.query(`
262
+ INSERT INTO webapp_refresh_sessions
263
+ (id, user_id, family_id, client_id, scope, refresh_token_hash, created_at, updated_at, expires_at, last_used_at, revoked_at)
264
+ SELECT id, ?, family_id, client_id, scope, refresh_token_hash, created_at, updated_at, expires_at, last_used_at, revoked_at
265
+ FROM ${legacyRefreshSessions}
266
+ `).run(owner.id);
267
+ }
268
+
269
+ for (const table of legacyTables) {
270
+ db.exec(`DROP TABLE IF EXISTS ${legacyName(table)};`);
271
+ }
272
+ });
273
+
274
+ db.exec("PRAGMA foreign_keys = OFF;");
275
+ transaction();
276
+ }
277
+
278
+ function initialize(): void {
279
+ migrateSingleUserSchema();
280
+ createSchema();
281
+ db.exec("PRAGMA foreign_keys = ON;");
282
+ }
283
+
284
+ function getPreference(key: string, userId?: string): string | undefined {
285
+ const row = db.query("SELECT value FROM webapp_preferences WHERE key = ? AND user_id = ?").get(key, userId ?? "") as Row | null;
102
286
  return optionalText(row?.["value"]);
103
287
  }
104
288
 
105
- function setPreference(key: string, value: string): void {
289
+ function setPreference(key: string, value: string, userId?: string): void {
106
290
  db.query(`
107
- INSERT INTO webapp_preferences (key, value, updated_at) VALUES (?, ?, ?)
108
- ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
109
- `).run(key, value, nowIso());
291
+ INSERT INTO webapp_preferences (key, user_id, value, updated_at) VALUES (?, ?, ?, ?)
292
+ ON CONFLICT(key, user_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
293
+ `).run(key, userId ?? "", value, nowIso());
110
294
  }
111
295
 
112
- function deletePreference(key: string): void {
113
- db.query("DELETE FROM webapp_preferences WHERE key = ?").run(key);
296
+ function deletePreference(key: string, userId?: string): void {
297
+ if (userId) {
298
+ db.query("DELETE FROM webapp_preferences WHERE key = ? AND user_id = ?").run(key, userId);
299
+ return;
300
+ }
301
+ db.query("DELETE FROM webapp_preferences WHERE key = ? AND user_id = ''").run(key);
302
+ }
303
+
304
+ function mapUser(row: Row): UserRecord {
305
+ return {
306
+ id: text(row["id"]),
307
+ username: text(row["username"]),
308
+ role: text(row["role"]) as WebAppUserRole,
309
+ authVersion: Number(row["auth_version"] ?? 0),
310
+ passkeyConfigured: Number(row["passkey_configured"] ?? 0) > 0,
311
+ createdAt: text(row["created_at"]),
312
+ updatedAt: text(row["updated_at"]),
313
+ lastLoginAt: optionalText(row["last_login_at"]),
314
+ disabledAt: optionalText(row["disabled_at"]),
315
+ };
316
+ }
317
+
318
+ function mapSetupLink(row: Row): UserSetupLinkRecord {
319
+ return {
320
+ id: text(row["id"]),
321
+ userId: text(row["user_id"]),
322
+ tokenHash: text(row["token_hash"]),
323
+ kind: text(row["kind"]) as UserSetupLinkRecord["kind"],
324
+ createdByUserId: optionalText(row["created_by_user_id"]),
325
+ createdAt: text(row["created_at"]),
326
+ expiresAt: text(row["expires_at"]),
327
+ consumedAt: optionalText(row["consumed_at"]),
328
+ };
329
+ }
330
+
331
+ function mapAudit(row: Row): AuditEventRecord {
332
+ return {
333
+ id: text(row["id"]),
334
+ eventType: text(row["event_type"]),
335
+ actorUserId: optionalText(row["actor_user_id"]),
336
+ targetUserId: optionalText(row["target_user_id"]),
337
+ metadata: json<Record<string, unknown>>(row["metadata"], {}),
338
+ createdAt: text(row["created_at"]),
339
+ };
114
340
  }
115
341
 
116
342
  function mapPasskey(row: Row): StoredPasskey {
117
343
  return {
118
344
  id: text(row["id"]),
345
+ userId: text(row["user_id"]),
119
346
  name: text(row["name"]),
120
347
  credentialId: text(row["credential_id"]),
121
348
  publicKey: new Uint8Array(row["public_key"] as ArrayBuffer) as Uint8Array<ArrayBuffer>,
@@ -132,6 +359,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
132
359
  function mapApiKey(row: Row): ApiKeyRecord {
133
360
  return {
134
361
  id: text(row["id"]),
362
+ userId: text(row["user_id"]),
135
363
  name: text(row["name"]),
136
364
  prefix: text(row["prefix"]),
137
365
  tokenHash: text(row["token_hash"]),
@@ -149,6 +377,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
149
377
  clientId: text(row["client_id"]),
150
378
  scope: text(row["scope"]),
151
379
  status: text(row["status"]) as DeviceAuthRequestRecord["status"],
380
+ approvedByUserId: optionalText(row["approved_by_user_id"]),
152
381
  createdAt: text(row["created_at"]),
153
382
  updatedAt: text(row["updated_at"]),
154
383
  expiresAt: text(row["expires_at"]),
@@ -158,6 +387,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
158
387
  function mapRefresh(row: Row): RefreshSessionRecord {
159
388
  return {
160
389
  id: text(row["id"]),
390
+ userId: text(row["user_id"]),
161
391
  familyId: text(row["family_id"]),
162
392
  clientId: text(row["client_id"]),
163
393
  scope: text(row["scope"]),
@@ -170,16 +400,96 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
170
400
  };
171
401
  }
172
402
 
403
+ function userSelect(): string {
404
+ return `
405
+ SELECT u.*,
406
+ CASE WHEN p.id IS NULL THEN 0 ELSE 1 END AS passkey_configured
407
+ FROM webapp_users u
408
+ LEFT JOIN webapp_passkeys p ON p.user_id = u.id
409
+ `;
410
+ }
411
+
173
412
  return {
174
413
  initialize,
175
414
  getPreference,
176
415
  setPreference,
177
416
  deletePreference,
178
- getThemePreference: () => getPreference("theme") as ThemePreference | undefined,
179
- setThemePreference: (value) => setPreference("theme", value),
417
+ getThemePreference: (userId) => getPreference("theme", userId) as ThemePreference | undefined,
418
+ setThemePreference: (value, userId) => setPreference("theme", value, userId),
180
419
  getLogLevelPreference: () => getPreference("logLevel") as LogLevelName | undefined,
181
420
  setLogLevelPreference: (value) => setPreference("logLevel", value),
182
- listPasskeys: () => (db.query("SELECT * FROM webapp_passkeys ORDER BY created_at ASC").all() as Row[]).map(mapPasskey),
421
+
422
+ countUsers: () => Number((db.query("SELECT COUNT(*) AS count FROM webapp_users").get() as Row | null)?.["count"] ?? 0),
423
+ listUsers: () => (db.query(`${userSelect()} ORDER BY u.created_at ASC`).all() as Row[]).map(mapUser),
424
+ createUser: (user) => {
425
+ db.query(`
426
+ INSERT INTO webapp_users (id, username, role, auth_version, created_at, updated_at, last_login_at, disabled_at)
427
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
428
+ `).run(user.id, user.username, user.role, user.authVersion, user.createdAt, user.updatedAt, user.lastLoginAt ?? null, user.disabledAt ?? null);
429
+ },
430
+ getUserById: (id) => {
431
+ const row = db.query(`${userSelect()} WHERE u.id = ?`).get(id) as Row | null;
432
+ return row ? mapUser(row) : undefined;
433
+ },
434
+ getUserByUsername: (username) => {
435
+ const row = db.query(`${userSelect()} WHERE lower(u.username) = lower(?)`).get(username) as Row | null;
436
+ return row ? mapUser(row) : undefined;
437
+ },
438
+ getOwnerUser: () => {
439
+ const row = db.query(`${userSelect()} WHERE u.role = 'owner' ORDER BY u.created_at ASC LIMIT 1`).get() as Row | null;
440
+ return row ? mapUser(row) : undefined;
441
+ },
442
+ setUserRole: (id, role, updatedAt) => {
443
+ const result = db.query("UPDATE webapp_users SET role = ?, updated_at = ? WHERE id = ?").run(role, updatedAt, id);
444
+ return result.changes > 0;
445
+ },
446
+ markUserLogin: (id, lastLoginAt) => {
447
+ db.query("UPDATE webapp_users SET last_login_at = ?, updated_at = ? WHERE id = ?").run(lastLoginAt, lastLoginAt, id);
448
+ },
449
+ incrementUserAuthVersion: (id, updatedAt) => {
450
+ db.query("UPDATE webapp_users SET auth_version = auth_version + 1, updated_at = ? WHERE id = ?").run(updatedAt, id);
451
+ },
452
+ deleteUser: (id) => {
453
+ const result = db.query("DELETE FROM webapp_users WHERE id = ? AND role != 'owner'").run(id);
454
+ return result.changes > 0;
455
+ },
456
+
457
+ createSetupLink: (record) => {
458
+ db.query(`
459
+ INSERT INTO webapp_user_setup_links
460
+ (id, user_id, token_hash, kind, created_by_user_id, created_at, expires_at, consumed_at)
461
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
462
+ `).run(record.id, record.userId, record.tokenHash, record.kind, record.createdByUserId ?? null, record.createdAt, record.expiresAt, record.consumedAt ?? null);
463
+ },
464
+ getSetupLinkByTokenHash: (tokenHash) => {
465
+ const row = db.query("SELECT * FROM webapp_user_setup_links WHERE token_hash = ?").get(tokenHash) as Row | null;
466
+ return row ? mapSetupLink(row) : undefined;
467
+ },
468
+ consumeSetupLink: (id, consumedAt) => {
469
+ db.query("UPDATE webapp_user_setup_links SET consumed_at = ? WHERE id = ?").run(consumedAt, id);
470
+ },
471
+ deletePendingSetupLinksForUser: (userId, now) => {
472
+ db.query("UPDATE webapp_user_setup_links SET consumed_at = ? WHERE user_id = ? AND consumed_at IS NULL").run(now, userId);
473
+ },
474
+
475
+ saveAuditEvent: (record) => {
476
+ db.query(`
477
+ INSERT INTO webapp_audit_events (id, event_type, actor_user_id, target_user_id, metadata, created_at)
478
+ VALUES (?, ?, ?, ?, ?, ?)
479
+ `).run(record.id, record.eventType, record.actorUserId ?? null, record.targetUserId ?? null, JSON.stringify(record.metadata), record.createdAt);
480
+ },
481
+ listAuditEvents: (limit = 100) => (db.query("SELECT * FROM webapp_audit_events ORDER BY created_at DESC LIMIT ?").all(limit) as Row[]).map(mapAudit),
482
+
483
+ listPasskeys: (userId) => {
484
+ const rows = userId
485
+ ? (db.query("SELECT * FROM webapp_passkeys WHERE user_id = ? ORDER BY created_at ASC").all(userId) as Row[])
486
+ : (db.query("SELECT * FROM webapp_passkeys ORDER BY created_at ASC").all() as Row[]);
487
+ return rows.map(mapPasskey);
488
+ },
489
+ getPasskeyByUserId: (userId) => {
490
+ const row = db.query("SELECT * FROM webapp_passkeys WHERE user_id = ?").get(userId) as Row | null;
491
+ return row ? mapPasskey(row) : undefined;
492
+ },
183
493
  getPasskeyByCredentialId: (credentialId) => {
184
494
  const row = db.query("SELECT * FROM webapp_passkeys WHERE credential_id = ?").get(credentialId) as Row | null;
185
495
  return row ? mapPasskey(row) : undefined;
@@ -187,10 +497,22 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
187
497
  savePasskey: (passkey) => {
188
498
  db.query(`
189
499
  INSERT INTO webapp_passkeys
190
- (id, name, credential_id, public_key, counter, device_type, backed_up, transports, created_at, updated_at, last_used_at)
191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
500
+ (id, user_id, name, credential_id, public_key, counter, device_type, backed_up, transports, created_at, updated_at, last_used_at)
501
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
502
+ ON CONFLICT(user_id) DO UPDATE SET
503
+ id = excluded.id,
504
+ name = excluded.name,
505
+ credential_id = excluded.credential_id,
506
+ public_key = excluded.public_key,
507
+ counter = excluded.counter,
508
+ device_type = excluded.device_type,
509
+ backed_up = excluded.backed_up,
510
+ transports = excluded.transports,
511
+ updated_at = excluded.updated_at,
512
+ last_used_at = excluded.last_used_at
192
513
  `).run(
193
514
  passkey.id,
515
+ passkey.userId,
194
516
  passkey.name,
195
517
  passkey.credentialId,
196
518
  passkey.publicKey,
@@ -207,32 +529,45 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
207
529
  db.query("UPDATE webapp_passkeys SET counter = ?, last_used_at = ?, updated_at = ? WHERE credential_id = ?")
208
530
  .run(counter, lastUsedAt, lastUsedAt, credentialId);
209
531
  },
210
- deleteAllPasskeys: () => {
211
- db.query("DELETE FROM webapp_passkeys").run();
532
+ deletePasskeysForUser: (userId) => {
533
+ db.query("DELETE FROM webapp_passkeys WHERE user_id = ?").run(userId);
534
+ },
535
+
536
+ listApiKeys: (userId) => {
537
+ const rows = userId
538
+ ? (db.query("SELECT * FROM webapp_api_keys WHERE user_id = ? ORDER BY created_at DESC").all(userId) as Row[])
539
+ : (db.query("SELECT * FROM webapp_api_keys ORDER BY created_at DESC").all() as Row[]);
540
+ return rows.map(mapApiKey);
212
541
  },
213
- listApiKeys: () => (db.query("SELECT * FROM webapp_api_keys ORDER BY created_at DESC").all() as Row[]).map(mapApiKey),
214
542
  getApiKeyByHash: (tokenHash) => {
215
543
  const row = db.query("SELECT * FROM webapp_api_keys WHERE token_hash = ?").get(tokenHash) as Row | null;
216
544
  return row ? mapApiKey(row) : undefined;
217
545
  },
218
546
  saveApiKey: (record) => {
219
547
  db.query(`
220
- INSERT INTO webapp_api_keys (id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at)
221
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
222
- `).run(record.id, record.name, record.prefix, record.tokenHash, JSON.stringify(record.scopes), record.createdAt, record.lastUsedAt ?? null, record.expiresAt ?? null);
548
+ INSERT INTO webapp_api_keys (id, user_id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at)
549
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
550
+ `).run(record.id, record.userId, record.name, record.prefix, record.tokenHash, JSON.stringify(record.scopes), record.createdAt, record.lastUsedAt ?? null, record.expiresAt ?? null);
223
551
  },
224
552
  touchApiKey: (id, lastUsedAt) => {
225
553
  db.query("UPDATE webapp_api_keys SET last_used_at = ? WHERE id = ?").run(lastUsedAt, id);
226
554
  },
227
- deleteApiKey: (id) => {
228
- const result = db.query("DELETE FROM webapp_api_keys WHERE id = ?").run(id);
555
+ deleteApiKey: (id, userId) => {
556
+ const result = userId
557
+ ? db.query("DELETE FROM webapp_api_keys WHERE id = ? AND user_id = ?").run(id, userId)
558
+ : db.query("DELETE FROM webapp_api_keys WHERE id = ?").run(id);
229
559
  return result.changes > 0;
230
560
  },
561
+ deleteApiKeysForUser: (userId) => {
562
+ db.query("DELETE FROM webapp_api_keys WHERE user_id = ?").run(userId);
563
+ },
564
+
231
565
  saveDeviceAuthRequest: (record) => {
232
566
  db.query(`
233
- INSERT INTO webapp_device_auth_requests (device_code_hash, user_code, client_id, scope, status, created_at, updated_at, expires_at)
234
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
235
- `).run(record.deviceCodeHash, record.userCode, record.clientId, record.scope, record.status, record.createdAt, record.updatedAt, record.expiresAt);
567
+ INSERT INTO webapp_device_auth_requests
568
+ (device_code_hash, user_code, client_id, scope, status, approved_by_user_id, created_at, updated_at, expires_at)
569
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
570
+ `).run(record.deviceCodeHash, record.userCode, record.clientId, record.scope, record.status, record.approvedByUserId ?? null, record.createdAt, record.updatedAt, record.expiresAt);
236
571
  },
237
572
  getDeviceAuthByUserCode: (userCode) => {
238
573
  const row = db.query("SELECT * FROM webapp_device_auth_requests WHERE user_code = ?").get(userCode) as Row | null;
@@ -242,12 +577,14 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
242
577
  const row = db.query("SELECT * FROM webapp_device_auth_requests WHERE device_code_hash = ?").get(deviceCodeHash) as Row | null;
243
578
  return row ? mapDevice(row) : undefined;
244
579
  },
245
- updateDeviceAuthStatus: (userCode, status, updatedAt) => {
246
- db.query("UPDATE webapp_device_auth_requests SET status = ?, updated_at = ? WHERE user_code = ?").run(status, updatedAt, userCode);
580
+ updateDeviceAuthStatus: (userCode, status, updatedAt, approvedByUserId) => {
581
+ db.query("UPDATE webapp_device_auth_requests SET status = ?, approved_by_user_id = COALESCE(?, approved_by_user_id), updated_at = ? WHERE user_code = ?")
582
+ .run(status, approvedByUserId ?? null, updatedAt, userCode);
247
583
  },
248
584
  deleteExpiredDeviceAuthRequests: (now) => {
249
585
  db.query("DELETE FROM webapp_device_auth_requests WHERE expires_at <= ?").run(now);
250
586
  },
587
+
251
588
  getSigningKey: () => {
252
589
  const row = db.query("SELECT * FROM webapp_signing_keys ORDER BY created_at DESC LIMIT 1").get() as Row | null;
253
590
  return row
@@ -264,17 +601,24 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
264
601
  db.query("INSERT INTO webapp_signing_keys (kid, alg, public_jwk, private_jwk, created_at) VALUES (?, ?, ?, ?, ?)")
265
602
  .run(record.kid, record.alg, JSON.stringify(record.publicJwk), JSON.stringify(record.privateJwk), record.createdAt);
266
603
  },
604
+
267
605
  saveRefreshSession: (record) => {
268
606
  db.query(`
269
- INSERT INTO webapp_refresh_sessions (id, family_id, client_id, scope, refresh_token_hash, created_at, updated_at, expires_at, last_used_at, revoked_at)
270
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
271
- `).run(record.id, record.familyId, record.clientId, record.scope, record.refreshTokenHash, record.createdAt, record.updatedAt, record.expiresAt, record.lastUsedAt ?? null, record.revokedAt ?? null);
607
+ INSERT INTO webapp_refresh_sessions
608
+ (id, user_id, family_id, client_id, scope, refresh_token_hash, created_at, updated_at, expires_at, last_used_at, revoked_at)
609
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610
+ `).run(record.id, record.userId, record.familyId, record.clientId, record.scope, record.refreshTokenHash, record.createdAt, record.updatedAt, record.expiresAt, record.lastUsedAt ?? null, record.revokedAt ?? null);
272
611
  },
273
612
  getRefreshSessionByHash: (refreshTokenHash) => {
274
613
  const row = db.query("SELECT * FROM webapp_refresh_sessions WHERE refresh_token_hash = ?").get(refreshTokenHash) as Row | null;
275
614
  return row ? mapRefresh(row) : undefined;
276
615
  },
277
- listRefreshSessions: () => (db.query("SELECT * FROM webapp_refresh_sessions ORDER BY created_at DESC").all() as Row[]).map(mapRefresh),
616
+ listRefreshSessions: (userId) => {
617
+ const rows = userId
618
+ ? (db.query("SELECT * FROM webapp_refresh_sessions WHERE user_id = ? ORDER BY created_at DESC").all(userId) as Row[])
619
+ : (db.query("SELECT * FROM webapp_refresh_sessions ORDER BY created_at DESC").all() as Row[]);
620
+ return rows.map(mapRefresh);
621
+ },
278
622
  rotateRefreshSession: (oldHash, next, now) => {
279
623
  const transaction = db.transaction(() => {
280
624
  const old = db.query("SELECT * FROM webapp_refresh_sessions WHERE refresh_token_hash = ?").get(oldHash) as Row | null;
@@ -283,19 +627,25 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
283
627
  }
284
628
  db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ?").run(now, now, oldHash);
285
629
  db.query(`
286
- INSERT INTO webapp_refresh_sessions (id, family_id, client_id, scope, refresh_token_hash, created_at, updated_at, expires_at, last_used_at, revoked_at)
287
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
288
- `).run(next.id, next.familyId, next.clientId, next.scope, next.refreshTokenHash, next.createdAt, next.updatedAt, next.expiresAt, next.lastUsedAt ?? null, next.revokedAt ?? null);
630
+ INSERT INTO webapp_refresh_sessions
631
+ (id, user_id, family_id, client_id, scope, refresh_token_hash, created_at, updated_at, expires_at, last_used_at, revoked_at)
632
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
633
+ `).run(next.id, next.userId, next.familyId, next.clientId, next.scope, next.refreshTokenHash, next.createdAt, next.updatedAt, next.expiresAt, next.lastUsedAt ?? null, next.revokedAt ?? null);
289
634
  return mapRefresh(old);
290
635
  });
291
636
  return transaction();
292
637
  },
293
- revokeRefreshSession: (id, revokedAt) => {
294
- const result = db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, id);
638
+ revokeRefreshSession: (id, revokedAt, userId) => {
639
+ const result = userId
640
+ ? db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, id, userId)
641
+ : db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, id);
295
642
  return result.changes > 0;
296
643
  },
297
644
  revokeRefreshFamily: (familyId, revokedAt) => {
298
645
  db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE family_id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, familyId);
299
646
  },
647
+ revokeRefreshSessionsForUser: (userId, revokedAt) => {
648
+ db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, userId);
649
+ },
300
650
  };
301
651
  }