@pablozaiden/webapp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/docs/auth-validation.md +34 -0
  4. package/docs/auth.md +35 -0
  5. package/docs/deployment.md +49 -0
  6. package/docs/getting-started.md +62 -0
  7. package/docs/github-actions.md +421 -0
  8. package/docs/realtime.md +61 -0
  9. package/docs/release.md +64 -0
  10. package/docs/server.md +45 -0
  11. package/docs/settings.md +42 -0
  12. package/docs/sidebar.md +80 -0
  13. package/docs/ui-guidelines.md +55 -0
  14. package/package.json +60 -0
  15. package/src/build/build-binary.ts +42 -0
  16. package/src/build/index.ts +1 -0
  17. package/src/contracts/index.ts +86 -0
  18. package/src/server/auth/api-keys.ts +61 -0
  19. package/src/server/auth/crypto.ts +34 -0
  20. package/src/server/auth/device-auth.ts +324 -0
  21. package/src/server/auth/passkeys.ts +280 -0
  22. package/src/server/auth/request-origin.ts +33 -0
  23. package/src/server/auth/sqlite-store.ts +301 -0
  24. package/src/server/auth/store.ts +91 -0
  25. package/src/server/auth/types.ts +25 -0
  26. package/src/server/create-web-app-server.ts +447 -0
  27. package/src/server/index.ts +7 -0
  28. package/src/server/logger.ts +44 -0
  29. package/src/server/realtime/bus.ts +104 -0
  30. package/src/server/responses.ts +54 -0
  31. package/src/server/routes.ts +67 -0
  32. package/src/server/runtime-config.ts +101 -0
  33. package/src/server/same-origin.ts +35 -0
  34. package/src/types.d.ts +11 -0
  35. package/src/web/WebAppRoot.tsx +706 -0
  36. package/src/web/components/index.tsx +471 -0
  37. package/src/web/index.ts +5 -0
  38. package/src/web/realtime/useRealtime.ts +189 -0
  39. package/src/web/sidebar/types.ts +45 -0
  40. package/src/web/styles.css +1295 -0
