@pablozaiden/webapp 0.3.7 → 0.3.9

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.7",
3
+ "version": "0.3.9",
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
  }
@@ -967,8 +967,8 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
967
967
  <div className="wapp-list">
968
968
  {authSessions.length ? authSessions.map((session) => (
969
969
  <div className="wapp-list-row" key={session.id}>
970
- <span><strong>{session.clientId}</strong><small>{session.scope} · {session.active ? "active" : "inactive"} · {session.updatedAt}</small></span>
971
- <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>
972
972
  </div>
973
973
  )) : <EmptyState title="No device sessions" />}
974
974
  </div>
@@ -1112,6 +1112,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1112
1112
  ...augmented,
1113
1113
  ];
1114
1114
  }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
1115
+ const activeActionNodes = useMemo(() => augmentPinningActions(baseNodes), [augmentPinningActions, baseNodes]);
1115
1116
 
1116
1117
  useEffect(() => {
1117
1118
  if (!config?.currentUser) return;
@@ -1176,7 +1177,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1176
1177
  const topActions = sidebar.topActions?.slice(0, 2) ?? [];
1177
1178
  const defaultTitle = route.view === "settings" ? "Settings" : route.view === homeRoute.view ? appName : route.view.replace(/-/g, " ");
1178
1179
  const headerContext = { route, defaultTitle };
1179
- const activeSidebarNode = flattenSidebarItems(nodes).find((node) => routeMatches(node.route, route));
1180
+ const activeSidebarNode = flattenSidebarItems(activeActionNodes).find((node) => routeMatches(node.route, route));
1180
1181
  const activeSidebarActions = activeSidebarNode?.actions ?? [];
1181
1182
  const headerActions = [
1182
1183
  ...(header?.getActions?.(headerContext) ?? []),