@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.
- package/README.md +3 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/getting-started.md +33 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +39 -4
- package/docs/settings.md +11 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +5 -1
- package/package.json +1 -1
- package/src/contracts/index.ts +53 -0
- package/src/server/auth/api-keys.ts +13 -8
- package/src/server/auth/device-auth.ts +34 -11
- package/src/server/auth/passkeys.ts +191 -73
- package/src/server/auth/sqlite-store.ts +394 -44
- package/src/server/auth/store.ts +57 -13
- package/src/server/auth/types.ts +7 -3
- package/src/server/auth/users.ts +83 -0
- package/src/server/create-web-app-server.ts +327 -29
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/routes.ts +23 -2
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +233 -27
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +1 -0
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +65 -2
|
@@ -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 {
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
|
101
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
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 = ?")
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
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: () =>
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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 =
|
|
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
|
}
|