@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -108,6 +108,7 @@ export interface AuthSessionSummary {
108
108
  expiresAt: string;
109
109
  lastUsedAt?: string;
110
110
  revokedAt?: string;
111
+ /** Built-in session lists only expose active sessions; retained for API compatibility. */
111
112
  active: boolean;
112
113
  }
113
114
 
@@ -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
- return store.listApiKeys(userId).map(summarize);
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
- return store.listRefreshSessions(userId).map((session) => ({
305
- id: session.id,
306
- clientId: session.clientId,
307
- scope: session.scope,
308
- createdAt: session.createdAt,
309
- updatedAt: session.updatedAt,
310
- expiresAt: session.expiresAt,
311
- lastUsedAt: session.lastUsedAt,
312
- revokedAt: session.revokedAt,
313
- active: !session.revokedAt && !isExpired(session.expiresAt),
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
  }
@@ -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
  }
@@ -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" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; collapsed: SidebarCollapsedState; toggleCollapsed: (id: string, isCollapsed: boolean) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
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 isCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
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={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>
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={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>{isCollapsed ? "▶" : "▼"}</button> : null}
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.active ? "active" : "inactive"} · {session.updatedAt}</small></span>
953
- <Button type="button" variant="danger" disabled={!session.active} onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
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: sidebarSearchEnabled ? search : "" }), [sidebar, search, sidebarSearchEnabled]);
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 || (sidebarSearchEnabled && search.trim()) || currentPins.length === 0) return augmented;
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, search, sidebar.pinning, sidebarSearchEnabled]);
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} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
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>