@pablozaiden/webapp 0.0.1 → 0.2.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,10 +1,10 @@
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, passkey auth, API keys, device auth, realtime websocket state, 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 API keys, device auth, realtime websocket state, scoped settings, binary builds and Docker images.
4
4
 
5
5
  ## Quick start
6
6
 
7
- Use one of the examples while the package is private:
7
+ Use one of the examples during framework development:
8
8
 
9
9
  ```bash
10
10
  bun install
@@ -19,7 +19,7 @@ Both examples run with Bun native hot reload through `bun --hot`, with no standa
19
19
  | Export | Use |
20
20
  | --- | --- |
21
21
  | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, SQLite store |
22
- | `@pablozaiden/webapp/web` | `WebAppRoot`, sidebar types, UI controls, realtime hooks |
22
+ | `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
23
23
  | `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
24
24
  | `@pablozaiden/webapp/build` | Bun single-binary compile helper |
25
25
 
@@ -1,17 +1,36 @@
1
1
  # Auth validation checklist
2
2
 
3
- Run this checklist before migrating an app or cutting a checkpoint.
3
+ Run this checklist before releasing a framework app or cutting a checkpoint.
4
4
 
5
5
  ## Passkeys
6
6
 
7
7
  1. Start the app with passkeys enabled and an empty data dir.
8
- 2. Confirm the bootstrap screen appears before the shell.
9
- 3. Register a passkey with the browser/platform authenticator.
10
- 4. Confirm the app shell loads and Settings shows passkey configured.
8
+ 2. Confirm the owner bootstrap screen appears before the shell.
9
+ 3. Register the owner username and passkey with the browser/platform authenticator.
10
+ 4. Confirm the app shell loads and Settings shows the owner account and passkey configured.
11
11
  5. Click Logout and confirm reload/login requires passkey authentication.
12
12
  6. Authenticate with the registered passkey and confirm the shell loads again.
13
13
  7. Delete passkey from Settings, confirm the dialog supports Escape, X and Cancel.
14
- 8. Confirm deleting returns to bootstrap on reload.
14
+ 8. Confirm deleting the owner passkey shows owner re-setup on reload.
15
+
16
+ ## Users and roles
17
+
18
+ 1. As owner/admin, create a non-admin user and copy the one-time setup link.
19
+ 2. Open `/setup?token=...` in a fresh context and register that user's passkey.
20
+ 3. Confirm the new user can sign in and does not see admin-only settings.
21
+ 4. Confirm app data is empty for a newly-created non-owner user unless explicitly provisioned.
22
+ 5. Promote the user to admin and confirm user management appears.
23
+ 6. Demote the user and confirm admin settings disappear.
24
+ 7. Reset the user and confirm old sessions/API keys/device sessions no longer work.
25
+ 8. Confirm owner cannot be reset, deleted, or demoted.
26
+
27
+ ## App data isolation
28
+
29
+ 1. Create data as owner and as a non-owner user.
30
+ 2. Confirm list endpoints return only the current user's data.
31
+ 3. Confirm item endpoints return 404 for another user's IDs.
32
+ 4. Confirm user-owned realtime events reach only sockets authenticated as the owning user.
33
+ 5. Confirm public endpoints intentionally attach data to the correct owner/user and never broadcast user-owned IDs globally.
15
34
 
16
35
  ## API keys
17
36
 
package/docs/auth.md CHANGED
@@ -2,19 +2,43 @@
2
2
 
3
3
  The framework provides three auth modes designed to coexist.
4
4
 
5
- ## Passkeys
5
+ ## Users and passkeys
6
6
 
7
- Passkeys follow a single-passkey bootstrap model:
7
+ Passkeys are multi-user. The first run creates the immutable owner user and that user's passkey.
8
8
 
9
- 1. If no passkey exists, registration is allowed.
10
- 2. Once a passkey exists, protected routes require an authenticated passkey browser session, API key, or device bearer token.
11
- 3. Additional public registration is rejected after bootstrap.
9
+ 1. If no users exist, the UI asks for the owner username and passkey.
10
+ 2. The owner cannot be deleted or demoted.
11
+ 3. Owners and admins create users from Settings, which returns a one-time 24h setup link.
12
+ 4. Users complete setup from `/setup?token=...`; usernames are lowercase, immutable and unique.
13
+ 5. Admin resets clear the user's passkey, API keys and device sessions, then issue a new one-time setup link.
14
+ 6. If the owner passkey is deleted, the owner setup screen is shown again.
12
15
 
13
- Settings owns passkey logout/delete UX.
16
+ Route handlers get `ctx.auth.user` plus helpers:
17
+
18
+ ```ts
19
+ const user = ctx.requireUser();
20
+ const admin = ctx.requireAdmin();
21
+ ctx.assertUser(userId);
22
+ ```
23
+
24
+ For app-owned data, prefer the ownership helpers so every route has the same self-only behavior:
25
+
26
+ ```ts
27
+ GET: (_req, ctx) => jsonResponse(ctx.filterOwned(items));
28
+
29
+ PATCH: (_req, ctx) => {
30
+ const item = ctx.requireOwned(items.find((candidate) => candidate.id === ctx.params.id));
31
+ return jsonResponse(item);
32
+ }
33
+ ```
34
+
35
+ `ctx.requireOwned()` returns 404 for missing resources and for resources owned by another user, avoiding cross-user existence leaks. For user-owned realtime updates, publish through `ctx.userRealtime` instead of the global `ctx.realtime`.
36
+
37
+ `{PREFIX}_DISABLE_PASSKEY=true` is an emergency bypass that logs in as the existing owner only. It does not create the owner.
14
38
 
15
39
  ## API keys
16
40
 
17
- API keys are 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.
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.
18
42
 
19
43
  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`.