@@ -0,0 +1,301 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import type { ApiKeyRecord, DeviceAuthRequestRecord, RefreshSessionRecord, SigningKeyRecord, StoredPasskey, WebAppStore } from "./store";
5
+ import type { LogLevelName, ThemePreference } from "../../contracts";
6
+
7
+ type Row = Record<string, unknown>;
8
+
9
+ function nowIso(): string {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function text(value: unknown): string {
14
+ return typeof value === "string" ? value : "";
15
+ }
16
+
17
+ function optionalText(value: unknown): string | undefined {
18
+ return typeof value === "string" && value.length > 0 ? value : undefined;
19
+ }
20
+
21
+ function json<T>(value: unknown, fallback: T): T {
22
+ if (typeof value !== "string") {
23
+ return fallback;
24
+ }
25
+ try {
26
+ return JSON.parse(value) as T;
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string } = {}): WebAppStore {
33
+ const dataDir = options.dataDir ?? "./data";
34
+ const dbPath = join(dataDir, options.fileName ?? "webapp.sqlite");
35
+ mkdirSync(dirname(dbPath), { recursive: true });
36
+ const db = new Database(dbPath);
37
+
38
+ function initialize(): void {
39
+ db.exec(`
40
+ CREATE TABLE IF NOT EXISTS webapp_preferences (
41
+ key TEXT PRIMARY KEY,
42
+ value TEXT NOT NULL,
43
+ updated_at TEXT NOT NULL
44
+ );
45
+ CREATE TABLE IF NOT EXISTS webapp_passkeys (
46
+ id TEXT PRIMARY KEY,
47
+ name TEXT NOT NULL,
48
+ credential_id TEXT NOT NULL UNIQUE,
49
+ public_key BLOB NOT NULL,
50
+ counter INTEGER NOT NULL,
51
+ device_type TEXT NOT NULL,
52
+ backed_up INTEGER NOT NULL,
53
+ transports TEXT NOT NULL,
54
+ created_at TEXT NOT NULL,
55
+ updated_at TEXT NOT NULL,
56
+ last_used_at TEXT
57
+ );
58
+ CREATE TABLE IF NOT EXISTS webapp_api_keys (
59
+ id TEXT PRIMARY KEY,
60
+ name TEXT NOT NULL,
61
+ prefix TEXT NOT NULL,
62
+ token_hash TEXT NOT NULL UNIQUE,
63
+ scopes TEXT NOT NULL,
64
+ created_at TEXT NOT NULL,
65
+ last_used_at TEXT,
66
+ expires_at TEXT
67
+ );
68
+ CREATE TABLE IF NOT EXISTS webapp_device_auth_requests (
69
+ device_code_hash TEXT PRIMARY KEY,
70
+ user_code TEXT NOT NULL UNIQUE,
71
+ client_id TEXT NOT NULL,
72
+ scope TEXT NOT NULL,
73
+ status TEXT NOT NULL,
74
+ created_at TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL,
76
+ expires_at TEXT NOT NULL
77
+ );
78
+ CREATE TABLE IF NOT EXISTS webapp_signing_keys (
79
+ kid TEXT PRIMARY KEY,
80
+ alg TEXT NOT NULL,
81
+ public_jwk TEXT NOT NULL,
82
+ private_jwk TEXT NOT NULL,
83
+ created_at TEXT NOT NULL
84
+ );
85
+ CREATE TABLE IF NOT EXISTS webapp_refresh_sessions (
86
+ id TEXT PRIMARY KEY,
87
+ family_id TEXT NOT NULL,
88
+ client_id TEXT NOT NULL,
89
+ scope TEXT NOT NULL,
90
+ refresh_token_hash TEXT NOT NULL UNIQUE,
91
+ created_at TEXT NOT NULL,
92
+ updated_at TEXT NOT NULL,
93
+ expires_at TEXT NOT NULL,
94
+ last_used_at TEXT,
95
+ revoked_at TEXT
96
+ );
97
+ `);
98
+ }
99
+
100
+ function getPreference(key: string): string | undefined {
101
+ const row = db.query("SELECT value FROM webapp_preferences WHERE key = ?").get(key) as Row | null;
102
+ return optionalText(row?.["value"]);
103
+ }
104
+
105
+ function setPreference(key: string, value: string): void {
106
+ 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());
110
+ }
111
+
112
+ function deletePreference(key: string): void {
113
+ db.query("DELETE FROM webapp_preferences WHERE key = ?").run(key);
114
+ }
115
+
116
+ function mapPasskey(row: Row): StoredPasskey {
117
+ return {
118
+ id: text(row["id"]),
119
+ name: text(row["name"]),
120
+ credentialId: text(row["credential_id"]),
121
+ publicKey: new Uint8Array(row["public_key"] as ArrayBuffer) as Uint8Array<ArrayBuffer>,
122
+ counter: Number(row["counter"] ?? 0),
123
+ deviceType: text(row["device_type"]),
124
+ backedUp: Number(row["backed_up"] ?? 0) === 1,
125
+ transports: json<string[]>(row["transports"], []),
126
+ createdAt: text(row["created_at"]),
127
+ updatedAt: text(row["updated_at"]),
128
+ lastUsedAt: optionalText(row["last_used_at"]),
129
+ };
130
+ }
131
+
132
+ function mapApiKey(row: Row): ApiKeyRecord {
133
+ return {
134
+ id: text(row["id"]),
135
+ name: text(row["name"]),
136
+ prefix: text(row["prefix"]),
137
+ tokenHash: text(row["token_hash"]),
138
+ scopes: json<string[]>(row["scopes"], []),
139
+ createdAt: text(row["created_at"]),
140
+ lastUsedAt: optionalText(row["last_used_at"]),
141
+ expiresAt: optionalText(row["expires_at"]),
142
+ };
143
+ }
144
+
145
+ function mapDevice(row: Row): DeviceAuthRequestRecord {
146
+ return {
147
+ deviceCodeHash: text(row["device_code_hash"]),
148
+ userCode: text(row["user_code"]),
149
+ clientId: text(row["client_id"]),
150
+ scope: text(row["scope"]),
151
+ status: text(row["status"]) as DeviceAuthRequestRecord["status"],
152
+ createdAt: text(row["created_at"]),
153
+ updatedAt: text(row["updated_at"]),
154
+ expiresAt: text(row["expires_at"]),
155
+ };
156
+ }
157
+
158
+ function mapRefresh(row: Row): RefreshSessionRecord {
159
+ return {
160
+ id: text(row["id"]),
161
+ familyId: text(row["family_id"]),
162
+ clientId: text(row["client_id"]),
163
+ scope: text(row["scope"]),
164
+ refreshTokenHash: text(row["refresh_token_hash"]),
165
+ createdAt: text(row["created_at"]),
166
+ updatedAt: text(row["updated_at"]),
167
+ expiresAt: text(row["expires_at"]),
168
+ lastUsedAt: optionalText(row["last_used_at"]),
169
+ revokedAt: optionalText(row["revoked_at"]),
170
+ };
171
+ }
172
+
173
+ return {
174
+ initialize,
175
+ getPreference,
176
+ setPreference,
177
+ deletePreference,
178
+ getThemePreference: () => getPreference("theme") as ThemePreference | undefined,
179
+ setThemePreference: (value) => setPreference("theme", value),
180
+ getLogLevelPreference: () => getPreference("logLevel") as LogLevelName | undefined,
181
+ setLogLevelPreference: (value) => setPreference("logLevel", value),
182
+ listPasskeys: () => (db.query("SELECT * FROM webapp_passkeys ORDER BY created_at ASC").all() as Row[]).map(mapPasskey),
183
+ getPasskeyByCredentialId: (credentialId) => {
184
+ const row = db.query("SELECT * FROM webapp_passkeys WHERE credential_id = ?").get(credentialId) as Row | null;
185
+ return row ? mapPasskey(row) : undefined;
186
+ },
187
+ savePasskey: (passkey) => {
188
+ db.query(`
189
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
192
+ `).run(
193
+ passkey.id,
194
+ passkey.name,
195
+ passkey.credentialId,
196
+ passkey.publicKey,
197
+ passkey.counter,
198
+ passkey.deviceType,
199
+ passkey.backedUp ? 1 : 0,
200
+ JSON.stringify(passkey.transports),
201
+ passkey.createdAt,
202
+ passkey.updatedAt,
203
+ passkey.lastUsedAt ?? null,
204
+ );
205
+ },
206
+ updatePasskeyUsage: (credentialId, counter, lastUsedAt) => {
207
+ db.query("UPDATE webapp_passkeys SET counter = ?, last_used_at = ?, updated_at = ? WHERE credential_id = ?")
208
+ .run(counter, lastUsedAt, lastUsedAt, credentialId);
209
+ },
210
+ deleteAllPasskeys: () => {
211
+ db.query("DELETE FROM webapp_passkeys").run();
212
+ },
213
+ listApiKeys: () => (db.query("SELECT * FROM webapp_api_keys ORDER BY created_at DESC").all() as Row[]).map(mapApiKey),
214
+ getApiKeyByHash: (tokenHash) => {
215
+ const row = db.query("SELECT * FROM webapp_api_keys WHERE token_hash = ?").get(tokenHash) as Row | null;
216
+ return row ? mapApiKey(row) : undefined;
217
+ },
218
+ saveApiKey: (record) => {
219
+ 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);
223
+ },
224
+ touchApiKey: (id, lastUsedAt) => {
225
+ db.query("UPDATE webapp_api_keys SET last_used_at = ? WHERE id = ?").run(lastUsedAt, id);
226
+ },
227
+ deleteApiKey: (id) => {
228
+ const result = db.query("DELETE FROM webapp_api_keys WHERE id = ?").run(id);
229
+ return result.changes > 0;
230
+ },
231
+ saveDeviceAuthRequest: (record) => {
232
+ 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);
236
+ },
237
+ getDeviceAuthByUserCode: (userCode) => {
238
+ const row = db.query("SELECT * FROM webapp_device_auth_requests WHERE user_code = ?").get(userCode) as Row | null;
239
+ return row ? mapDevice(row) : undefined;
240
+ },
241
+ getDeviceAuthByDeviceCodeHash: (deviceCodeHash) => {
242
+ const row = db.query("SELECT * FROM webapp_device_auth_requests WHERE device_code_hash = ?").get(deviceCodeHash) as Row | null;
243
+ return row ? mapDevice(row) : undefined;
244
+ },
245
+ updateDeviceAuthStatus: (userCode, status, updatedAt) => {
246
+ db.query("UPDATE webapp_device_auth_requests SET status = ?, updated_at = ? WHERE user_code = ?").run(status, updatedAt, userCode);
247
+ },
248
+ deleteExpiredDeviceAuthRequests: (now) => {
249
+ db.query("DELETE FROM webapp_device_auth_requests WHERE expires_at <= ?").run(now);
250
+ },
251
+ getSigningKey: () => {
252
+ const row = db.query("SELECT * FROM webapp_signing_keys ORDER BY created_at DESC LIMIT 1").get() as Row | null;
253
+ return row
254
+ ? {
255
+ alg: text(row["alg"]),
256
+ kid: text(row["kid"]),
257
+ publicJwk: json<Record<string, unknown>>(row["public_jwk"], {}),
258
+ privateJwk: json<Record<string, unknown>>(row["private_jwk"], {}),
259
+ createdAt: text(row["created_at"]),
260
+ }
261
+ : undefined;
262
+ },
263
+ saveSigningKey: (record) => {
264
+ db.query("INSERT INTO webapp_signing_keys (kid, alg, public_jwk, private_jwk, created_at) VALUES (?, ?, ?, ?, ?)")
265
+ .run(record.kid, record.alg, JSON.stringify(record.publicJwk), JSON.stringify(record.privateJwk), record.createdAt);
266
+ },
267
+ saveRefreshSession: (record) => {
268
+ 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);
272
+ },
273
+ getRefreshSessionByHash: (refreshTokenHash) => {
274
+ const row = db.query("SELECT * FROM webapp_refresh_sessions WHERE refresh_token_hash = ?").get(refreshTokenHash) as Row | null;
275
+ return row ? mapRefresh(row) : undefined;
276
+ },
277
+ listRefreshSessions: () => (db.query("SELECT * FROM webapp_refresh_sessions ORDER BY created_at DESC").all() as Row[]).map(mapRefresh),
278
+ rotateRefreshSession: (oldHash, next, now) => {
279
+ const transaction = db.transaction(() => {
280
+ const old = db.query("SELECT * FROM webapp_refresh_sessions WHERE refresh_token_hash = ?").get(oldHash) as Row | null;
281
+ if (!old) {
282
+ return undefined;
283
+ }
284
+ db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ?").run(now, now, oldHash);
285
+ 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);
289
+ return mapRefresh(old);
290
+ });
291
+ return transaction();
292
+ },
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);
295
+ return result.changes > 0;
296
+ },
297
+ revokeRefreshFamily: (familyId, revokedAt) => {
298
+ db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE family_id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, familyId);
299
+ },
300
+ };
301
+ }
@@ -0,0 +1,91 @@
1
+ import type { ApiKeySummary, LogLevelName, ThemePreference } from "../../contracts";
2
+
3
+ export interface StoredPasskey {
4
+ id: string;
5
+ name: string;
6
+ credentialId: string;
7
+ publicKey: Uint8Array<ArrayBuffer>;
8
+ counter: number;
9
+ deviceType: string;
10
+ backedUp: boolean;
11
+ transports: string[];
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ lastUsedAt?: string;
15
+ }
16
+
17
+ export interface ApiKeyRecord extends ApiKeySummary {
18
+ tokenHash: string;
19
+ }
20
+
21
+ export interface DeviceAuthRequestRecord {
22
+ deviceCodeHash: string;
23
+ userCode: string;
24
+ clientId: string;
25
+ scope: string;
26
+ status: "pending" | "approved" | "denied" | "consumed";
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ expiresAt: string;
30
+ }
31
+
32
+ export interface RefreshSessionRecord {
33
+ id: string;
34
+ familyId: string;
35
+ clientId: string;
36
+ scope: string;
37
+ refreshTokenHash: string;
38
+ createdAt: string;
39
+ updatedAt: string;
40
+ expiresAt: string;
41
+ lastUsedAt?: string;
42
+ revokedAt?: string;
43
+ }
44
+
45
+ export interface SigningKeyRecord {
46
+ alg: string;
47
+ kid: string;
48
+ publicJwk: Record<string, unknown>;
49
+ privateJwk: Record<string, unknown>;
50
+ createdAt: string;
51
+ }
52
+
53
+ export interface WebAppStore {
54
+ initialize(): void;
55
+ getPreference(key: string): string | undefined;
56
+ setPreference(key: string, value: string): void;
57
+ deletePreference(key: string): void;
58
+
59
+ getThemePreference(): ThemePreference | undefined;
60
+ setThemePreference(value: ThemePreference): void;
61
+ getLogLevelPreference(): LogLevelName | undefined;
62
+ setLogLevelPreference(value: LogLevelName): void;
63
+
64
+ listPasskeys(): StoredPasskey[];
65
+ getPasskeyByCredentialId(credentialId: string): StoredPasskey | undefined;
66
+ savePasskey(passkey: StoredPasskey): void;
67
+ updatePasskeyUsage(credentialId: string, counter: number, lastUsedAt: string): void;
68
+ deleteAllPasskeys(): void;
69
+
70
+ listApiKeys(): ApiKeyRecord[];
71
+ getApiKeyByHash(tokenHash: string): ApiKeyRecord | undefined;
72
+ saveApiKey(record: ApiKeyRecord): void;
73
+ touchApiKey(id: string, lastUsedAt: string): void;
74
+ deleteApiKey(id: string): boolean;
75
+
76
+ saveDeviceAuthRequest(record: DeviceAuthRequestRecord): void;
77
+ getDeviceAuthByUserCode(userCode: string): DeviceAuthRequestRecord | undefined;
78
+ getDeviceAuthByDeviceCodeHash(deviceCodeHash: string): DeviceAuthRequestRecord | undefined;
79
+ updateDeviceAuthStatus(userCode: string, status: DeviceAuthRequestRecord["status"], updatedAt: string): void;
80
+ deleteExpiredDeviceAuthRequests(nowIso: string): void;
81
+
82
+ getSigningKey(): SigningKeyRecord | undefined;
83
+ saveSigningKey(record: SigningKeyRecord): void;
84
+
85
+ saveRefreshSession(record: RefreshSessionRecord): void;
86
+ getRefreshSessionByHash(refreshTokenHash: string): RefreshSessionRecord | undefined;
87
+ listRefreshSessions(): RefreshSessionRecord[];
88
+ rotateRefreshSession(oldHash: string, next: RefreshSessionRecord, nowIso: string): RefreshSessionRecord | undefined;
89
+ revokeRefreshSession(id: string, revokedAt: string): boolean;
90
+ revokeRefreshFamily(familyId: string, revokedAt: string): void;
91
+ }
@@ -0,0 +1,25 @@
1
+ export interface AccessTokenClaims {
2
+ sub: string;
3
+ jti: string;
4
+ sid: string;
5
+ clientId: string;
6
+ scope: string;
7
+ }
8
+
9
+ export type AuthenticatedRequestState =
10
+ | { kind: "anonymous" }
11
+ | { kind: "passkey"; passkeyId?: string }
12
+ | { kind: "api-key"; apiKeyId: string; scopes: string[] }
13
+ | { kind: "bearer"; claims: AccessTokenClaims };
14
+
15
+ export class AuthError extends Error {
16
+ code: string;
17
+ status: number;
18
+
19
+ constructor(code: string, message: string, status: number) {
20
+ super(message);
21
+ this.name = "AuthError";
22
+ this.code = code;
23
+ this.status = status;
24
+ }
25
+ }