@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
package/src/server/auth/store.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
-
import type { ApiKeySummary, LogLevelName, ThemePreference } from "../../contracts";
|
|
1
|
+
import type { ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
2
|
+
|
|
3
|
+
export interface UserRecord extends WebAppUserSummary {
|
|
4
|
+
authVersion: number;
|
|
5
|
+
disabledAt?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UserSetupLinkRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
tokenHash: string;
|
|
12
|
+
kind: "invite" | "reset" | "owner-reset";
|
|
13
|
+
createdByUserId?: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
expiresAt: string;
|
|
16
|
+
consumedAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuditEventRecord extends AuditEventSummary {}
|
|
2
20
|
|
|
3
21
|
export interface StoredPasskey {
|
|
4
22
|
id: string;
|
|
23
|
+
userId: string;
|
|
5
24
|
name: string;
|
|
6
25
|
credentialId: string;
|
|
7
26
|
publicKey: Uint8Array<ArrayBuffer>;
|
|
@@ -15,6 +34,7 @@ export interface StoredPasskey {
|
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
export interface ApiKeyRecord extends ApiKeySummary {
|
|
37
|
+
userId: string;
|
|
18
38
|
tokenHash: string;
|
|
19
39
|
}
|
|
20
40
|
|
|
@@ -24,6 +44,7 @@ export interface DeviceAuthRequestRecord {
|
|
|
24
44
|
clientId: string;
|
|
25
45
|
scope: string;
|
|
26
46
|
status: "pending" | "approved" | "denied" | "consumed";
|
|
47
|
+
approvedByUserId?: string;
|
|
27
48
|
createdAt: string;
|
|
28
49
|
updatedAt: string;
|
|
29
50
|
expiresAt: string;
|
|
@@ -31,6 +52,7 @@ export interface DeviceAuthRequestRecord {
|
|
|
31
52
|
|
|
32
53
|
export interface RefreshSessionRecord {
|
|
33
54
|
id: string;
|
|
55
|
+
userId: string;
|
|
34
56
|
familyId: string;
|
|
35
57
|
clientId: string;
|
|
36
58
|
scope: string;
|
|
@@ -52,31 +74,52 @@ export interface SigningKeyRecord {
|
|
|
52
74
|
|
|
53
75
|
export interface WebAppStore {
|
|
54
76
|
initialize(): void;
|
|
55
|
-
getPreference(key: string): string | undefined;
|
|
56
|
-
setPreference(key: string, value: string): void;
|
|
57
|
-
deletePreference(key: string): void;
|
|
77
|
+
getPreference(key: string, userId?: string): string | undefined;
|
|
78
|
+
setPreference(key: string, value: string, userId?: string): void;
|
|
79
|
+
deletePreference(key: string, userId?: string): void;
|
|
58
80
|
|
|
59
|
-
getThemePreference(): ThemePreference | undefined;
|
|
60
|
-
setThemePreference(value: ThemePreference): void;
|
|
81
|
+
getThemePreference(userId?: string): ThemePreference | undefined;
|
|
82
|
+
setThemePreference(value: ThemePreference, userId?: string): void;
|
|
61
83
|
getLogLevelPreference(): LogLevelName | undefined;
|
|
62
84
|
setLogLevelPreference(value: LogLevelName): void;
|
|
63
85
|
|
|
64
|
-
|
|
86
|
+
countUsers(): number;
|
|
87
|
+
listUsers(): UserRecord[];
|
|
88
|
+
createUser(user: UserRecord): void;
|
|
89
|
+
getUserById(id: string): UserRecord | undefined;
|
|
90
|
+
getUserByUsername(username: string): UserRecord | undefined;
|
|
91
|
+
getOwnerUser(): UserRecord | undefined;
|
|
92
|
+
setUserRole(id: string, role: WebAppUserRole, updatedAt: string): boolean;
|
|
93
|
+
markUserLogin(id: string, lastLoginAt: string): void;
|
|
94
|
+
incrementUserAuthVersion(id: string, updatedAt: string): void;
|
|
95
|
+
deleteUser(id: string): boolean;
|
|
96
|
+
|
|
97
|
+
createSetupLink(record: UserSetupLinkRecord): void;
|
|
98
|
+
getSetupLinkByTokenHash(tokenHash: string): UserSetupLinkRecord | undefined;
|
|
99
|
+
consumeSetupLink(id: string, consumedAt: string): void;
|
|
100
|
+
deletePendingSetupLinksForUser(userId: string, nowIso: string): void;
|
|
101
|
+
|
|
102
|
+
saveAuditEvent(record: AuditEventRecord): void;
|
|
103
|
+
listAuditEvents(limit?: number): AuditEventRecord[];
|
|
104
|
+
|
|
105
|
+
listPasskeys(userId?: string): StoredPasskey[];
|
|
106
|
+
getPasskeyByUserId(userId: string): StoredPasskey | undefined;
|
|
65
107
|
getPasskeyByCredentialId(credentialId: string): StoredPasskey | undefined;
|
|
66
108
|
savePasskey(passkey: StoredPasskey): void;
|
|
67
109
|
updatePasskeyUsage(credentialId: string, counter: number, lastUsedAt: string): void;
|
|
68
|
-
|
|
110
|
+
deletePasskeysForUser(userId: string): void;
|
|
69
111
|
|
|
70
|
-
listApiKeys(): ApiKeyRecord[];
|
|
112
|
+
listApiKeys(userId?: string): ApiKeyRecord[];
|
|
71
113
|
getApiKeyByHash(tokenHash: string): ApiKeyRecord | undefined;
|
|
72
114
|
saveApiKey(record: ApiKeyRecord): void;
|
|
73
115
|
touchApiKey(id: string, lastUsedAt: string): void;
|
|
74
|
-
deleteApiKey(id: string): boolean;
|
|
116
|
+
deleteApiKey(id: string, userId?: string): boolean;
|
|
117
|
+
deleteApiKeysForUser(userId: string): void;
|
|
75
118
|
|
|
76
119
|
saveDeviceAuthRequest(record: DeviceAuthRequestRecord): void;
|
|
77
120
|
getDeviceAuthByUserCode(userCode: string): DeviceAuthRequestRecord | undefined;
|
|
78
121
|
getDeviceAuthByDeviceCodeHash(deviceCodeHash: string): DeviceAuthRequestRecord | undefined;
|
|
79
|
-
updateDeviceAuthStatus(userCode: string, status: DeviceAuthRequestRecord["status"], updatedAt: string): void;
|
|
122
|
+
updateDeviceAuthStatus(userCode: string, status: DeviceAuthRequestRecord["status"], updatedAt: string, approvedByUserId?: string): void;
|
|
80
123
|
deleteExpiredDeviceAuthRequests(nowIso: string): void;
|
|
81
124
|
|
|
82
125
|
getSigningKey(): SigningKeyRecord | undefined;
|
|
@@ -84,8 +127,9 @@ export interface WebAppStore {
|
|
|
84
127
|
|
|
85
128
|
saveRefreshSession(record: RefreshSessionRecord): void;
|
|
86
129
|
getRefreshSessionByHash(refreshTokenHash: string): RefreshSessionRecord | undefined;
|
|
87
|
-
listRefreshSessions(): RefreshSessionRecord[];
|
|
130
|
+
listRefreshSessions(userId?: string): RefreshSessionRecord[];
|
|
88
131
|
rotateRefreshSession(oldHash: string, next: RefreshSessionRecord, nowIso: string): RefreshSessionRecord | undefined;
|
|
89
|
-
revokeRefreshSession(id: string, revokedAt: string): boolean;
|
|
132
|
+
revokeRefreshSession(id: string, revokedAt: string, userId?: string): boolean;
|
|
90
133
|
revokeRefreshFamily(familyId: string, revokedAt: string): void;
|
|
134
|
+
revokeRefreshSessionsForUser(userId: string, revokedAt: string): void;
|
|
91
135
|
}
|
package/src/server/auth/types.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import type { CurrentUser } from "../../contracts";
|
|
2
|
+
|
|
1
3
|
export interface AccessTokenClaims {
|
|
2
4
|
sub: string;
|
|
5
|
+
username?: string;
|
|
6
|
+
role?: string;
|
|
3
7
|
jti: string;
|
|
4
8
|
sid: string;
|
|
5
9
|
clientId: string;
|
|
@@ -8,9 +12,9 @@ export interface AccessTokenClaims {
|
|
|
8
12
|
|
|
9
13
|
export type AuthenticatedRequestState =
|
|
10
14
|
| { kind: "anonymous" }
|
|
11
|
-
| { kind: "passkey"; passkeyId?: string }
|
|
12
|
-
| { kind: "api-key"; apiKeyId: string; scopes: string[] }
|
|
13
|
-
| { kind: "bearer"; claims: AccessTokenClaims };
|
|
15
|
+
| { kind: "passkey"; user: CurrentUser; passkeyId?: string }
|
|
16
|
+
| { kind: "api-key"; user: CurrentUser; apiKeyId: string; scopes: string[] }
|
|
17
|
+
| { kind: "bearer"; user: CurrentUser; claims: AccessTokenClaims };
|
|
14
18
|
|
|
15
19
|
export class AuthError extends Error {
|
|
16
20
|
code: string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CurrentUser, WebAppUserRole, WebAppUserSummary } from "../../contracts";
|
|
2
|
+
import type { AuditEventRecord, UserRecord, WebAppStore } from "./store";
|
|
3
|
+
import { addSeconds, nowIso, sha256 } from "./crypto";
|
|
4
|
+
import { AuthError } from "./types";
|
|
5
|
+
|
|
6
|
+
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/;
|
|
7
|
+
export const SETUP_LINK_TTL_SECONDS = 60 * 60 * 24;
|
|
8
|
+
|
|
9
|
+
export function normalizeUsername(username: string): string {
|
|
10
|
+
return username.trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function assertValidUsername(username: string): string {
|
|
14
|
+
const normalized = normalizeUsername(username);
|
|
15
|
+
if (!USERNAME_PATTERN.test(normalized)) {
|
|
16
|
+
throw new AuthError("invalid_username", "Username must be 3-32 lowercase letters, numbers, dots, underscores or hyphens", 400);
|
|
17
|
+
}
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toCurrentUser(user: UserRecord): CurrentUser {
|
|
22
|
+
return {
|
|
23
|
+
id: user.id,
|
|
24
|
+
username: user.username,
|
|
25
|
+
role: user.role,
|
|
26
|
+
isOwner: user.role === "owner",
|
|
27
|
+
isAdmin: user.role === "owner" || user.role === "admin",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function summarizeUser(user: UserRecord): WebAppUserSummary {
|
|
32
|
+
return {
|
|
33
|
+
id: user.id,
|
|
34
|
+
username: user.username,
|
|
35
|
+
role: user.role,
|
|
36
|
+
passkeyConfigured: user.passkeyConfigured,
|
|
37
|
+
createdAt: user.createdAt,
|
|
38
|
+
updatedAt: user.updatedAt,
|
|
39
|
+
lastLoginAt: user.lastLoginAt,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createUserRecord(input: { username: string; role: WebAppUserRole }): UserRecord {
|
|
44
|
+
const createdAt = nowIso();
|
|
45
|
+
return {
|
|
46
|
+
id: crypto.randomUUID(),
|
|
47
|
+
username: assertValidUsername(input.username),
|
|
48
|
+
role: input.role,
|
|
49
|
+
authVersion: 1,
|
|
50
|
+
passkeyConfigured: false,
|
|
51
|
+
createdAt,
|
|
52
|
+
updatedAt: createdAt,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSetupLinkRecord(input: {
|
|
57
|
+
userId: string;
|
|
58
|
+
token: string;
|
|
59
|
+
kind: "invite" | "reset" | "owner-reset";
|
|
60
|
+
createdByUserId?: string;
|
|
61
|
+
}) {
|
|
62
|
+
const createdAt = nowIso();
|
|
63
|
+
return {
|
|
64
|
+
id: crypto.randomUUID(),
|
|
65
|
+
userId: input.userId,
|
|
66
|
+
tokenHash: sha256(input.token),
|
|
67
|
+
kind: input.kind,
|
|
68
|
+
createdByUserId: input.createdByUserId,
|
|
69
|
+
createdAt,
|
|
70
|
+
expiresAt: addSeconds(SETUP_LINK_TTL_SECONDS),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function audit(store: WebAppStore, input: Omit<AuditEventRecord, "id" | "createdAt" | "metadata"> & { metadata?: Record<string, unknown> }): void {
|
|
75
|
+
store.saveAuditEvent({
|
|
76
|
+
id: crypto.randomUUID(),
|
|
77
|
+
eventType: input.eventType,
|
|
78
|
+
actorUserId: input.actorUserId,
|
|
79
|
+
targetUserId: input.targetUserId,
|
|
80
|
+
metadata: input.metadata ?? {},
|
|
81
|
+
createdAt: nowIso(),
|
|
82
|
+
});
|
|
83
|
+
}
|