@pablozaiden/webapp 1.0.0 → 1.1.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 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
package/docs/server.md CHANGED
@@ -148,7 +148,7 @@ 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 |
@@ -162,6 +162,12 @@ The effective log level in `GET /api/config` and `GET
162
162
  to `true`. PUT remains an authenticated, same-origin admin mutation and
163
163
  returns a conflict when the environment controls the value.
164
164
 
165
+ Server-side applications that need credentials for internal runtimes should use
166
+ the server-only `createManagedApiKey`, `listManagedApiKeys`, and
167
+ `revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
168
+ store, authenticate through the normal bearer path, and are intentionally absent
169
+ from the browser-managed endpoint and Settings summaries.
170
+
165
171
  ## Framework-owned web document and PWA metadata
166
172
 
167
173
  `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,7 +3,7 @@
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
@@ -33,7 +33,11 @@ the shared hook state after a successful save, and `fromEnv` is true when
33
33
  `{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
34
34
  configuration fetch or initializer component.
35
35
 
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.
36
+ Security lists only show useful active user credentials. Managed API keys created by
37
+ server-side applications, and their metadata, are not returned by the browser API-key
38
+ list or rendered in Settings. Expired API keys are purged before listing, and revoked
39
+ or expired device-auth refresh sessions are not shown. Revoked refresh sessions may
40
+ remain in storage when needed for token-reuse protection, but they are hidden from Settings.
37
41
 
38
42
  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
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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;
@@ -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 {
@@ -386,6 +386,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
386
386
  const target = store.getUserById(userId);
387
387
  if (!target) return notFound();
388
388
  if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
389
+ store.deleteApiKeysForUser(userId);
389
390
  if (!store.deleteUser(userId)) return notFound();
390
391
  audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
391
392
  return successResponse();
@@ -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";