@pablozaiden/webapp 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/auth.md +3 -1
- package/docs/settings.md +2 -0
- package/package.json +1 -1
- package/src/contracts/index.ts +1 -0
- package/src/server/auth/api-keys.ts +7 -1
- package/src/server/auth/device-auth.ts +17 -11
- package/src/server/auth/sqlite-store.ts +6 -0
- package/src/server/auth/store.ts +2 -0
- package/src/web/WebAppRoot.tsx +33 -13
package/docs/auth.md
CHANGED
|
@@ -40,6 +40,8 @@ PATCH: (_req, ctx) => {
|
|
|
40
40
|
|
|
41
41
|
API keys are user-owned bearer tokens for scripts and agents. They are stored hashed in SQLite and shown only once at creation. Route `scopes` are enforced for API-key requests; `*` grants all scopes.
|
|
42
42
|
|
|
43
|
+
Expired API keys do not authenticate, are omitted from user-facing lists, and are purged when key lists or expired keys are encountered.
|
|
44
|
+
|
|
43
45
|
Same-origin checks are skipped for API-key and device bearer requests unless a route sets `sameOrigin: "always"`, because non-browser clients usually do not send `Origin`.
|
|
44
46
|
|
|
45
47
|
## Device auth
|
|
@@ -52,7 +54,7 @@ Device auth is included in V1:
|
|
|
52
54
|
4. Client exchanges the approved code at `/api/auth/token`.
|
|
53
55
|
5. Access tokens are JWT bearer tokens whose `sub` is the approving user id; refresh tokens rotate on every refresh.
|
|
54
56
|
|
|
55
|
-
Device codes are one-use. Device sessions are self-only in Settings. Reusing a consumed device code or stale refresh token returns `invalid_grant`.
|
|
57
|
+
Device codes are one-use. Device sessions are self-only in Settings, and only active refresh-token sessions are listed. Revoked or expired sessions are hidden; expired sessions are purged, while revoked session records can remain stored to detect stale refresh-token reuse. Reusing a consumed device code or stale refresh token returns `invalid_grant`.
|
|
56
58
|
|
|
57
59
|
## Same-origin policy
|
|
58
60
|
|
package/docs/settings.md
CHANGED
|
@@ -11,6 +11,8 @@ Settings is framework-owned so apps stay consistent. It includes:
|
|
|
11
11
|
- Admin server kill
|
|
12
12
|
- Version/about
|
|
13
13
|
|
|
14
|
+
Security lists only show useful active credentials. Expired API keys are purged before listing, and revoked or expired device-auth refresh sessions are not shown. Revoked refresh sessions may remain in storage when needed for token-reuse protection, but they are hidden from Settings.
|
|
15
|
+
|
|
14
16
|
Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
|
|
15
17
|
|
|
16
18
|
The server kill control follows the normal Settings row layout: explanation on the left, action on the right. After confirmation and a successful response, Settings shows a 15-second shutdown countdown progress bar that visibly drains before reloading the page.
|
package/package.json
CHANGED
package/src/contracts/index.ts
CHANGED
|
@@ -16,7 +16,10 @@ function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummar
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function listApiKeys(store: WebAppStore, userId: string): ApiKeySummary[] {
|
|
19
|
-
|
|
19
|
+
store.deleteExpiredApiKeys?.(nowIso());
|
|
20
|
+
return store.listApiKeys(userId)
|
|
21
|
+
.filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
|
|
22
|
+
.map(summarize);
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export function createApiKey(store: WebAppStore, user: CurrentUser, input: { name?: string; scopes?: string[]; prefix?: string; expiresAt?: string }): CreatedApiKeyResponse {
|
|
@@ -44,6 +47,9 @@ export function authenticateApiKey(store: WebAppStore, token: string): { user: C
|
|
|
44
47
|
const tokenHash = sha256(token);
|
|
45
48
|
const record = store.getApiKeyByHash(tokenHash);
|
|
46
49
|
if (!record || !secureEqual(record.tokenHash, tokenHash) || (record.expiresAt && isExpired(record.expiresAt))) {
|
|
50
|
+
if (record?.expiresAt && isExpired(record.expiresAt)) {
|
|
51
|
+
store.deleteApiKey(record.id);
|
|
52
|
+
}
|
|
47
53
|
return undefined;
|
|
48
54
|
}
|
|
49
55
|
const user = store.getUserById(record.userId);
|
|
@@ -123,6 +123,7 @@ function tokenSet(accessToken: string, refreshToken: string, scope: string): Tok
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function createDeviceAuthorization(req: Request, store: WebAppStore, config: RuntimeConfig, input: { clientId?: string; scope?: string } = {}): DeviceAuthorizationResponse {
|
|
126
|
+
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
126
127
|
const deviceCode = randomToken(32);
|
|
127
128
|
let userCode = generateUserCode();
|
|
128
129
|
while (store.getDeviceAuthByUserCode(userCode)) {
|
|
@@ -151,6 +152,7 @@ export function createDeviceAuthorization(req: Request, store: WebAppStore, conf
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
export function getDeviceVerificationDetails(store: WebAppStore, userCode: string, passkeyRequired: boolean): DeviceVerificationDetails {
|
|
155
|
+
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
154
156
|
const record = store.getDeviceAuthByUserCode(userCode);
|
|
155
157
|
if (!record || isExpired(record.expiresAt)) {
|
|
156
158
|
throw new AuthError("invalid_user_code", "Device authorization code is invalid or expired", 404);
|
|
@@ -207,6 +209,7 @@ function createRefreshRecord(userId: string, clientId: string, scope: string, fa
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
export async function exchangeDeviceCode(store: WebAppStore, config: RuntimeConfig, deviceCode: string, clientId?: string): Promise<TokenResponse> {
|
|
212
|
+
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
210
213
|
const record = store.getDeviceAuthByDeviceCodeHash(sha256(deviceCode));
|
|
211
214
|
if (!record) {
|
|
212
215
|
throw new AuthError("invalid_grant", "Device code is invalid", 400);
|
|
@@ -301,17 +304,20 @@ export async function verifyAccessToken(store: WebAppStore, config: RuntimeConfi
|
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
export function listAuthSessions(store: WebAppStore, userId: string): AuthSessionSummary[] {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
307
|
+
store.deleteExpiredRefreshSessions?.(nowIso());
|
|
308
|
+
return store.listRefreshSessions(userId)
|
|
309
|
+
.filter((session) => !session.revokedAt && !isExpired(session.expiresAt))
|
|
310
|
+
.map((session) => ({
|
|
311
|
+
id: session.id,
|
|
312
|
+
clientId: session.clientId,
|
|
313
|
+
scope: session.scope,
|
|
314
|
+
createdAt: session.createdAt,
|
|
315
|
+
updatedAt: session.updatedAt,
|
|
316
|
+
expiresAt: session.expiresAt,
|
|
317
|
+
lastUsedAt: session.lastUsedAt,
|
|
318
|
+
revokedAt: session.revokedAt,
|
|
319
|
+
active: true,
|
|
320
|
+
}));
|
|
315
321
|
}
|
|
316
322
|
|
|
317
323
|
export function revokeAuthSession(store: WebAppStore, userId: string, id: string): boolean {
|
|
@@ -560,6 +560,9 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
560
560
|
deleteApiKeysForUser: (userId) => {
|
|
561
561
|
db.query("DELETE FROM webapp_api_keys WHERE user_id = ?").run(userId);
|
|
562
562
|
},
|
|
563
|
+
deleteExpiredApiKeys: (now) => {
|
|
564
|
+
db.query("DELETE FROM webapp_api_keys WHERE expires_at IS NOT NULL AND expires_at <= ?").run(now);
|
|
565
|
+
},
|
|
563
566
|
|
|
564
567
|
saveDeviceAuthRequest: (record) => {
|
|
565
568
|
db.query(`
|
|
@@ -646,5 +649,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
646
649
|
revokeRefreshSessionsForUser: (userId, revokedAt) => {
|
|
647
650
|
db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, userId);
|
|
648
651
|
},
|
|
652
|
+
deleteExpiredRefreshSessions: (now) => {
|
|
653
|
+
db.query("DELETE FROM webapp_refresh_sessions WHERE expires_at <= ?").run(now);
|
|
654
|
+
},
|
|
649
655
|
};
|
|
650
656
|
}
|
package/src/server/auth/store.ts
CHANGED
|
@@ -115,6 +115,7 @@ export interface WebAppStore {
|
|
|
115
115
|
touchApiKey(id: string, lastUsedAt: string): void;
|
|
116
116
|
deleteApiKey(id: string, userId?: string): boolean;
|
|
117
117
|
deleteApiKeysForUser(userId: string): void;
|
|
118
|
+
deleteExpiredApiKeys?(nowIso: string): void;
|
|
118
119
|
|
|
119
120
|
saveDeviceAuthRequest(record: DeviceAuthRequestRecord): void;
|
|
120
121
|
getDeviceAuthByUserCode(userCode: string): DeviceAuthRequestRecord | undefined;
|
|
@@ -132,4 +133,5 @@ export interface WebAppStore {
|
|
|
132
133
|
revokeRefreshSession(id: string, revokedAt: string, userId?: string): boolean;
|
|
133
134
|
revokeRefreshFamily(familyId: string, revokedAt: string): void;
|
|
134
135
|
revokeRefreshSessionsForUser(userId: string, revokedAt: string): void;
|
|
136
|
+
deleteExpiredRefreshSessions?(nowIso: string): void;
|
|
135
137
|
}
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -531,6 +531,17 @@ function DeviceVerificationScreen() {
|
|
|
531
531
|
|
|
532
532
|
type SidebarTreeParentKind = "root" | "section" | "item";
|
|
533
533
|
|
|
534
|
+
type SidebarTreeProps = {
|
|
535
|
+
nodes: SidebarNode[];
|
|
536
|
+
route: WebAppRoute;
|
|
537
|
+
navigate: (route: WebAppRoute) => void;
|
|
538
|
+
collapsed: SidebarCollapsedState;
|
|
539
|
+
toggleCollapsed: (id: string, isCollapsed: boolean) => void;
|
|
540
|
+
searchActive: boolean;
|
|
541
|
+
level?: number;
|
|
542
|
+
parentKind?: SidebarTreeParentKind;
|
|
543
|
+
};
|
|
544
|
+
|
|
534
545
|
function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): { marginLeft?: string } | undefined {
|
|
535
546
|
if (level <= 0) {
|
|
536
547
|
return undefined;
|
|
@@ -540,19 +551,26 @@ function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): {
|
|
|
540
551
|
return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
|
|
541
552
|
}
|
|
542
553
|
|
|
543
|
-
function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, level = 0, parentKind = "root" }:
|
|
554
|
+
function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, searchActive, level = 0, parentKind = "root" }: SidebarTreeProps) {
|
|
544
555
|
const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
|
|
545
556
|
return (
|
|
546
557
|
<>
|
|
547
558
|
{nodes.map((node) => {
|
|
548
559
|
const hasChildren = Boolean(node.children?.length);
|
|
549
|
-
const
|
|
560
|
+
const storedIsCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
|
|
561
|
+
const isCollapsed = searchActive && hasChildren ? false : storedIsCollapsed;
|
|
562
|
+
const toggleAriaLabel = searchActive ? `Toggling unavailable during search for ${node.title}` : `${isCollapsed ? "Expand" : "Collapse"} ${node.title}`;
|
|
563
|
+
const toggleNodeCollapsed = () => {
|
|
564
|
+
if (!searchActive) {
|
|
565
|
+
toggleCollapsed(node.id, storedIsCollapsed);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
550
568
|
if (node.type === "section") {
|
|
551
569
|
return (
|
|
552
570
|
<section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
|
|
553
571
|
<div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
|
|
554
572
|
{hasChildren ? (
|
|
555
|
-
<button type="button" aria-expanded={!isCollapsed} aria-label={
|
|
573
|
+
<button type="button" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>
|
|
556
574
|
<span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
|
|
557
575
|
</button>
|
|
558
576
|
) : (
|
|
@@ -560,7 +578,7 @@ function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, level
|
|
|
560
578
|
)}
|
|
561
579
|
{node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
|
|
562
580
|
</div>
|
|
563
|
-
{!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} level={level + 1} parentKind="section" /> : null}
|
|
581
|
+
{!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" /> : null}
|
|
564
582
|
{!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
|
|
565
583
|
</section>
|
|
566
584
|
);
|
|
@@ -568,7 +586,7 @@ function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, level
|
|
|
568
586
|
const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
|
|
569
587
|
return (
|
|
570
588
|
<div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
|
|
571
|
-
{hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={
|
|
589
|
+
{hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>{isCollapsed ? "▶" : "▼"}</button> : null}
|
|
572
590
|
<button
|
|
573
591
|
type="button"
|
|
574
592
|
className={`wapp-sidebar-item ${active ? "active" : ""}`}
|
|
@@ -585,7 +603,7 @@ function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, level
|
|
|
585
603
|
</span>
|
|
586
604
|
{node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
|
|
587
605
|
</button>
|
|
588
|
-
{!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} level={level + 1} parentKind="item" /></div> : null}
|
|
606
|
+
{!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="item" /></div> : null}
|
|
589
607
|
</div>
|
|
590
608
|
);
|
|
591
609
|
})}
|
|
@@ -949,8 +967,8 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
949
967
|
<div className="wapp-list">
|
|
950
968
|
{authSessions.length ? authSessions.map((session) => (
|
|
951
969
|
<div className="wapp-list-row" key={session.id}>
|
|
952
|
-
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.
|
|
953
|
-
<Button type="button" variant="danger"
|
|
970
|
+
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.updatedAt}</small></span>
|
|
971
|
+
<Button type="button" variant="danger" onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
|
|
954
972
|
</div>
|
|
955
973
|
)) : <EmptyState title="No device sessions" />}
|
|
956
974
|
</div>
|
|
@@ -1040,10 +1058,12 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1040
1058
|
});
|
|
1041
1059
|
}, []);
|
|
1042
1060
|
const sidebarSearchEnabled = sidebar.search !== false;
|
|
1061
|
+
const normalizedSidebarSearch = sidebarSearchEnabled ? search.trim() : "";
|
|
1062
|
+
const sidebarSearchActive = normalizedSidebarSearch.length > 0;
|
|
1043
1063
|
const pinningEnabled = sidebar.pinning !== false;
|
|
1044
1064
|
const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
|
|
1045
1065
|
const baseNodes = useMemo(() => sidebar.getNodes({ search: "" }), [sidebar]);
|
|
1046
|
-
const filteredNodes = useMemo(() => sidebar.getNodes({ search:
|
|
1066
|
+
const filteredNodes = useMemo(() => sidebar.getNodes({ search: normalizedSidebarSearch }), [sidebar, normalizedSidebarSearch]);
|
|
1047
1067
|
const allPinnableItems = useMemo(() => flattenSidebarItems(baseNodes).filter((node) => node.pinnable && node.route), [baseNodes]);
|
|
1048
1068
|
const currentPins = useMemo(() => {
|
|
1049
1069
|
const byId = new Map(allPinnableItems.map((node) => [node.pinId ?? node.id, node]));
|
|
@@ -1071,7 +1091,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1071
1091
|
}), [pinningActionFor]);
|
|
1072
1092
|
const nodes = useMemo(() => {
|
|
1073
1093
|
const augmented = augmentPinningActions(filteredNodes);
|
|
1074
|
-
if (!pinningEnabled ||
|
|
1094
|
+
if (!pinningEnabled || sidebarSearchActive || currentPins.length === 0) return augmented;
|
|
1075
1095
|
const augmentedByPinId = new Map(flattenSidebarItems(augmentPinningActions(baseNodes)).map((node) => [node.pinId ?? node.id, node]));
|
|
1076
1096
|
const pinnedChildren = currentPins.map((pin) => ({
|
|
1077
1097
|
...(augmentedByPinId.get(pin.id) ?? {
|
|
@@ -1091,7 +1111,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1091
1111
|
{ type: "section" as const, id: "framework:pinned", title: sidebar.pinning ? sidebar.pinning.sectionTitle ?? "Pinned" : "Pinned", children: pinnedChildren },
|
|
1092
1112
|
...augmented,
|
|
1093
1113
|
];
|
|
1094
|
-
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled,
|
|
1114
|
+
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
|
|
1095
1115
|
|
|
1096
1116
|
useEffect(() => {
|
|
1097
1117
|
if (!config?.currentUser) return;
|
|
@@ -1192,8 +1212,8 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
1192
1212
|
</div>
|
|
1193
1213
|
</div>
|
|
1194
1214
|
<div className="wapp-sidebar-scroll">
|
|
1195
|
-
{sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search}
|
|
1196
|
-
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={sidebarTreeState.collapsed} toggleCollapsed={sidebarTreeState.toggleCollapsed} />
|
|
1215
|
+
{sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onInput={(event) => setSearch(event.currentTarget.value)} placeholder="Search" /></label> : null}
|
|
1216
|
+
<SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={sidebarTreeState.collapsed} toggleCollapsed={sidebarTreeState.toggleCollapsed} searchActive={sidebarSearchActive} />
|
|
1197
1217
|
<div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
|
|
1198
1218
|
</div>
|
|
1199
1219
|
</aside>
|