@pablozaiden/webapp 0.1.0 → 0.2.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 +4 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +47 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +89 -4
- package/docs/settings.md +15 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +8 -2
- package/package.json +2 -1
- package/src/cli/api-command.ts +119 -0
- package/src/cli/credentials.ts +67 -0
- package/src/cli/device-auth.ts +179 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/runtime.ts +51 -0
- package/src/contracts/index.ts +53 -0
- package/src/server/auth/api-keys.ts +13 -8
- package/src/server/auth/device-auth.ts +34 -11
- package/src/server/auth/passkeys.ts +191 -73
- package/src/server/auth/sqlite-store.ts +394 -44
- package/src/server/auth/store.ts +57 -13
- package/src/server/auth/types.ts +7 -3
- package/src/server/auth/users.ts +83 -0
- package/src/server/create-web-app-server.ts +451 -54
- package/src/server/index.ts +1 -0
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +35 -4
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +336 -30
- package/src/web/api-client.ts +64 -0
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +2 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +104 -2
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
|
|
7
|
+
Use one of the examples during framework development:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
bun install
|
|
@@ -19,8 +19,9 @@ 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
|
+
| `@pablozaiden/webapp/cli` | One-binary command helpers, device-auth credentials and generic API CLI caller |
|
|
24
25
|
| `@pablozaiden/webapp/build` | Bun single-binary compile helper |
|
|
25
26
|
|
|
26
27
|
See `docs/getting-started.md` for the minimum app shape and `examples/notes-todo` for a realistic app. Use `docs/github-actions.md` when adding CI, Docker and release workflows to an app built with the framework. Use `bun run screenshots` for reproducible manual visual captures. Release/publishing details for this package are in `docs/release.md`.
|
package/docs/auth-validation.md
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
# Auth validation checklist
|
|
2
2
|
|
|
3
|
-
Run this checklist before
|
|
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
|
|
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
|
|
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
|
-
##
|
|
5
|
+
## Users and passkeys
|
|
6
6
|
|
|
7
|
-
Passkeys
|
|
7
|
+
Passkeys are multi-user. The first run creates the immutable owner user and that user's passkey.
|
|
8
8
|
|
|
9
|
-
1. If no
|
|
10
|
-
2.
|
|
11
|
-
3.
|
|
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
|
-
|
|
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
|
|
package/docs/cli.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# CLI helpers
|
|
2
|
+
|
|
3
|
+
Apps built with the framework should be one app and one binary. The binary can expose subcommands such as `serve`, `version`, `update`, app-specific commands, and optional framework-backed commands like `auth`, `status`, `api` and `schema`.
|
|
4
|
+
|
|
5
|
+
Use `@pablozaiden/webapp/cli` for small composable helpers, not a mandatory CLI framework:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { dispatchCliCommand, printCliResult } from "@pablozaiden/webapp/cli";
|
|
9
|
+
|
|
10
|
+
const result = await dispatchCliCommand({
|
|
11
|
+
args: Bun.argv.slice(2),
|
|
12
|
+
help: "Usage: my-app <serve|version|notify>",
|
|
13
|
+
defaultCommand: "serve",
|
|
14
|
+
commands: {
|
|
15
|
+
serve: async () => {
|
|
16
|
+
app.start();
|
|
17
|
+
await new Promise(() => undefined);
|
|
18
|
+
return { exitCode: 0 };
|
|
19
|
+
},
|
|
20
|
+
version: () => ({ exitCode: 0, output: version }),
|
|
21
|
+
notify: (args) => runNotifyCommand(args),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
process.exitCode = printCliResult(result);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Device-auth CLI helpers can be composed into apps that need authenticated CLI calls. Public-token commands, such as webhook notification commands, can stay app-owned and do not need framework auth.
|
|
29
|
+
|
|
30
|
+
Route metadata can power generic API commands:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { runApiCliCommand } from "@pablozaiden/webapp/cli";
|
|
34
|
+
import { createRouteCatalog } from "@pablozaiden/webapp/server";
|
|
35
|
+
|
|
36
|
+
const catalog = createRouteCatalog(routes);
|
|
37
|
+
const result = await runApiCliCommand({
|
|
38
|
+
catalog,
|
|
39
|
+
args,
|
|
40
|
+
baseUrl: "http://localhost:3000",
|
|
41
|
+
credentials,
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`runApiCliCommand` can list endpoints, print schema metadata, call endpoints with `--method` and `--payload`, attach bearer credentials, and refresh once on `401`.
|
package/docs/deployment.md
CHANGED
|
@@ -22,8 +22,11 @@ Run the binary with the same CLI contract:
|
|
|
22
22
|
```bash
|
|
23
23
|
MY_APP_PORT=3300 ./dist/my-app serve
|
|
24
24
|
./dist/my-app version
|
|
25
|
+
./dist/my-app api items
|
|
25
26
|
```
|
|
26
27
|
|
|
28
|
+
Keep server and CLI modes in the same binary. App-specific commands and framework-backed commands should be subcommands of that binary rather than separate executables.
|
|
29
|
+
|
|
27
30
|
## Docker
|
|
28
31
|
|
|
29
32
|
Examples include Dockerfiles that copy a Bun-compiled Linux binary into a runtime image. Build the binary for the container architecture first, then build the image:
|
package/docs/getting-started.md
CHANGED
|
@@ -4,11 +4,25 @@ 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
|
-
|
|
13
|
+
auth: "user",
|
|
14
|
+
description: "List or create items.",
|
|
15
|
+
cliPath: "items",
|
|
16
|
+
tags: ["items"],
|
|
17
|
+
GET: (_req, ctx) => jsonResponse(ctx.filterOwned(items)),
|
|
18
|
+
async POST(req, ctx) {
|
|
19
|
+
const user = ctx.requireUser();
|
|
20
|
+
const body = await parseJson<{ title: string }>(req);
|
|
21
|
+
const item = { id: crypto.randomUUID(), userId: user.id, title: body.title };
|
|
22
|
+
items.push(item);
|
|
23
|
+
ctx.userRealtime.publishEntityChanged("items", item.id);
|
|
24
|
+
return jsonResponse(item);
|
|
25
|
+
},
|
|
12
26
|
},
|
|
13
27
|
"/api/webhooks/:source/:token": {
|
|
14
28
|
auth: "public",
|
|
@@ -29,13 +43,39 @@ const app = createWebAppServer({
|
|
|
29
43
|
await app.runFromCli();
|
|
30
44
|
```
|
|
31
45
|
|
|
32
|
-
|
|
46
|
+
Apps should stay one app and one binary. Use subcommands for different modes:
|
|
33
47
|
|
|
34
|
-
```
|
|
35
|
-
|
|
48
|
+
```bash
|
|
49
|
+
my-app serve
|
|
50
|
+
my-app version
|
|
51
|
+
my-app api items
|
|
52
|
+
my-app notify --message "optional app-owned command"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See `docs/cli.md` for framework CLI helpers and generic API command support.
|
|
56
|
+
|
|
57
|
+
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:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { WebAppRoot, renderWebApp } from "@pablozaiden/webapp/web";
|
|
36
61
|
import "@pablozaiden/webapp/web/styles.css";
|
|
62
|
+
|
|
63
|
+
function Home() {
|
|
64
|
+
return <main className="wapp-main-content">Hello</main>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
renderWebApp(
|
|
68
|
+
<WebAppRoot
|
|
69
|
+
appName="My App"
|
|
70
|
+
homeRoute={{ view: "home" }}
|
|
71
|
+
sidebar={{ getNodes: () => [] }}
|
|
72
|
+
routes={{ home: <Home /> }}
|
|
73
|
+
/>,
|
|
74
|
+
);
|
|
37
75
|
```
|
|
38
76
|
|
|
77
|
+
`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.
|
|
78
|
+
|
|
39
79
|
Recommended dev script:
|
|
40
80
|
|
|
41
81
|
```json
|
|
@@ -50,11 +90,11 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
|
|
|
50
90
|
|
|
51
91
|
| Variable | Default | Description |
|
|
52
92
|
| --- | --- | --- |
|
|
53
|
-
| `{PREFIX}_HOST` | `
|
|
93
|
+
| `{PREFIX}_HOST` | `localhost` | Bind host |
|
|
54
94
|
| `{PREFIX}_PORT` | `3000` | Bind port |
|
|
55
95
|
| `{PREFIX}_DATA_DIR` | `./data` | SQLite persistence directory |
|
|
56
96
|
| `{PREFIX}_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`; locks settings log-level control when set |
|
|
57
|
-
| `{PREFIX}_DISABLE_PASSKEY` | unset |
|
|
97
|
+
| `{PREFIX}_DISABLE_PASSKEY` | unset | Emergency bypass that logs in as the existing owner; it does not create users |
|
|
58
98
|
| `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
|
|
59
99
|
| `{PREFIX}_PUBLIC_BASE_URL` | request origin | External URL for device auth links |
|
|
60
100
|
| `{PREFIX}_AUTH_ISSUER` | `urn:{prefix}:webapp` | JWT issuer override |
|
package/docs/github-actions.md
CHANGED
|
@@ -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
|
|
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
|
|
260
|
+
- name: Build Docker image
|
|
259
261
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
|
260
262
|
with:
|
|
261
263
|
context: .
|
|
262
|
-
push:
|
|
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
|
|
12
|
-
|
|
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,34 @@ Use `createWebAppServer` with `defineRoutes`. Route patterns support exact path
|
|
|
5
5
|
```ts
|
|
6
6
|
const routes = defineRoutes<AppEvent>({
|
|
7
7
|
"/api/projects": {
|
|
8
|
-
|
|
8
|
+
auth: "user",
|
|
9
|
+
description: "List or create projects.",
|
|
10
|
+
cliPath: "projects",
|
|
11
|
+
tags: ["projects"],
|
|
12
|
+
GET: (_req, ctx) => {
|
|
13
|
+
return jsonResponse(ctx.filterOwned(projects));
|
|
14
|
+
},
|
|
9
15
|
async POST(req, ctx) {
|
|
16
|
+
const user = ctx.requireUser();
|
|
10
17
|
const body = await parseJson<{ name: string }>(req);
|
|
11
|
-
const project = createProject(body.name);
|
|
12
|
-
ctx.
|
|
18
|
+
const project = createProject(user.id, body.name);
|
|
19
|
+
ctx.userRealtime.publishEntityChanged("projects", project.id);
|
|
13
20
|
return jsonResponse(project);
|
|
14
21
|
},
|
|
15
22
|
},
|
|
16
23
|
"/api/projects/:id": {
|
|
24
|
+
auth: "user",
|
|
17
25
|
scopes: ["projects:write"],
|
|
18
|
-
PATCH
|
|
26
|
+
async PATCH(req, ctx) {
|
|
27
|
+
const project = ctx.requireOwned(await findProject(ctx.params.id));
|
|
28
|
+
Object.assign(project, await parseJson<Partial<Project>>(req));
|
|
29
|
+
ctx.userRealtime.publishEntityChanged("projects", project.id);
|
|
30
|
+
return jsonResponse(project);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
"/api/admin/summary": {
|
|
34
|
+
auth: "admin",
|
|
35
|
+
GET: () => jsonResponse(adminSummary()),
|
|
19
36
|
},
|
|
20
37
|
});
|
|
21
38
|
```
|
|
@@ -27,9 +44,38 @@ Route defaults are intentionally secure:
|
|
|
27
44
|
| `auth` | `required` | Requires passkey session, API key or device bearer token once auth is configured |
|
|
28
45
|
| `sameOrigin` | `mutations` | Requires `Origin`/`Referer` for cookie/browser mutations |
|
|
29
46
|
| `scopes` | `[]` | Checked for API keys and device tokens; `*` grants all |
|
|
47
|
+
| `userParam` | unset | Optional route param name that must match the current user id |
|
|
30
48
|
|
|
31
49
|
Set `auth: "public", sameOrigin: "never"` only for deliberate unauthenticated endpoints such as health probes, webhooks or callback receivers.
|
|
32
50
|
|
|
51
|
+
Route definitions can include optional metadata. This keeps the API route table as the single source of truth for handlers, CLI endpoint listing, schema output and docs:
|
|
52
|
+
|
|
53
|
+
| Field | Meaning |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `description` | Human-readable route description |
|
|
56
|
+
| `cliPath` | CLI-friendly path; defaults to the API path without `/api/` |
|
|
57
|
+
| `tags` | Grouping labels for docs/CLI |
|
|
58
|
+
| `requestSchema`, `querySchema`, `responseSchema` | Optional schema objects for CLI/docs |
|
|
59
|
+
| `catalog: false` | Exclude a route from generated catalogs |
|
|
60
|
+
|
|
61
|
+
Use `createRouteCatalog(routes)` and `findRouteCatalogEntry(catalog, input)` to power app CLI commands without maintaining a second route catalog.
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
Route context is user-aware:
|
|
66
|
+
|
|
67
|
+
| Helper | Meaning |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `ctx.requireUser()` | Returns the current user or throws 401 |
|
|
70
|
+
| `ctx.requireAdmin()` | Returns owner/admin users or throws 403 |
|
|
71
|
+
| `ctx.requireOwner()` | Returns the owner or throws 403 |
|
|
72
|
+
| `ctx.assertUser(userId)` | Throws unless the current user id matches |
|
|
73
|
+
| `ctx.filterOwned(records)` | Returns only records whose `userId` is the current user id |
|
|
74
|
+
| `ctx.requireOwned(record)` | Returns a user-owned record or throws 404 for missing/other-user records |
|
|
75
|
+
| `ctx.userRealtime.*` | Publishes realtime events only to sockets authenticated as the current user |
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
33
79
|
Built-in endpoints include:
|
|
34
80
|
|
|
35
81
|
| Endpoint | Purpose |
|
|
@@ -37,9 +83,48 @@ Built-in endpoints include:
|
|
|
37
83
|
| `GET /api/health` | Health/version |
|
|
38
84
|
| `GET /api/config` | Safe framework config for UI |
|
|
39
85
|
| `/api/passkey-auth/*` | Passkey bootstrap/login/logout/delete |
|
|
86
|
+
| `/api/user-setup*` | One-time invite/reset setup links |
|
|
87
|
+
| `/setup` | Browser setup screen for one-time invite/reset links |
|
|
88
|
+
| `/api/users`, `/api/users/:id/*`, `/api/audit-events` | Admin user management and audit log |
|
|
40
89
|
| `/api/api-keys` | Browser-managed API key create/list/delete |
|
|
41
90
|
| `/api/auth/device`, `/api/auth/token`, `/api/auth/refresh`, `/api/auth/revoke` | Device auth and refresh-token flow |
|
|
91
|
+
| `/device` | Browser device-code approval screen |
|
|
42
92
|
| `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
|
|
43
93
|
| `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
|
|
44
94
|
| `/api/server/kill` | Authenticated server shutdown |
|
|
45
95
|
| `/api/ws` | Realtime websocket by default |
|
|
96
|
+
|
|
97
|
+
## Public/static routes
|
|
98
|
+
|
|
99
|
+
Declare public non-API assets explicitly with `publicRoutes`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
createWebAppServer({
|
|
103
|
+
// ...
|
|
104
|
+
publicRoutes: {
|
|
105
|
+
"/manifest.webmanifest": {
|
|
106
|
+
headers: { "content-type": "application/manifest+json" },
|
|
107
|
+
GET: manifestJson,
|
|
108
|
+
},
|
|
109
|
+
"/service-worker": serviceWorker,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Only declared public routes are served this way. Unknown `/api/*` paths still return `404`, while normal frontend paths still return the React index.
|
|
115
|
+
|
|
116
|
+
## App-owned websocket upgrades
|
|
117
|
+
|
|
118
|
+
Normal app state should use framework realtime. For raw transports such as terminals, VNC, or port-forward proxies, route handlers may call `ctx.server?.upgrade(...)` and return `undefined`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
"/api/terminal": {
|
|
122
|
+
auth: "user",
|
|
123
|
+
sameOrigin: "always",
|
|
124
|
+
GET: (req, ctx) => ctx.server?.upgrade(req, {
|
|
125
|
+
data: { webappSocketHandler: "terminal", sessionId: ctx.params.id },
|
|
126
|
+
}) ? undefined : new Response("Upgrade failed", { status: 400 }),
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Register matching handlers with `websockets`. Framework auth and same-origin checks run before the upgrade route handler.
|
package/docs/settings.md
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Settings is framework-owned so apps stay consistent. It includes:
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
- API key create/list/delete when enabled
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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
|
|
|
14
|
+
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
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
12
18
|
Apps can append structured custom sections:
|
|
13
19
|
|
|
14
20
|
```tsx
|
|
@@ -18,6 +24,7 @@ Apps can append structured custom sections:
|
|
|
18
24
|
{
|
|
19
25
|
id: "sync",
|
|
20
26
|
title: "Sync",
|
|
27
|
+
scope: "user",
|
|
21
28
|
rows: [
|
|
22
29
|
{
|
|
23
30
|
id: "last-sync",
|
|
@@ -28,6 +35,7 @@ Apps can append structured custom sections:
|
|
|
28
35
|
{
|
|
29
36
|
id: "disconnect",
|
|
30
37
|
title: "Disconnect",
|
|
38
|
+
scope: "admin",
|
|
31
39
|
description: "Stops syncing this workspace.",
|
|
32
40
|
danger: true,
|
|
33
41
|
actions: [{ id: "disconnect", label: "Disconnect", variant: "danger", onAction: disconnect }],
|
|
@@ -40,3 +48,5 @@ Apps can append structured custom sections:
|
|
|
40
48
|
```
|
|
41
49
|
|
|
42
50
|
`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.
|
|
51
|
+
|
|
52
|
+
`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: "
|
|
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` |
|
|
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
|
|
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);
|
package/docs/ui-guidelines.md
CHANGED
|
@@ -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` |
|
|
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,19 @@ 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
|
-
Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles.
|
|
57
|
+
Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles. When screenshots are captured to validate a visual change, review them against the specific goal; capturing files without checking the result is not validation.
|
|
58
|
+
|
|
59
|
+
All destructive delete actions must use the framework `ConfirmDialog` before calling the delete endpoint. Server lifecycle actions such as kill/reboot must also require confirmation and show the standard 15-second shutdown countdown progress bar after the request succeeds.
|
|
60
|
+
|
|
61
|
+
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./web": "./src/web/index.ts",
|
|
27
27
|
"./web/styles.css": "./src/web/styles.css",
|
|
28
28
|
"./contracts": "./src/contracts/index.ts",
|
|
29
|
+
"./cli": "./src/cli/index.ts",
|
|
29
30
|
"./build": "./src/build/index.ts"
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|