@pablozaiden/webapp 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pablozaiden/webapp
2
2
 
3
- Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
3
+ Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned and server-managed API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -39,6 +39,10 @@ Run this checklist before releasing a framework app or cutting a checkpoint.
39
39
  3. Call a protected API route with `Authorization: Bearer <token>`.
40
40
  4. Delete the key from Settings and confirm the confirmation dialog appears.
41
41
  5. Confirm the deleted token no longer authenticates.
42
+ 6. Create a managed key through the server-only helper and confirm it authenticates through the same bearer path.
43
+ 7. Confirm managed keys and `managedBy` metadata are absent from the normal API-key list and Settings UI.
44
+ 8. Restart against the same data directory and confirm a non-expired managed key still authenticates.
45
+ 9. Revoke the managed key through the server-only helper and confirm it no longer authenticates.
42
46
 
43
47
  ## CLI API-key authentication
44
48
 
package/docs/auth.md CHANGED
@@ -42,6 +42,38 @@ API keys are user-owned bearer tokens for scripts and agents. They are stored ha
42
42
 
43
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
44
 
45
+ Server-side applications can create managed keys through the server-only helpers exported from
46
+ `@pablozaiden/webapp/server`:
47
+
48
+ ```ts
49
+ import { createManagedApiKey, listManagedApiKeys, revokeManagedApiKey } from "@pablozaiden/webapp/server";
50
+
51
+ const created = createManagedApiKey(app.store, user, {
52
+ name: "Runtime credential",
53
+ managedBy: "my-application",
54
+ scopes: ["*"],
55
+ });
56
+ const keys = listManagedApiKeys(app.store, user.id, "my-application");
57
+ revokeManagedApiKey(app.store, created.key.id, user.id);
58
+ ```
59
+
60
+ Managed keys have an explicit `kind: "managed"` classification and optional opaque
61
+ `managedBy` metadata. The plaintext token is returned only by creation and is never
62
+ stored; applications must retain it securely. Managed keys use the same bearer
63
+ authentication, ownership, scope, expiration, disabled-user, and lifecycle invalidation
64
+ rules as user keys. They survive an ordinary restart, and applications should explicitly
65
+ revoke each generation during their own resource cleanup.
66
+
67
+ Managed keys have no browser/API CRUD endpoint. The normal `/api/api-keys` routes and
68
+ Settings UI expose only `kind: "user"` keys, and request input cannot create or convert a
69
+ managed key. User reset, passkey deletion, user deletion, and expiration cleanup cover
70
+ both kinds.
71
+
72
+ Custom `WebAppStore` implementations must persist `kind` and `managedBy`, migrate
73
+ pre-managed records as `kind: "user"`, include both kinds in expiration cleanup and
74
+ user deletion/reset cleanup, and preserve the owner filter used by the management
75
+ helpers. The built-in SQLite store performs this migration automatically.
76
+
45
77
  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`.
46
78
 
47
79
  ## Device auth
@@ -190,6 +190,16 @@ reload; `fromEnv` indicates that the server environment locks the value.
190
190
  `LoggerAdapter` remains application-owned and can adapt the state to any
191
191
  logging library.
192
192
 
193
+ ### In-memory server logs
194
+
195
+ Admins can enable temporary server-log capture from Developer Settings next to
196
+ the log-level selector. The framework exposes the current state in
197
+ `/api/config`; it starts disabled on every process start and is never stored in
198
+ the database. Admin clients can retrieve a bounded chronological snapshot from
199
+ `GET /api/server/logs` after enabling capture. The buffer is limited to the
200
+ newest 1,000 framework logger entries or 512 KiB of rendered log lines, and
201
+ turning the setting off clears it.
202
+
193
203
  ### Transient notifications
194
204
 
195
205
  The standard `renderWebApp` runtime provides the framework notification service
package/docs/server.md CHANGED
@@ -148,11 +148,13 @@ Built-in endpoints include:
148
148
  | `/api/user-setup*` | One-time invite/reset setup links |
149
149
  | `/setup` | Browser setup screen for one-time invite/reset links |
150
150
  | `/api/users`, `/api/users/:id/*`, `/api/audit-events` | Admin user management and audit log |
151
- | `/api/api-keys` | Browser-managed API key create/list/delete |
151
+ | `/api/api-keys` | Browser-managed user API-key create/list/delete |
152
152
  | `/api/auth/device`, `/api/auth/token`, `/api/auth/refresh`, `/api/auth/revoke` | Device auth and refresh-token flow |
153
153
  | `/device` | Browser device-code approval screen |
154
154
  | `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
155
155
  | `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
156
+ | `GET /api/server/logs` | Admin-only snapshot of the in-memory server logs |
157
+ | `PUT /api/server/logs/settings` | Admin-only runtime toggle for in-memory server logs |
156
158
  | `/api/server/kill` | Authenticated server shutdown |
157
159
  | `/api/ws` | Realtime websocket by default |
158
160
 
@@ -162,6 +164,24 @@ The effective log level in `GET /api/config` and `GET
162
164
  to `true`. PUT remains an authenticated, same-origin admin mutation and
163
165
  returns a conflict when the environment controls the value.
164
166
 
167
+ `GET /api/server/logs` is restricted to admin users and returns
168
+ `{ enabled: boolean, logs: ServerLogEntry[] }`. Entries are returned in
169
+ chronological order and contain the timestamp, level, logger scope, message,
170
+ and rendered log line. When capture is disabled, the endpoint returns `200`
171
+ with `enabled: false` and an empty list. `PUT /api/server/logs/settings`
172
+ accepts `{ "enabled": boolean }`, is restricted to admins, and uses the normal
173
+ same-origin policy for browser mutations. API-key and bearer callers continue
174
+ to use the existing token-auth behavior. The in-memory buffer is limited to
175
+ the newest 1,000 entries and 512 KiB of rendered UTF-8 lines; disabling capture
176
+ clears it. These endpoints only expose messages emitted through the framework
177
+ logger and never make the logs durable.
178
+
179
+ Server-side applications that need credentials for internal runtimes should use
180
+ the server-only `createManagedApiKey`, `listManagedApiKeys`, and
181
+ `revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
182
+ store, authenticate through the normal bearer path, and are intentionally absent
183
+ from the browser-managed endpoint and Settings summaries.
184
+
165
185
  ## Framework-owned web document and PWA metadata
166
186
 
167
187
  `createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated document also initializes the shared `data-wapp-mobile` state before client styles load so CSS and `WebAppRoot` use the same mobile breakpoint. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
package/docs/settings.md CHANGED
@@ -3,11 +3,13 @@
3
3
  Settings is framework-owned so apps stay consistent. It includes:
4
4
 
5
5
  - Account summary and passkey logout/delete
6
- - API key create/list/delete for the current user when enabled
6
+ - User API key create/list/delete for the current user when enabled
7
7
  - Device-auth sessions for the current user when enabled
8
8
  - Theme preference: system/light/dark, stored per user
9
9
  - Admin user management
10
10
  - Admin log-level preference unless `{PREFIX}_LOG_LEVEL` is set
11
+ - Admin in-memory server-log capture toggle; disabled and cleared on process
12
+ start or when turned off
11
13
  - Admin server kill
12
14
  - Version/about
13
15
 
@@ -33,7 +35,17 @@ the shared hook state after a successful save, and `fromEnv` is true when
33
35
  `{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
34
36
  configuration fetch or initializer component.
35
37
 
36
- 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.
38
+ Developer Settings also includes **Store server logs in memory**. It is an
39
+ admin-only, process-local setting and is disabled when the server starts. When
40
+ enabled, the framework logger retains only the newest 1,000 eligible entries
41
+ within a 512 KiB rendered-line limit; disabling it immediately clears the
42
+ buffer. The setting and captured logs are never stored in SQLite.
43
+
44
+ Security lists only show useful active user credentials. Managed API keys created by
45
+ server-side applications, and their metadata, are not returned by the browser API-key
46
+ list or rendered in Settings. Expired API keys are purged before listing, and revoked
47
+ or expired device-auth refresh sessions are not shown. Revoked refresh sessions may
48
+ remain in storage when needed for token-reuse protection, but they are hidden from Settings.
37
49
 
38
50
  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.
39
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,6 +58,8 @@ export interface PasskeyAuthStatusResponse {
58
58
  ownerPasskeySetupRequired: boolean;
59
59
  }
60
60
 
61
+ export type ApiKeyKind = "user" | "managed";
62
+
61
63
  export interface ApiKeySummary {
62
64
  id: string;
63
65
  name: string;
@@ -112,6 +114,14 @@ export interface AuthSessionSummary {
112
114
  active: boolean;
113
115
  }
114
116
 
117
+ export interface ServerLogEntry {
118
+ timestamp: string;
119
+ level: LogLevelName;
120
+ scope: string;
121
+ message: string;
122
+ line: string;
123
+ }
124
+
115
125
  export interface WebAppConfigResponse {
116
126
  appName: string;
117
127
  version: string;
@@ -125,6 +135,9 @@ export interface WebAppConfigResponse {
125
135
  level: LogLevelName;
126
136
  fromEnv: boolean;
127
137
  };
138
+ inMemoryLogs: {
139
+ enabled: boolean;
140
+ };
128
141
  deviceAuth: {
129
142
  enabled: boolean;
130
143
  };
@@ -1,9 +1,30 @@
1
1
  import type { ApiKeySummary, CreatedApiKeyResponse, CurrentUser } from "../../contracts";
2
- import type { WebAppStore } from "./store";
2
+ import type { ApiKeyRecord, WebAppStore } from "./store";
3
3
  import { nowIso, randomToken, sha256, secureEqual, isExpired } from "./crypto";
4
4
  import { AuthError } from "./types";
5
5
 
6
- function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummary {
6
+ export interface ManagedApiKeySummary extends ApiKeySummary {
7
+ kind: "managed";
8
+ managedBy?: string;
9
+ }
10
+
11
+ export interface CreatedManagedApiKeyResponse {
12
+ key: ManagedApiKeySummary;
13
+ token: string;
14
+ }
15
+
16
+ export interface ApiKeyCreationOptions {
17
+ name?: string;
18
+ scopes?: string[];
19
+ prefix?: string;
20
+ expiresAt?: string;
21
+ }
22
+
23
+ export interface ManagedApiKeyCreationOptions extends ApiKeyCreationOptions {
24
+ managedBy?: string;
25
+ }
26
+
27
+ function summarize(record: ApiKeyRecord): ApiKeySummary {
7
28
  return {
8
29
  id: record.id,
9
30
  name: record.name,
@@ -15,17 +36,31 @@ function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummar
15
36
  };
16
37
  }
17
38
 
39
+ function summarizeManaged(record: ApiKeyRecord): ManagedApiKeySummary {
40
+ return {
41
+ ...summarize(record),
42
+ kind: "managed",
43
+ managedBy: record.managedBy,
44
+ };
45
+ }
46
+
18
47
  export function listApiKeys(store: WebAppStore, userId: string): ApiKeySummary[] {
19
48
  store.deleteExpiredApiKeys?.(nowIso());
20
49
  return store.listApiKeys(userId)
50
+ .filter((record) => record.userId === userId && record.kind === "user")
21
51
  .filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
22
52
  .map(summarize);
23
53
  }
24
54
 
25
- export function createApiKey(store: WebAppStore, user: CurrentUser, input: { name?: string; scopes?: string[]; prefix?: string; expiresAt?: string }): CreatedApiKeyResponse {
55
+ function createApiKeyRecord(
56
+ user: CurrentUser,
57
+ input: ApiKeyCreationOptions,
58
+ kind: ApiKeyRecord["kind"],
59
+ managedBy?: string,
60
+ ): { record: ApiKeyRecord; token: string } {
26
61
  const prefix = input.prefix ?? "wapp";
27
62
  const token = `${prefix}_${randomToken(32)}`;
28
- const record = {
63
+ const record: ApiKeyRecord = {
29
64
  id: crypto.randomUUID(),
30
65
  userId: user.id,
31
66
  name: input.name?.trim() || "API key",
@@ -34,15 +69,53 @@ export function createApiKey(store: WebAppStore, user: CurrentUser, input: { nam
34
69
  scopes: input.scopes?.length ? input.scopes : ["*"],
35
70
  createdAt: nowIso(),
36
71
  expiresAt: input.expiresAt,
72
+ kind,
73
+ managedBy,
37
74
  };
75
+ return { record, token };
76
+ }
77
+
78
+ export function createApiKey(store: WebAppStore, user: CurrentUser, input: ApiKeyCreationOptions): CreatedApiKeyResponse {
79
+ const { record, token } = createApiKeyRecord(user, input, "user");
38
80
  store.saveApiKey(record);
39
81
  return { key: summarize(record), token };
40
82
  }
41
83
 
42
84
  export function deleteApiKey(store: WebAppStore, userId: string, id: string): boolean {
85
+ const record = store.listApiKeys(userId).find((candidate) => candidate.id === id && candidate.userId === userId);
86
+ if (!record || record.kind !== "user") {
87
+ return false;
88
+ }
43
89
  return store.deleteApiKey(id, userId);
44
90
  }
45
91
 
92
+ export function createManagedApiKey(store: WebAppStore, user: CurrentUser, input: ManagedApiKeyCreationOptions = {}): CreatedManagedApiKeyResponse {
93
+ const { record, token } = createApiKeyRecord(user, input, "managed", input.managedBy);
94
+ store.saveApiKey(record);
95
+ return { key: summarizeManaged(record), token };
96
+ }
97
+
98
+ export function listManagedApiKeys(store: WebAppStore, userId: string, managedBy?: string): ManagedApiKeySummary[] {
99
+ store.deleteExpiredApiKeys?.(nowIso());
100
+ return store.listApiKeys(userId)
101
+ .filter((record) => record.userId === userId && record.kind === "managed")
102
+ .filter((record) => managedBy === undefined || record.managedBy === managedBy)
103
+ .filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
104
+ .map(summarizeManaged);
105
+ }
106
+
107
+ export function revokeManagedApiKey(store: WebAppStore, id: string, userId?: string): boolean {
108
+ const record = store.listApiKeys(userId).find((candidate) =>
109
+ candidate.id === id
110
+ && candidate.kind === "managed"
111
+ && (userId === undefined || candidate.userId === userId)
112
+ );
113
+ if (!record) {
114
+ return false;
115
+ }
116
+ return store.deleteApiKey(id, record.userId);
117
+ }
118
+
46
119
  export function authenticateApiKey(store: WebAppStore, token: string): { user: CurrentUser; apiKeyId: string; scopes: string[] } | undefined {
47
120
  const tokenHash = sha256(token);
48
121
  const record = store.getApiKeyByHash(tokenHash);
@@ -53,7 +126,7 @@ export function authenticateApiKey(store: WebAppStore, token: string): { user: C
53
126
  return undefined;
54
127
  }
55
128
  const user = store.getUserById(record.userId);
56
- if (!user) {
129
+ if (!user || user.disabledAt) {
57
130
  return undefined;
58
131
  }
59
132
  store.touchApiKey(record.id, nowIso());
@@ -11,7 +11,7 @@ import type {
11
11
  UserSetupLinkRecord,
12
12
  WebAppStore,
13
13
  } from "./store";
14
- import type { LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
14
+ import type { ApiKeyKind, LogLevelName, ThemePreference, WebAppUserRole } from "../../contracts";
15
15
  import { createUserRecord } from "./users";
16
16
 
17
17
  type Row = Record<string, unknown>;
@@ -132,6 +132,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
132
132
  created_at TEXT NOT NULL,
133
133
  last_used_at TEXT,
134
134
  expires_at TEXT,
135
+ kind TEXT NOT NULL DEFAULT 'user',
136
+ managed_by TEXT,
135
137
  FOREIGN KEY (user_id) REFERENCES webapp_users(id) ON DELETE CASCADE
136
138
  );
137
139
  CREATE TABLE IF NOT EXISTS webapp_device_auth_requests (
@@ -175,6 +177,19 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
175
177
  `);
176
178
  }
177
179
 
180
+ function migrateApiKeySchema(): void {
181
+ if (!tableExists("webapp_api_keys")) {
182
+ return;
183
+ }
184
+ if (!hasColumn("webapp_api_keys", "kind")) {
185
+ db.exec("ALTER TABLE webapp_api_keys ADD COLUMN kind TEXT NOT NULL DEFAULT 'user';");
186
+ }
187
+ if (!hasColumn("webapp_api_keys", "managed_by")) {
188
+ db.exec("ALTER TABLE webapp_api_keys ADD COLUMN managed_by TEXT;");
189
+ }
190
+ db.exec("CREATE INDEX IF NOT EXISTS idx_webapp_api_keys_managed ON webapp_api_keys(user_id, kind, managed_by);");
191
+ }
192
+
178
193
  function migrateSingleUserSchema(): void {
179
194
  const legacySources = [
180
195
  ["webapp_preferences", tableExists("webapp_preferences") && !hasColumn("webapp_preferences", "user_id")],
@@ -277,6 +292,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
277
292
  function initialize(): void {
278
293
  migrateSingleUserSchema();
279
294
  createSchema();
295
+ migrateApiKeySchema();
280
296
  db.exec("PRAGMA foreign_keys = ON;");
281
297
  }
282
298
 
@@ -356,6 +372,7 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
356
372
  }
357
373
 
358
374
  function mapApiKey(row: Row): ApiKeyRecord {
375
+ const kind: ApiKeyKind = row["kind"] === "managed" ? "managed" : "user";
359
376
  return {
360
377
  id: text(row["id"]),
361
378
  userId: text(row["user_id"]),
@@ -366,6 +383,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
366
383
  createdAt: text(row["created_at"]),
367
384
  lastUsedAt: optionalText(row["last_used_at"]),
368
385
  expiresAt: optionalText(row["expires_at"]),
386
+ kind,
387
+ managedBy: optionalText(row["managed_by"]),
369
388
  };
370
389
  }
371
390
 
@@ -544,9 +563,22 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
544
563
  },
545
564
  saveApiKey: (record) => {
546
565
  db.query(`
547
- INSERT INTO webapp_api_keys (id, user_id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at)
548
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
549
- `).run(record.id, record.userId, record.name, record.prefix, record.tokenHash, JSON.stringify(record.scopes), record.createdAt, record.lastUsedAt ?? null, record.expiresAt ?? null);
566
+ INSERT INTO webapp_api_keys
567
+ (id, user_id, name, prefix, token_hash, scopes, created_at, last_used_at, expires_at, kind, managed_by)
568
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
569
+ `).run(
570
+ record.id,
571
+ record.userId,
572
+ record.name,
573
+ record.prefix,
574
+ record.tokenHash,
575
+ JSON.stringify(record.scopes),
576
+ record.createdAt,
577
+ record.lastUsedAt ?? null,
578
+ record.expiresAt ?? null,
579
+ record.kind,
580
+ record.managedBy ?? null,
581
+ );
550
582
  },
551
583
  touchApiKey: (id, lastUsedAt) => {
552
584
  db.query("UPDATE webapp_api_keys SET last_used_at = ? WHERE id = ?").run(lastUsedAt, id);
@@ -1,4 +1,4 @@
1
- import type { ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
1
+ import type { ApiKeyKind, ApiKeySummary, AuditEventSummary, LogLevelName, ThemePreference, WebAppUserRole, WebAppUserSummary } from "../../contracts";
2
2
 
3
3
  export interface UserRecord extends WebAppUserSummary {
4
4
  authVersion: number;
@@ -36,6 +36,8 @@ export interface StoredPasskey {
36
36
  export interface ApiKeyRecord extends ApiKeySummary {
37
37
  userId: string;
38
38
  tokenHash: string;
39
+ kind: ApiKeyKind;
40
+ managedBy?: string;
39
41
  }
40
42
 
41
43
  export interface DeviceAuthRequestRecord {
@@ -11,7 +11,7 @@ import {
11
11
  import { createAuthentication } from "./authentication";
12
12
  import { createFrameworkEndpointHandler } from "./framework-endpoints";
13
13
  import { createServerLifecycle } from "./server-lifecycle";
14
- import { setLogLevel } from "./logger";
14
+ import { inMemoryLogStorage, resetInMemoryLogStorage, setLogLevel } from "./logger";
15
15
  import { createWebDocumentProvider, htmlResponse } from "./web-document";
16
16
  import { createPublicRouteDispatcher } from "./public-route-dispatch";
17
17
  import { createRouteDispatcher } from "./route-dispatch";
@@ -45,6 +45,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
45
45
  if (config.appName !== input.appName || config.envPrefix !== input.envPrefix) {
46
46
  throw new Error("runtimeConfig appName and envPrefix must match the createWebAppServer inputs");
47
47
  }
48
+ resetInMemoryLogStorage();
48
49
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
49
50
  store.initialize();
50
51
  const savedLogLevel = store.getLogLevelPreference();
@@ -80,6 +81,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
80
81
  deviceAuthEnabled,
81
82
  configResponse: input.configResponse,
82
83
  onLogLevelChange: input.logLevel?.onChange,
84
+ inMemoryLogs: inMemoryLogStorage,
83
85
  ensureWebDocument,
84
86
  });
85
87
  const publicRouteDispatcher = createPublicRouteDispatcher({
@@ -48,6 +48,7 @@ import {
48
48
  createUserRequestSchema,
49
49
  deviceAuthorizationRequestSchema,
50
50
  deviceCodeActionRequestSchema,
51
+ inMemoryLogSettingsRequestSchema,
51
52
  logLevelPreferenceRequestSchema,
52
53
  passkeyBootstrapOptionsSchema,
53
54
  refreshTokenRequestSchema,
@@ -61,7 +62,8 @@ import {
61
62
  } from "./request-schemas";
62
63
  import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
63
64
  import { setLogLevel } from "./logger";
64
- import { errorResponse, jsonResponse, notFound, parseJson, successResponse } from "./responses";
65
+ import type { InMemoryLogStorage } from "./logger";
66
+ import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse } from "./responses";
65
67
  import type { WebSocketData } from "./realtime/bus";
66
68
 
67
69
  export interface FrameworkEndpointDependencies {
@@ -75,6 +77,7 @@ export interface FrameworkEndpointDependencies {
75
77
  deviceAuthEnabled: boolean;
76
78
  configResponse?: WebAppServerConfig["configResponse"];
77
79
  onLogLevelChange?: (level: LogLevelName) => void;
80
+ inMemoryLogs: InMemoryLogStorage;
78
81
  ensureWebDocument: () => Promise<WebDocument>;
79
82
  }
80
83
 
@@ -97,6 +100,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
97
100
  deviceAuthEnabled,
98
101
  configResponse: extendConfigResponse,
99
102
  onLogLevelChange,
103
+ inMemoryLogs,
100
104
  ensureWebDocument,
101
105
  } = dependencies;
102
106
 
@@ -115,6 +119,9 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
115
119
  level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
116
120
  fromEnv: config.logLevelFromEnv,
117
121
  },
122
+ inMemoryLogs: {
123
+ enabled: inMemoryLogs.isEnabled(),
124
+ },
118
125
  apiKeys: { enabled: Boolean(apiKeysEnabled) },
119
126
  deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
120
127
  } satisfies WebAppConfigResponse;
@@ -386,6 +393,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
386
393
  const target = store.getUserById(userId);
387
394
  if (!target) return notFound();
388
395
  if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
396
+ store.deleteApiKeysForUser(userId);
389
397
  if (!store.deleteUser(userId)) return notFound();
390
398
  audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
391
399
  return successResponse();
@@ -432,6 +440,32 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
432
440
  return successResponse({ level: body.level });
433
441
  }
434
442
  }
443
+ if (path === "/api/server/logs") {
444
+ const auth = await authentication.authorize(req, true);
445
+ if (auth instanceof Response) return auth;
446
+ ensureAdmin(auth);
447
+ if (req.method !== "GET") {
448
+ return methodNotAllowed();
449
+ }
450
+ const enabled = inMemoryLogs.isEnabled();
451
+ return jsonResponse({
452
+ enabled,
453
+ logs: enabled ? inMemoryLogs.getEntries() : [],
454
+ });
455
+ }
456
+ if (path === "/api/server/logs/settings") {
457
+ const auth = await authentication.authorize(req, true);
458
+ if (auth instanceof Response) return auth;
459
+ ensureAdmin(auth);
460
+ if (req.method !== "PUT") {
461
+ return methodNotAllowed();
462
+ }
463
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
464
+ if (originFailure) return originFailure;
465
+ const body = await parseJson(req, inMemoryLogSettingsRequestSchema);
466
+ inMemoryLogs.setEnabled(body.enabled);
467
+ return successResponse({ enabled: inMemoryLogs.isEnabled() });
468
+ }
435
469
  if (path === "/api/server/kill" && req.method === "POST") {
436
470
  const auth = await authentication.authorize(req, true);
437
471
  if (auth instanceof Response) return auth;
@@ -5,6 +5,7 @@ export * from "./responses";
5
5
  export * from "./public-assets";
6
6
  export * from "./create-web-app-server";
7
7
  export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
8
+ export * from "./auth/api-keys";
8
9
  export * from "./auth/store";
9
10
  export * from "./auth/sqlite-store";
10
11
  export * from "./realtime/bus";
@@ -1,4 +1,4 @@
1
- import type { LogLevelName } from "../contracts";
1
+ import type { LogLevelName, ServerLogEntry } from "../contracts";
2
2
 
3
3
  const ORDER: Record<LogLevelName, number> = {
4
4
  trace: 10,
@@ -8,7 +8,21 @@ const ORDER: Record<LogLevelName, number> = {
8
8
  error: 50,
9
9
  };
10
10
 
11
+ export const MAX_IN_MEMORY_LOG_ENTRIES = 1_000;
12
+ export const MAX_IN_MEMORY_LOG_BYTES = 512 * 1024;
13
+
11
14
  let currentLevel: LogLevelName = "info";
15
+ let inMemoryLogStorageEnabled = false;
16
+ let inMemoryLogEntries: ServerLogEntry[] = [];
17
+ let inMemoryLogBytes = 0;
18
+ const textEncoder = new TextEncoder();
19
+
20
+ export interface InMemoryLogStorage {
21
+ isEnabled(): boolean;
22
+ setEnabled(enabled: boolean): void;
23
+ getEntries(): ServerLogEntry[];
24
+ reset(): void;
25
+ }
12
26
 
13
27
  export function setLogLevel(level: LogLevelName): void {
14
28
  currentLevel = level;
@@ -18,6 +32,58 @@ export function getLogLevel(): LogLevelName {
18
32
  return currentLevel;
19
33
  }
20
34
 
35
+ function lineByteLength(line: string): number {
36
+ return textEncoder.encode(line).byteLength;
37
+ }
38
+
39
+ function appendInMemoryLogEntry(entry: ServerLogEntry): void {
40
+ if (!inMemoryLogStorageEnabled) {
41
+ return;
42
+ }
43
+ const entryBytes = lineByteLength(entry.line);
44
+ if (entryBytes > MAX_IN_MEMORY_LOG_BYTES) {
45
+ return;
46
+ }
47
+ inMemoryLogEntries.push(entry);
48
+ inMemoryLogBytes += entryBytes;
49
+ while (inMemoryLogEntries.length > MAX_IN_MEMORY_LOG_ENTRIES || inMemoryLogBytes > MAX_IN_MEMORY_LOG_BYTES) {
50
+ const removed = inMemoryLogEntries.shift();
51
+ if (!removed) {
52
+ break;
53
+ }
54
+ inMemoryLogBytes -= lineByteLength(removed.line);
55
+ }
56
+ }
57
+
58
+ export function setInMemoryLogStorageEnabled(enabled: boolean): void {
59
+ inMemoryLogStorageEnabled = enabled;
60
+ if (!enabled) {
61
+ inMemoryLogEntries = [];
62
+ inMemoryLogBytes = 0;
63
+ }
64
+ }
65
+
66
+ export function isInMemoryLogStorageEnabled(): boolean {
67
+ return inMemoryLogStorageEnabled;
68
+ }
69
+
70
+ export function getInMemoryLogEntries(): ServerLogEntry[] {
71
+ return inMemoryLogEntries.map((entry) => ({ ...entry }));
72
+ }
73
+
74
+ export function resetInMemoryLogStorage(): void {
75
+ inMemoryLogStorageEnabled = false;
76
+ inMemoryLogEntries = [];
77
+ inMemoryLogBytes = 0;
78
+ }
79
+
80
+ export const inMemoryLogStorage: InMemoryLogStorage = {
81
+ isEnabled: isInMemoryLogStorageEnabled,
82
+ setEnabled: setInMemoryLogStorageEnabled,
83
+ getEntries: getInMemoryLogEntries,
84
+ reset: resetInMemoryLogStorage,
85
+ };
86
+
21
87
  export function createLogger(scope: string) {
22
88
  function log(level: LogLevelName, message: string, fields?: Record<string, unknown>): void {
23
89
  if (ORDER[level] < ORDER[currentLevel]) {
@@ -26,6 +92,7 @@ export function createLogger(scope: string) {
26
92
  const timestamp = new Date().toISOString();
27
93
  const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
28
94
  const line = `${timestamp}\t${level.toUpperCase()}\t${scope}\t${message}${suffix}`;
95
+ appendInMemoryLogEntry({ timestamp, level, scope, message, line });
29
96
  if (level === "error") {
30
97
  console.error(line);
31
98
  } else if (level === "warn") {
@@ -142,3 +142,7 @@ export const themePreferenceRequestSchema = z.object({
142
142
  export const logLevelPreferenceRequestSchema = z.object({
143
143
  level: z.enum(["trace", "debug", "info", "warn", "error"]),
144
144
  });
145
+
146
+ export const inMemoryLogSettingsRequestSchema = z.object({
147
+ enabled: z.boolean(),
148
+ });
@@ -252,6 +252,18 @@ export function SelectField({ label, children, ...props }: SelectHTMLAttributes<
252
252
  );
253
253
  }
254
254
 
255
+ export function CheckboxField({ label, hint, ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; hint?: string }) {
256
+ return (
257
+ <label className="wapp-checkbox-field">
258
+ <input {...props} type="checkbox" />
259
+ <span>
260
+ {label}
261
+ {hint ? <small>{hint}</small> : null}
262
+ </span>
263
+ </label>
264
+ );
265
+ }
266
+
255
267
  export function SegmentedControl<T extends string>({ value, options, onChange, label }: { value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void; label: string }) {
256
268
  return (
257
269
  <div className="wapp-segmented" role="group" aria-label={label}>
@@ -1,7 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
3
3
  import { appJson } from "../api-client";
4
- import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
4
+ import { Badge, Button, CheckboxField, ErrorState, FormSection, LoadingState, SelectField } from "../components";
5
5
  import { useLogLevel } from "../log-level";
6
6
  import { isThemePreference, useTheme } from "../theme";
7
7
 
@@ -21,6 +21,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
21
21
  const { preference, setPreference, loading, error, retry } = useTheme();
22
22
  const logLevel = useLogLevel();
23
23
  const [logLevelSaving, setLogLevelSaving] = useState(false);
24
+ const [inMemoryLogsSaving, setInMemoryLogsSaving] = useState(false);
24
25
 
25
26
  async function updateLogLevel(value: string) {
26
27
  if (!isLogLevelName(value)) {
@@ -39,6 +40,22 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
39
40
  }
40
41
  }
41
42
 
43
+ async function updateInMemoryLogs(enabled: boolean) {
44
+ try {
45
+ setError(undefined);
46
+ setInMemoryLogsSaving(true);
47
+ await appJson<unknown>("/api/server/logs/settings", {
48
+ method: "PUT",
49
+ body: JSON.stringify({ enabled }),
50
+ });
51
+ await refresh();
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err.message : String(err));
54
+ } finally {
55
+ setInMemoryLogsSaving(false);
56
+ }
57
+ }
58
+
42
59
  return (
43
60
  <>
44
61
  {config.currentUser ? (
@@ -88,6 +105,13 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
88
105
  {LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
89
106
  </SelectField>
90
107
  ) : null}
108
+ <CheckboxField
109
+ label="Store server logs in memory"
110
+ hint="Temporary; disabled and cleared when the server process starts or this setting is turned off."
111
+ checked={config.inMemoryLogs.enabled}
112
+ disabled={inMemoryLogsSaving}
113
+ onChange={(event) => void updateInMemoryLogs(event.currentTarget.checked)}
114
+ />
91
115
  </FormSection>
92
116
  ) : null}
93
117
  </>
@@ -1618,6 +1618,32 @@ textarea::placeholder {
1618
1618
  resize: vertical;
1619
1619
  }
1620
1620
 
1621
+ .wapp-checkbox-field {
1622
+ display: flex;
1623
+ align-items: flex-start;
1624
+ gap: 0.5rem;
1625
+ }
1626
+
1627
+ .wapp-checkbox-field input {
1628
+ width: 1rem;
1629
+ min-height: 1rem;
1630
+ margin-top: 0.125rem;
1631
+ flex: 0 0 auto;
1632
+ }
1633
+
1634
+ .wapp-checkbox-field span {
1635
+ font-size: 0.8125rem;
1636
+ font-weight: 500;
1637
+ }
1638
+
1639
+ .wapp-checkbox-field small {
1640
+ display: block;
1641
+ color: var(--wapp-muted);
1642
+ font-size: 0.75rem;
1643
+ font-weight: 400;
1644
+ margin-top: 0.25rem;
1645
+ }
1646
+
1621
1647
  .wapp-field small {
1622
1648
  display: block;
1623
1649
  color: var(--wapp-muted);
@@ -50,6 +50,7 @@ function isWebAppConfigResponse(value: unknown): value is WebAppConfigResponse {
50
50
  && isRecord(value["logLevel"])
51
51
  && isLogLevelName(value["logLevel"]["level"])
52
52
  && typeof value["logLevel"]["fromEnv"] === "boolean"
53
+ && hasBooleanFields(value["inMemoryLogs"], ["enabled"])
53
54
  && hasBooleanFields(value["deviceAuth"], ["enabled"])
54
55
  && hasBooleanFields(value["apiKeys"], ["enabled"]);
55
56
  }