20
44
 
@@ -26,9 +50,9 @@ Device auth is included in V1:
26
50
  2. Server returns `device_code`, human `user_code`, `verification_uri`, and polling interval.
27
51
  3. Browser user opens `/device?user_code=...` and approves from the same-origin UI.
28
52
  4. Client exchanges the approved code at `/api/auth/token`.
29
- 5. Access tokens are JWT bearer tokens; refresh tokens rotate on every refresh.
53
+ 5. Access tokens are JWT bearer tokens whose `sub` is the approving user id; refresh tokens rotate on every refresh.
30
54
 
31
- Device codes are one-use. Reusing a consumed device code or stale refresh token returns `invalid_grant`.
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`.
32
56
 
33
57
  ## Same-origin policy
34
58
 
@@ -4,11 +4,22 @@ Apps are one Bun application: backend routes, websocket, React UI and static ass
4
4
 
5
5
  ```ts
6
6
  import webIndex from "./index.html";
7
- import { createWebAppServer, defineRoutes, jsonResponse } from "@pablozaiden/webapp/server";
7
+ import { createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
8
+
9
+ const items: Array<{ id: string; userId: string; title: string }> = [];
8
10
 
9
11
  const routes = defineRoutes({
10
12
  "/api/items": {
11
- GET: () => jsonResponse([{ id: "one", title: "One" }]),
13
+ auth: "user",
14
+ GET: (_req, ctx) => jsonResponse(ctx.filterOwned(items)),
15
+ async POST(req, ctx) {
16
+ const user = ctx.requireUser();
17
+ const body = await parseJson<{ title: string }>(req);
18
+ const item = { id: crypto.randomUUID(), userId: user.id, title: body.title };
19
+ items.push(item);
20
+ ctx.userRealtime.publishEntityChanged("items", item.id);
21
+ return jsonResponse(item);
22
+ },
12
23
  },
13
24
  "/api/webhooks/:source/:token": {
14
25
  auth: "public",
@@ -29,13 +40,28 @@ const app = createWebAppServer({
29
40
  await app.runFromCli();
30
41
  ```
31
42
 
32
- Frontend entrypoints should import the framework CSS explicitly so Bun hot reload observes style changes:
43
+ Frontend entrypoints should use `renderWebApp` so Bun/browser hot reload reuses the existing React root instead of calling `createRoot()` twice. Import the framework CSS explicitly so Bun hot reload observes style changes:
33
44
 
34
- ```ts
35
- import { WebAppRoot } from "@pablozaiden/webapp/web";
45
+ ```tsx
46
+ import { WebAppRoot, renderWebApp } from "@pablozaiden/webapp/web";
36
47
  import "@pablozaiden/webapp/web/styles.css";
48
+
49
+ function Home() {
50
+ return <main className="wapp-main-content">Hello</main>;
51
+ }
52
+
53
+ renderWebApp(
54
+ <WebAppRoot
55
+ appName="My App"
56
+ homeRoute={{ view: "home" }}
57
+ sidebar={{ getNodes: () => [] }}
58
+ routes={{ home: <Home /> }}
59
+ />,
60
+ );
37
61
  ```
38
62
 
63
+ `renderWebApp` renders into `#root` by default and reuses the existing React root across hot reloads. Pass a custom element id or `Element` only when the app uses a different mount point.
64
+
39
65
  Recommended dev script:
40
66
 
41
67
  ```json
@@ -50,11 +76,11 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
50
76
 
51
77
  | Variable | Default | Description |
52
78
  | --- | --- | --- |
53
- | `{PREFIX}_HOST` | `127.0.0.1` | Bind host |
79
+ | `{PREFIX}_HOST` | `localhost` | Bind host |
54
80
  | `{PREFIX}_PORT` | `3000` | Bind port |
55
81
  | `{PREFIX}_DATA_DIR` | `./data` | SQLite persistence directory |
56
82
  | `{PREFIX}_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`; locks settings log-level control when set |
57
- | `{PREFIX}_DISABLE_PASSKEY` | unset | Development escape hatch |
83
+ | `{PREFIX}_DISABLE_PASSKEY` | unset | Emergency bypass that logs in as the existing owner; it does not create users |
58
84
  | `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
59
85
  | `{PREFIX}_PUBLIC_BASE_URL` | request origin | External URL for device auth links |
60
86
  | `{PREFIX}_AUTH_ISSUER` | `urn:{prefix}:webapp` | JWT issuer override |
@@ -1,6 +1,6 @@
1
1
  # GitHub Actions for framework apps
2
2
 
3
- Use these templates for applications built with `@pablozaiden/webapp`. They follow the same deployment pattern as the reference apps:
3
+ Use these templates for applications built with `@pablozaiden/webapp`. They follow the framework deployment pattern:
4
4
 
5
5
  1. Pull requests install, build, test and smoke-test the Bun dev server.
6
6
  2. Merges to `main` publish a `main` Docker image to GHCR.
@@ -31,6 +31,8 @@ The templates assume these package scripts:
31
31
 
32
32
  If the app uses TypeScript typechecking separately, add a `tsc` script and call it from the PR workflow before tests.
33
33
 
34
+ The smoke templates only hit health/static/public endpoints. `MY_APP_DISABLE_PASSKEY=true` authenticates as the existing owner when one exists; it does not create an owner in an empty data directory. If a smoke test needs protected app APIs, seed a test owner in `MY_APP_DATA_DIR` first or add a deliberate public smoke endpoint.
35
+
34
36
  ## Dockerfile
35
37
 
36
38
  Place this at `Dockerfile` in the app repository root. It builds the app inside Docker, copies only the standalone Bun binary into a slim runtime image, runs as a non-root user, and exposes `/api/health` as the container healthcheck.
@@ -255,11 +257,11 @@ jobs:
255
257
  id: image
256
258
  run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
257
259
 
258
- - name: Build and push Docker image
260
+ - name: Build Docker image
259
261
  uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
260
262
  with:
261
263
  context: .
262
- push: true
264
+ push: false
263
265
  load: true
264
266
  tags: ${{ steps.image.outputs.name }}:main
265
267
  cache-from: type=gha
@@ -286,6 +288,9 @@ jobs:
286
288
 
287
289
  test "$(docker inspect --format='{{.State.Health.Status}}' my-app-main-smoke)" = "healthy"
288
290
  curl -fsS http://127.0.0.1:8080/api/health >/dev/null
291
+
292
+ - name: Push Docker image
293
+ run: docker push ${{ steps.image.outputs.name }}:main
289
294
  ```
290
295
 
291
296
  ## Binary release workflow
package/docs/realtime.md CHANGED
@@ -8,8 +8,9 @@ type AppEvent = ResourceRealtimeEvent;
8
8
  const routes = defineRoutes<AppEvent>({
9
9
  "/api/todos": {
10
10
  async POST(req, ctx) {
11
- const todo = await createTodo(req);
12
- ctx.realtime.publishEntityChanged("todos", todo.id);
11
+ const user = ctx.requireUser();
12
+ const todo = await createTodo(user.id, req);
13
+ ctx.userRealtime.publishEntityChanged("todos", todo.id);
13
14
  return jsonResponse(todo);
14
15
  },
15
16
  },
@@ -25,6 +26,8 @@ Standard events have `type`, `resource`, `action`, optional `id`, optional `scop
25
26
  | `publishDeleted("todos", id)` | `{ type: "todos.deleted", resource: "todos", action: "deleted", id }` |
26
27
  | `publishSettingsChanged()` | `{ type: "settings.changed", resource: "settings", action: "changed" }` |
27
28
 
29
+ For records that belong to the signed-in user, use `ctx.userRealtime.publishChanged`, `publishEntityChanged`, `publishDeleted`, or `publishSettingsChanged`. These helpers target only websocket connections authenticated as that user, so other users do not receive entity ids or timing signals.
30
+
28
31
  Frontend code can refresh declaratively:
29
32
 
30
33
  ```tsx
@@ -58,4 +61,6 @@ useRealtimeRefresh({
58
61
  ctx.realtime.publishEntityChanged("todos", todo.id, { scope: workspaceId });
59
62
  ```
60
63
 
64
+ Use scoped/global `ctx.realtime` for non-user scopes only when the server validates access to that scope. For per-user app data, prefer `ctx.userRealtime` instead of trusting a client-provided websocket filter.
65
+
61
66
  `ctx.realtime.publish(customEvent)` and `useRealtime({ onEvent })` still exist as low-level escape hatches. The hook reconnects with exponential backoff and uses the same origin as the page.
package/docs/server.md CHANGED
@@ -5,17 +5,31 @@ Use `createWebAppServer` with `defineRoutes`. Route patterns support exact path
5
5
  ```ts
6
6
  const routes = defineRoutes<AppEvent>({
7
7
  "/api/projects": {
8
- GET: () => jsonResponse(projects),
8
+ auth: "user",
9
+ GET: (_req, ctx) => {
10
+ return jsonResponse(ctx.filterOwned(projects));
11
+ },
9
12
  async POST(req, ctx) {
13
+ const user = ctx.requireUser();
10
14
  const body = await parseJson<{ name: string }>(req);
11
- const project = createProject(body.name);
12
- ctx.realtime.publishEntityChanged("projects", project.id);
15
+ const project = createProject(user.id, body.name);
16
+ ctx.userRealtime.publishEntityChanged("projects", project.id);
13
17
  return jsonResponse(project);
14
18
  },
15
19
  },
16
20
  "/api/projects/:id": {
21
+ auth: "user",
17
22
  scopes: ["projects:write"],
18
- PATCH: updateProject,
23
+ async PATCH(req, ctx) {
24
+ const project = ctx.requireOwned(await findProject(ctx.params.id));
25
+ Object.assign(project, await parseJson<Partial<Project>>(req));
26
+ ctx.userRealtime.publishEntityChanged("projects", project.id);
27
+ return jsonResponse(project);
28
+ },
29
+ },
30
+ "/api/admin/summary": {
31
+ auth: "admin",
32
+ GET: () => jsonResponse(adminSummary()),
19
33
  },
20
34
  });
21
35
  ```
@@ -27,9 +41,26 @@ Route defaults are intentionally secure:
27
41
  | `auth` | `required` | Requires passkey session, API key or device bearer token once auth is configured |
28
42
  | `sameOrigin` | `mutations` | Requires `Origin`/`Referer` for cookie/browser mutations |
29
43
  | `scopes` | `[]` | Checked for API keys and device tokens; `*` grants all |
44
+ | `userParam` | unset | Optional route param name that must match the current user id |
30
45
 
31
46
  Set `auth: "public", sameOrigin: "never"` only for deliberate unauthenticated endpoints such as health probes, webhooks or callback receivers.
32
47
 
48
+ Prefer explicit `auth: "user"`, `auth: "admin"` or `auth: "owner"` on app routes. They enforce the role before the handler runs, including API-key and device bearer requests.
49
+
50
+ Route context is user-aware:
51
+
52
+ | Helper | Meaning |
53
+ | --- | --- |
54
+ | `ctx.requireUser()` | Returns the current user or throws 401 |
55
+ | `ctx.requireAdmin()` | Returns owner/admin users or throws 403 |
56
+ | `ctx.requireOwner()` | Returns the owner or throws 403 |
57
+ | `ctx.assertUser(userId)` | Throws unless the current user id matches |
58
+ | `ctx.filterOwned(records)` | Returns only records whose `userId` is the current user id |
59
+ | `ctx.requireOwned(record)` | Returns a user-owned record or throws 404 for missing/other-user records |
60
+ | `ctx.userRealtime.*` | Publishes realtime events only to sockets authenticated as the current user |
61
+
62
+ Use `ctx.filterOwned(records, getUserId)` and `ctx.requireOwned(record, getUserId)` when app records use a different ownership field. Return 404 for other-user resources so route responses do not reveal whether another user's id exists.
63
+
33
64
  Built-in endpoints include:
34
65
 
35
66
  | Endpoint | Purpose |
@@ -37,8 +68,12 @@ Built-in endpoints include:
37
68
  | `GET /api/health` | Health/version |
38
69
  | `GET /api/config` | Safe framework config for UI |
39
70
  | `/api/passkey-auth/*` | Passkey bootstrap/login/logout/delete |
71
+ | `/api/user-setup*` | One-time invite/reset setup links |
72
+ | `/setup` | Browser setup screen for one-time invite/reset links |
73
+ | `/api/users`, `/api/users/:id/*`, `/api/audit-events` | Admin user management and audit log |
40
74
  | `/api/api-keys` | Browser-managed API key create/list/delete |
41
75
  | `/api/auth/device`, `/api/auth/token`, `/api/auth/refresh`, `/api/auth/revoke` | Device auth and refresh-token flow |
76
+ | `/device` | Browser device-code approval screen |
42
77
  | `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
43
78
  | `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
44
79
  | `/api/server/kill` | Authenticated server shutdown |
package/docs/settings.md CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  Settings is framework-owned so apps stay consistent. It includes:
4
4
 
5
- - Passkey logout/delete
6
- - API key create/list/delete when enabled
7
- - Theme preference: system/light/dark
8
- - Log-level preference unless `{PREFIX}_LOG_LEVEL` is set
9
- - Server kill
5
+ - Account summary and passkey logout/delete
6
+ - API key create/list/delete for the current user when enabled
7
+ - Device-auth sessions for the current user when enabled
8
+ - Theme preference: system/light/dark, stored per user
9
+ - Admin user management
10
+ - Admin log-level preference unless `{PREFIX}_LOG_LEVEL` is set
11
+ - Admin server kill
10
12
  - Version/about
11
13
 
12
14
  Apps can append structured custom sections:
@@ -18,6 +20,7 @@ Apps can append structured custom sections:
18
20
  {
19
21
  id: "sync",
20
22
  title: "Sync",
23
+ scope: "user",
21
24
  rows: [
22
25
  {
23
26
  id: "last-sync",
@@ -28,6 +31,7 @@ Apps can append structured custom sections:
28
31
  {
29
32
  id: "disconnect",
30
33
  title: "Disconnect",
34
+ scope: "admin",
31
35
  description: "Stops syncing this workspace.",
32
36
  danger: true,
33
37
  actions: [{ id: "disconnect", label: "Disconnect", variant: "danger", onAction: disconnect }],
@@ -40,3 +44,5 @@ Apps can append structured custom sections:
40
44
  ```
41
45
 
42
46
  `render` remains available as an escape hatch for custom controls inside a section. Prefer structured `rows` for simple settings because the framework keeps spacing, typography and danger-zone styling consistent.
47
+
48
+ `scope` can be `user`, `admin` or `owner` on both sections and rows. Omitted scope behaves like `user`. Use `admin` for global app/server settings and `user` for preferences or data that belong to the signed-in user.
package/docs/sidebar.md CHANGED
@@ -9,7 +9,7 @@
9
9
  sidebar={{
10
10
  topActions: [
11
11
  { id: "activity", title: "Activity", route: { view: "home" } },
12
- { id: "new", title: "New item", route: { view: "new" } },
12
+ { id: "inbox", title: "Inbox", route: { view: "inbox" } },
13
13
  ],
14
14
  getNodes: ({ search }) => buildSidebarNodes(search),
15
15
  }}
@@ -29,7 +29,7 @@ Sidebar nodes support:
29
29
  | `type` | `section` or `item` |
30
30
  | `route` | Hash route object used by `WebAppRoot` |
31
31
  | `children` | Collapsible nesting |
32
- | `action` | Per-section/item action such as `New` |
32
+ | `action` | Single inline per-section/item action; use sparingly and prefer `actions` menus when possible |
33
33
  | `actions` | Context menu items shown on sidebar right-click |
34
34
  | `pinnable` | Enables framework Pin/Unpin actions |
35
35
  | `pinId` | Stable pin identity when it should differ from `id` |
@@ -38,7 +38,7 @@ Sidebar nodes support:
38
38
 
39
39
  Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered.
40
40
 
41
- Use `actions` when an entity needs a context menu in the sidebar. The same `ActionMenuItem[]` should also be returned from `header.getActions` for that entity route so right-click actions and the title-bar three-line menu stay consistent:
41
+ Use `actions` when an entity needs commands in the sidebar. The same `ActionMenuItem[]` should also be returned from `header.getActions` for that entity route so right-click actions and the title-bar three-line menu stay consistent:
42
42
 
43
43
  ```tsx
44
44
  const actions = projectActions(project);
@@ -19,7 +19,7 @@ Use these first:
19
19
  | `Toolbar` | Page title/actions inside main content |
20
20
  | `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
21
21
  | `ActionMenu` | Three-line action menu for secondary surfaces; entity-level menus should usually be exposed through `WebAppRoot.header.getActions` so they render in the fixed title bar |
22
- | `Button` / `IconButton` | Actions |
22
+ | `Button` / `IconButton` | Form submission and true inline controls; prefer action menus for entity/app commands |
23
23
  | `Badge` | Status/count labels |
24
24
  | `EntityHeader` | Main-content entity title/description/actions |
25
25
  | `DataList` / `DataListRow` | Lists with title, description, metadata, badge and actions |
@@ -43,13 +43,17 @@ Generated screenshots live in `artifacts/screenshots`:
43
43
  | `notes-desktop-light.png` | Realistic app, desktop shell |
44
44
  | `notes-settings-desktop-light.png` | Framework settings |
45
45
  | `notes-mobile-light.png` | Mobile shell |
46
+ | `notes-mobile-sidebar-light.png` | Mobile drawer/sidebar |
46
47
  | `notes-desktop-dark.png` | Dark mode |
47
48
  | `kitchen-desktop-light.png` | Kitchen sink desktop |
48
49
  | `kitchen-mobile-light.png` | Kitchen sink mobile |
49
50
  | `kitchen-sidebar-collapsed-light.png` | Collapsed sidebar title bar |
50
51
  | `kitchen-context-menu-light.png` | Sidebar context menu |
51
52
  | `kitchen-dialog-dark.png` | Confirm dialog overlay |
53
+ | `kitchen-device-light.png` | Device auth approval flow |
52
54
 
53
55
  Use `bun run screenshots` to regenerate these captures with Playwright. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
54
56
 
55
57
  Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles.
58
+
59
+ Prefer the framework action-menu pattern for app commands. Actions such as New task, New note, New project, archive, delete, or state transitions should live in `SidebarNode.actions` and/or `WebAppRoot.header.getActions` so they appear behind the three-line title-bar/item action menus. Use discrete buttons mainly for form submission or inline controls that cannot reasonably live in an action menu.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,12 +2,60 @@ export type LogLevelName = "trace" | "debug" | "info" | "warn" | "error";
2
2
 
3
3
  export type ThemePreference = "system" | "light" | "dark";
4
4
 
5
+ export type WebAppUserRole = "owner" | "admin" | "user";
6
+
7
+ export interface CurrentUser {
8
+ id: string;
9
+ username: string;
10
+ role: WebAppUserRole;
11
+ isOwner: boolean;
12
+ isAdmin: boolean;
13
+ }
14
+
15
+ export interface WebAppUserSummary {
16
+ id: string;
17
+ username: string;
18
+ role: WebAppUserRole;
19
+ passkeyConfigured: boolean;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ lastLoginAt?: string;
23
+ }
24
+
25
+ export interface UserSetupLinkResponse {
26
+ url: string;
27
+ expiresAt: string;
28
+ }
29
+
30
+ export interface CreatedUserResponse {
31
+ user: WebAppUserSummary;
32
+ setupLink: UserSetupLinkResponse;
33
+ }
34
+
35
+ export interface UserSetupDetails {
36
+ username: string;
37
+ role: WebAppUserRole;
38
+ kind: "invite" | "reset";
39
+ expiresAt: string;
40
+ }
41
+
42
+ export interface AuditEventSummary {
43
+ id: string;
44
+ eventType: string;
45
+ actorUserId?: string;
46
+ targetUserId?: string;
47
+ metadata: Record<string, unknown>;
48
+ createdAt: string;
49
+ }
50
+
5
51
  export interface PasskeyAuthStatusResponse {
6
52
  enabled: boolean;
7
53
  passkeyConfigured: boolean;
8
54
  passkeyDisabled: boolean;
9
55
  passkeyRequired: boolean;
10
56
  authenticated: boolean;
57
+ bootstrapRequired: boolean;
58
+ ownerPasskeySetupRequired: boolean;
11
59
  }
12
60
 
13
61
  export interface ApiKeySummary {
@@ -66,7 +114,12 @@ export interface AuthSessionSummary {
66
114
  export interface WebAppConfigResponse {
67
115
  appName: string;
68
116
  version: string;
117
+ currentUser?: CurrentUser;
69
118
  passkeyAuth: PasskeyAuthStatusResponse;
119
+ userManagement: {
120
+ enabled: boolean;
121
+ canManageUsers: boolean;
122
+ };
70
123
  logLevel: {
71
124
  level: LogLevelName;
72
125
  fromEnv: boolean;
@@ -1,4 +1,4 @@
1
- import type { ApiKeySummary, CreatedApiKeyResponse } from "../../contracts";
1
+ import type { ApiKeySummary, CreatedApiKeyResponse, CurrentUser } from "../../contracts";
2
2
  import type { WebAppStore } from "./store";
3
3
  import { nowIso, randomToken, sha256, secureEqual, isExpired } from "./crypto";
4
4
  import { AuthError } from "./types";
@@ -15,15 +15,16 @@ function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummar
15
15
  };
16
16
  }
17
17
 
18
- export function listApiKeys(store: WebAppStore): ApiKeySummary[] {
19
- return store.listApiKeys().map(summarize);
18
+ export function listApiKeys(store: WebAppStore, userId: string): ApiKeySummary[] {
19
+ return store.listApiKeys(userId).map(summarize);
20
20
  }
21
21
 
22
- export function createApiKey(store: WebAppStore, input: { name?: string; scopes?: string[]; prefix?: string; expiresAt?: string }): CreatedApiKeyResponse {
22
+ export function createApiKey(store: WebAppStore, user: CurrentUser, input: { name?: string; scopes?: string[]; prefix?: string; expiresAt?: string }): CreatedApiKeyResponse {
23
23
  const prefix = input.prefix ?? "wapp";
24
24
  const token = `${prefix}_${randomToken(32)}`;
25
25
  const record = {
26
26
  id: crypto.randomUUID(),
27
+ userId: user.id,
27
28
  name: input.name?.trim() || "API key",
28
29
  prefix,
29
30
  tokenHash: sha256(token),
@@ -35,18 +36,22 @@ export function createApiKey(store: WebAppStore, input: { name?: string; scopes?
35
36
  return { key: summarize(record), token };
36
37
  }
37
38
 
38
- export function deleteApiKey(store: WebAppStore, id: string): boolean {
39
- return store.deleteApiKey(id);
39
+ export function deleteApiKey(store: WebAppStore, userId: string, id: string): boolean {
40
+ return store.deleteApiKey(id, userId);
40
41
  }
41
42
 
42
- export function authenticateApiKey(store: WebAppStore, token: string): { apiKeyId: string; scopes: string[] } | undefined {
43
+ export function authenticateApiKey(store: WebAppStore, token: string): { user: CurrentUser; apiKeyId: string; scopes: string[] } | undefined {
43
44
  const tokenHash = sha256(token);
44
45
  const record = store.getApiKeyByHash(tokenHash);
45
46
  if (!record || !secureEqual(record.tokenHash, tokenHash) || (record.expiresAt && isExpired(record.expiresAt))) {
46
47
  return undefined;
47
48
  }
49
+ const user = store.getUserById(record.userId);
50
+ if (!user) {
51
+ return undefined;
52
+ }
48
53
  store.touchApiKey(record.id, nowIso());
49
- return { apiKeyId: record.id, scopes: record.scopes };
54
+ return { user: { id: user.id, username: user.username, role: user.role, isOwner: user.role === "owner", isAdmin: user.role === "owner" || user.role === "admin" }, apiKeyId: record.id, scopes: record.scopes };
50
55
  }
51
56
 
52
57
  export function assertScopes(actual: string[], required: string[]): void {