@pablozaiden/webapp 0.0.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/LICENSE +21 -0
- package/README.md +26 -0
- package/docs/auth-validation.md +34 -0
- package/docs/auth.md +35 -0
- package/docs/deployment.md +49 -0
- package/docs/getting-started.md +62 -0
- package/docs/github-actions.md +421 -0
- package/docs/realtime.md +61 -0
- package/docs/release.md +64 -0
- package/docs/server.md +45 -0
- package/docs/settings.md +42 -0
- package/docs/sidebar.md +80 -0
- package/docs/ui-guidelines.md +55 -0
- package/package.json +60 -0
- package/src/build/build-binary.ts +42 -0
- package/src/build/index.ts +1 -0
- package/src/contracts/index.ts +86 -0
- package/src/server/auth/api-keys.ts +61 -0
- package/src/server/auth/crypto.ts +34 -0
- package/src/server/auth/device-auth.ts +324 -0
- package/src/server/auth/passkeys.ts +280 -0
- package/src/server/auth/request-origin.ts +33 -0
- package/src/server/auth/sqlite-store.ts +301 -0
- package/src/server/auth/store.ts +91 -0
- package/src/server/auth/types.ts +25 -0
- package/src/server/create-web-app-server.ts +447 -0
- package/src/server/index.ts +7 -0
- package/src/server/logger.ts +44 -0
- package/src/server/realtime/bus.ts +104 -0
- package/src/server/responses.ts +54 -0
- package/src/server/routes.ts +67 -0
- package/src/server/runtime-config.ts +101 -0
- package/src/server/same-origin.ts +35 -0
- package/src/types.d.ts +11 -0
- package/src/web/WebAppRoot.tsx +706 -0
- package/src/web/components/index.tsx +471 -0
- package/src/web/index.ts +5 -0
- package/src/web/realtime/useRealtime.ts +189 -0
- package/src/web/sidebar/types.ts +45 -0
- package/src/web/styles.css +1295 -0
package/docs/realtime.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Realtime
|
|
2
|
+
|
|
3
|
+
Every server has a typed `RealtimeBus`. Apps should prefer the framework event convention:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
type AppEvent = ResourceRealtimeEvent;
|
|
7
|
+
|
|
8
|
+
const routes = defineRoutes<AppEvent>({
|
|
9
|
+
"/api/todos": {
|
|
10
|
+
async POST(req, ctx) {
|
|
11
|
+
const todo = await createTodo(req);
|
|
12
|
+
ctx.realtime.publishEntityChanged("todos", todo.id);
|
|
13
|
+
return jsonResponse(todo);
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Standard events have `type`, `resource`, `action`, optional `id`, optional `scope` and optional `payload`:
|
|
20
|
+
|
|
21
|
+
| Helper | Event |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `publishChanged("todos")` | `{ type: "todos.changed", resource: "todos", action: "changed" }` |
|
|
24
|
+
| `publishEntityChanged("todos", id)` | `{ type: "todos.changed", resource: "todos", action: "changed", id }` |
|
|
25
|
+
| `publishDeleted("todos", id)` | `{ type: "todos.deleted", resource: "todos", action: "deleted", id }` |
|
|
26
|
+
| `publishSettingsChanged()` | `{ type: "settings.changed", resource: "settings", action: "changed" }` |
|
|
27
|
+
|
|
28
|
+
Frontend code can refresh declaratively:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
useRealtimeRefresh({
|
|
32
|
+
resources: ["todos", "notes"],
|
|
33
|
+
refresh: () => refreshData(),
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or combine initial loading and realtime refresh:
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
const todos = useLiveQuery({
|
|
41
|
+
load: () => api<Todo[]>("/api/todos"),
|
|
42
|
+
realtime: { resources: ["todos"] },
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For targeted delivery, clients subscribe with filters and the server publishes to matching targets:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
useRealtimeRefresh({
|
|
50
|
+
filters: { resource: "todos", scope: workspaceId },
|
|
51
|
+
resources: ["todos"],
|
|
52
|
+
scopes: [workspaceId],
|
|
53
|
+
refresh,
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
ctx.realtime.publishEntityChanged("todos", todo.id, { scope: workspaceId });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`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/release.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Release and publishing
|
|
2
|
+
|
|
3
|
+
## Pull requests
|
|
4
|
+
|
|
5
|
+
`PR Checks` runs on every pull request:
|
|
6
|
+
|
|
7
|
+
1. Install dependencies with `bun install --frozen-lockfile`.
|
|
8
|
+
2. Run `bun run tsc`.
|
|
9
|
+
3. Run `bun test`.
|
|
10
|
+
4. Build both example apps.
|
|
11
|
+
5. Start the compiled example binaries and smoke-test `/api/health` plus one app endpoint.
|
|
12
|
+
|
|
13
|
+
## Main branch
|
|
14
|
+
|
|
15
|
+
`Main Docker Smoke` runs after merges to `main`:
|
|
16
|
+
|
|
17
|
+
1. Build Linux x64 binaries for both examples.
|
|
18
|
+
2. Build Docker images from `examples/*/Dockerfile`.
|
|
19
|
+
3. Run both containers.
|
|
20
|
+
4. Smoke-test `/api/health` plus one app endpoint through the published container ports.
|
|
21
|
+
|
|
22
|
+
## NPM releases
|
|
23
|
+
|
|
24
|
+
`Release NPM Package` publishes `@pablozaiden/webapp` when a GitHub release is published. It also supports manual dispatch with a tag input, publishing with npm tag `unstable`.
|
|
25
|
+
|
|
26
|
+
The workflow:
|
|
27
|
+
|
|
28
|
+
1. Reads the version from the release tag, e.g. `v0.1.0` -> `0.1.0`.
|
|
29
|
+
2. Checks out that tag.
|
|
30
|
+
3. Updates `package.json` version in the workflow workspace.
|
|
31
|
+
4. Runs install, tests and build.
|
|
32
|
+
5. Publishes with npm provenance and public access.
|
|
33
|
+
|
|
34
|
+
## First manual publish / trusted publishing setup
|
|
35
|
+
|
|
36
|
+
For the first publish, npm may require a local manual publish before trusted publishing can be authorized for this GitHub repository.
|
|
37
|
+
|
|
38
|
+
1. Log in locally:
|
|
39
|
+
```bash
|
|
40
|
+
npm login
|
|
41
|
+
npm whoami
|
|
42
|
+
```
|
|
43
|
+
2. From a clean checkout, set the first version only in the local working tree:
|
|
44
|
+
```bash
|
|
45
|
+
npm version 0.1.0 --no-git-tag-version
|
|
46
|
+
```
|
|
47
|
+
3. Inspect the package contents:
|
|
48
|
+
```bash
|
|
49
|
+
npm pack --dry-run
|
|
50
|
+
```
|
|
51
|
+
4. Publish the scoped package publicly:
|
|
52
|
+
```bash
|
|
53
|
+
npm publish --access public
|
|
54
|
+
```
|
|
55
|
+
5. Revert the local version edit if it should not be committed:
|
|
56
|
+
```bash
|
|
57
|
+
git checkout -- package.json
|
|
58
|
+
```
|
|
59
|
+
6. In npm package settings for `@pablozaiden/webapp`, configure trusted publishing for GitHub Actions:
|
|
60
|
+
- Owner/repository: `PabloZaiden/webapp`
|
|
61
|
+
- Workflow: `release-npm-package.yml`
|
|
62
|
+
- Environment: leave empty unless an environment is added later
|
|
63
|
+
7. After that, publish future versions by creating and publishing GitHub releases with tags like `v0.1.1`.
|
|
64
|
+
|
package/docs/server.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Server API
|
|
2
|
+
|
|
3
|
+
Use `createWebAppServer` with `defineRoutes`. Route patterns support exact path segments and `:params`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const routes = defineRoutes<AppEvent>({
|
|
7
|
+
"/api/projects": {
|
|
8
|
+
GET: () => jsonResponse(projects),
|
|
9
|
+
async POST(req, ctx) {
|
|
10
|
+
const body = await parseJson<{ name: string }>(req);
|
|
11
|
+
const project = createProject(body.name);
|
|
12
|
+
ctx.realtime.publishEntityChanged("projects", project.id);
|
|
13
|
+
return jsonResponse(project);
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
"/api/projects/:id": {
|
|
17
|
+
scopes: ["projects:write"],
|
|
18
|
+
PATCH: updateProject,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Route defaults are intentionally secure:
|
|
24
|
+
|
|
25
|
+
| Setting | Default | Meaning |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `auth` | `required` | Requires passkey session, API key or device bearer token once auth is configured |
|
|
28
|
+
| `sameOrigin` | `mutations` | Requires `Origin`/`Referer` for cookie/browser mutations |
|
|
29
|
+
| `scopes` | `[]` | Checked for API keys and device tokens; `*` grants all |
|
|
30
|
+
|
|
31
|
+
Set `auth: "public", sameOrigin: "never"` only for deliberate unauthenticated endpoints such as health probes, webhooks or callback receivers.
|
|
32
|
+
|
|
33
|
+
Built-in endpoints include:
|
|
34
|
+
|
|
35
|
+
| Endpoint | Purpose |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `GET /api/health` | Health/version |
|
|
38
|
+
| `GET /api/config` | Safe framework config for UI |
|
|
39
|
+
| `/api/passkey-auth/*` | Passkey bootstrap/login/logout/delete |
|
|
40
|
+
| `/api/api-keys` | Browser-managed API key create/list/delete |
|
|
41
|
+
| `/api/auth/device`, `/api/auth/token`, `/api/auth/refresh`, `/api/auth/revoke` | Device auth and refresh-token flow |
|
|
42
|
+
| `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
|
|
43
|
+
| `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
|
|
44
|
+
| `/api/server/kill` | Authenticated server shutdown |
|
|
45
|
+
| `/api/ws` | Realtime websocket by default |
|
package/docs/settings.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Settings
|
|
2
|
+
|
|
3
|
+
Settings is framework-owned so apps stay consistent. It includes:
|
|
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
|
|
10
|
+
- Version/about
|
|
11
|
+
|
|
12
|
+
Apps can append structured custom sections:
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
<WebAppRoot
|
|
16
|
+
settings={{
|
|
17
|
+
sections: [
|
|
18
|
+
{
|
|
19
|
+
id: "sync",
|
|
20
|
+
title: "Sync",
|
|
21
|
+
rows: [
|
|
22
|
+
{
|
|
23
|
+
id: "last-sync",
|
|
24
|
+
title: "Last sync",
|
|
25
|
+
description: lastSync,
|
|
26
|
+
actions: [{ id: "sync-now", label: "Sync now", onAction: syncNow }],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "disconnect",
|
|
30
|
+
title: "Disconnect",
|
|
31
|
+
description: "Stops syncing this workspace.",
|
|
32
|
+
danger: true,
|
|
33
|
+
actions: [{ id: "disconnect", label: "Disconnect", variant: "danger", onAction: disconnect }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}}
|
|
39
|
+
/>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`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.
|
package/docs/sidebar.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Sidebar and routing
|
|
2
|
+
|
|
3
|
+
`WebAppRoot` owns the app shell: fixed sidebar title row, top action buttons, search, scrollable tree, version footer, main title bar and mobile drawer behavior.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<WebAppRoot
|
|
7
|
+
appName="My App"
|
|
8
|
+
homeRoute={{ view: "home" }}
|
|
9
|
+
sidebar={{
|
|
10
|
+
topActions: [
|
|
11
|
+
{ id: "activity", title: "Activity", route: { view: "home" } },
|
|
12
|
+
{ id: "new", title: "New item", route: { view: "new" } },
|
|
13
|
+
],
|
|
14
|
+
getNodes: ({ search }) => buildSidebarNodes(search),
|
|
15
|
+
}}
|
|
16
|
+
routes={{
|
|
17
|
+
home: <Home />,
|
|
18
|
+
project: (route) => <ProjectView id={String(route.projectId)} />,
|
|
19
|
+
}}
|
|
20
|
+
/>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The first two app actions are optional; settings and collapse/uncollapse are always framework-owned and always appear as the rightmost fixed actions.
|
|
24
|
+
|
|
25
|
+
Sidebar nodes support:
|
|
26
|
+
|
|
27
|
+
| Field | Purpose |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `type` | `section` or `item` |
|
|
30
|
+
| `route` | Hash route object used by `WebAppRoot` |
|
|
31
|
+
| `children` | Collapsible nesting |
|
|
32
|
+
| `action` | Per-section/item action such as `New` |
|
|
33
|
+
| `actions` | Context menu items shown on sidebar right-click |
|
|
34
|
+
| `pinnable` | Enables framework Pin/Unpin actions |
|
|
35
|
+
| `pinId` | Stable pin identity when it should differ from `id` |
|
|
36
|
+
| `badge` | Status/count label |
|
|
37
|
+
| `defaultCollapsed` | Initial collapsed state |
|
|
38
|
+
|
|
39
|
+
Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered.
|
|
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:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
const actions = projectActions(project);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
type: "item",
|
|
48
|
+
id: project.id,
|
|
49
|
+
title: project.name,
|
|
50
|
+
route: { view: "project", projectId: project.id },
|
|
51
|
+
actions,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
<WebAppRoot
|
|
55
|
+
sidebar={{
|
|
56
|
+
pinning: { sectionTitle: "Pinned" },
|
|
57
|
+
getNodes,
|
|
58
|
+
}}
|
|
59
|
+
header={{
|
|
60
|
+
getActions: ({ route }) => route.view === "project" ? projectActionsForRoute(route) : [],
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Native pinning
|
|
66
|
+
|
|
67
|
+
Pinning is framework-owned and persisted in browser `localStorage`. Mark route-backed items as `pinnable`; `WebAppRoot` injects `Pin to sidebar` / `Unpin from sidebar` into both the sidebar context menu and the title-bar action menu for the active route. Pinned entries reuse the original sidebar node actions, so right-clicking a pinned item shows the same contextual menu as the source item.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
{
|
|
71
|
+
type: "item",
|
|
72
|
+
id: project.id,
|
|
73
|
+
title: project.name,
|
|
74
|
+
route: { view: "project", projectId: project.id },
|
|
75
|
+
pinnable: true,
|
|
76
|
+
actions: projectActions(project),
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Disable pinning with `sidebar.pinning: false`; customize storage/title with `sidebar.pinning.storageKey` and `sidebar.pinning.sectionTitle`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# UI guidelines
|
|
2
|
+
|
|
3
|
+
The framework intentionally provides a consistent base UI:
|
|
4
|
+
|
|
5
|
+
- Fixed sidebar header and main header share the same height.
|
|
6
|
+
- Sidebar content scrolls independently; the title row and footer stay fixed.
|
|
7
|
+
- Sidebar width is desktop-first and collapsible; mobile uses a drawer with backdrop.
|
|
8
|
+
- The app title in the sidebar navigates home.
|
|
9
|
+
- Settings and collapse controls are framework-owned.
|
|
10
|
+
- Version is always visible at the bottom of the sidebar.
|
|
11
|
+
- Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
|
|
12
|
+
|
|
13
|
+
## Main content primitives
|
|
14
|
+
|
|
15
|
+
Use these first:
|
|
16
|
+
|
|
17
|
+
| Component | Use |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `Toolbar` | Page title/actions inside main content |
|
|
20
|
+
| `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
|
|
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 |
|
|
23
|
+
| `Badge` | Status/count labels |
|
|
24
|
+
| `EntityHeader` | Main-content entity title/description/actions |
|
|
25
|
+
| `DataList` / `DataListRow` | Lists with title, description, metadata, badge and actions |
|
|
26
|
+
| `TextField`, `TextAreaField`, `SelectField` | Forms |
|
|
27
|
+
| `FormGroup`, `FormActions` | Grouped forms and aligned action rows |
|
|
28
|
+
| `DangerZone` | Destructive settings or entity operations |
|
|
29
|
+
| `LoadingState`, `ErrorState` | Loading and failure UI |
|
|
30
|
+
| `CodeValue` | Tokens, IDs, URLs and copyable code values |
|
|
31
|
+
| `SegmentedControl` | Small enum settings |
|
|
32
|
+
| `EmptyState` | Empty or missing content |
|
|
33
|
+
| `ConfirmDialog` | Destructive confirmation |
|
|
34
|
+
|
|
35
|
+
For entity actions, prefer the framework title bar: define one `ActionMenuItem[]` builder, attach it to `SidebarNode.actions` for right-click, and return the same actions from `WebAppRoot.header.getActions` for the selected route.
|
|
36
|
+
|
|
37
|
+
## Visual validation captures
|
|
38
|
+
|
|
39
|
+
Generated screenshots live in `artifacts/screenshots`:
|
|
40
|
+
|
|
41
|
+
| Capture | Purpose |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `notes-desktop-light.png` | Realistic app, desktop shell |
|
|
44
|
+
| `notes-settings-desktop-light.png` | Framework settings |
|
|
45
|
+
| `notes-mobile-light.png` | Mobile shell |
|
|
46
|
+
| `notes-desktop-dark.png` | Dark mode |
|
|
47
|
+
| `kitchen-desktop-light.png` | Kitchen sink desktop |
|
|
48
|
+
| `kitchen-mobile-light.png` | Kitchen sink mobile |
|
|
49
|
+
| `kitchen-sidebar-collapsed-light.png` | Collapsed sidebar title bar |
|
|
50
|
+
| `kitchen-context-menu-light.png` | Sidebar context menu |
|
|
51
|
+
| `kitchen-dialog-dark.png` | Confirm dialog overlay |
|
|
52
|
+
|
|
53
|
+
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
|
+
|
|
55
|
+
Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles.
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pablozaiden/webapp",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/PabloZaiden/webapp.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"docs",
|
|
18
|
+
"README.md",
|
|
19
|
+
"package.json"
|
|
20
|
+
],
|
|
21
|
+
"workspaces": [
|
|
22
|
+
"examples/*"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
"./server": "./src/server/index.ts",
|
|
26
|
+
"./web": "./src/web/index.ts",
|
|
27
|
+
"./web/styles.css": "./src/web/styles.css",
|
|
28
|
+
"./contracts": "./src/contracts/index.ts",
|
|
29
|
+
"./build": "./src/build/index.ts"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"dev": "bun run --cwd examples/notes-todo dev",
|
|
33
|
+
"dev:kitchen-sink": "bun run --cwd examples/kitchen-sink dev",
|
|
34
|
+
"dev:notes-todo": "bun run --cwd examples/notes-todo dev",
|
|
35
|
+
"build": "bun run tsc && bun run --cwd examples/kitchen-sink build && bun run --cwd examples/notes-todo build",
|
|
36
|
+
"build:kitchen-sink": "bun run --cwd examples/kitchen-sink build",
|
|
37
|
+
"build:notes-todo": "bun run --cwd examples/notes-todo build",
|
|
38
|
+
"test": "bun test",
|
|
39
|
+
"tsc": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo",
|
|
40
|
+
"screenshots": "bunx playwright install chromium && bun tests/capture-screenshots.ts"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@simplewebauthn/browser": "13.3.0",
|
|
44
|
+
"@simplewebauthn/server": "13.3.1",
|
|
45
|
+
"jose": "6.2.3",
|
|
46
|
+
"react": "19.2.7",
|
|
47
|
+
"react-dom": "19.2.7",
|
|
48
|
+
"tailwindcss": "4.3.1",
|
|
49
|
+
"zod": "4.4.3"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@happy-dom/global-registrator": "20.10.3",
|
|
53
|
+
"@testing-library/react": "16.3.2",
|
|
54
|
+
"@types/bun": "1.3.14",
|
|
55
|
+
"@types/react": "19.2.17",
|
|
56
|
+
"@types/react-dom": "19.2.3",
|
|
57
|
+
"playwright": "1.61.0",
|
|
58
|
+
"typescript": "6.0.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type BunCompileTarget =
|
|
5
|
+
| "bun-linux-x64"
|
|
6
|
+
| "bun-linux-arm64"
|
|
7
|
+
| "bun-darwin-x64"
|
|
8
|
+
| "bun-darwin-arm64"
|
|
9
|
+
| "bun-windows-x64";
|
|
10
|
+
|
|
11
|
+
export interface BuildWebAppBinaryOptions {
|
|
12
|
+
entrypoint: string;
|
|
13
|
+
outfile: string;
|
|
14
|
+
target?: BunCompileTarget;
|
|
15
|
+
define?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getBunCompileTargetFromArgs(argv = Bun.argv): BunCompileTarget | undefined {
|
|
19
|
+
const raw = argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length);
|
|
20
|
+
return raw as BunCompileTarget | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function buildWebAppBinary(options: BuildWebAppBinaryOptions): Promise<void> {
|
|
24
|
+
mkdirSync(dirname(options.outfile), { recursive: true });
|
|
25
|
+
const result = await Bun.build({
|
|
26
|
+
entrypoints: [options.entrypoint],
|
|
27
|
+
target: "bun",
|
|
28
|
+
minify: true,
|
|
29
|
+
sourcemap: "external",
|
|
30
|
+
define: options.define,
|
|
31
|
+
compile: options.target ? { target: options.target, outfile: options.outfile } : { outfile: options.outfile },
|
|
32
|
+
});
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
for (const log of result.logs) {
|
|
35
|
+
console.error(log);
|
|
36
|
+
}
|
|
37
|
+
throw new Error("Binary build failed");
|
|
38
|
+
}
|
|
39
|
+
if (process.platform !== "win32" && !options.target?.startsWith("bun-windows")) {
|
|
40
|
+
chmodSync(options.outfile, 0o755);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./build-binary";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type LogLevelName = "trace" | "debug" | "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
export type ThemePreference = "system" | "light" | "dark";
|
|
4
|
+
|
|
5
|
+
export interface PasskeyAuthStatusResponse {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
passkeyConfigured: boolean;
|
|
8
|
+
passkeyDisabled: boolean;
|
|
9
|
+
passkeyRequired: boolean;
|
|
10
|
+
authenticated: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiKeySummary {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
prefix: string;
|
|
17
|
+
scopes: string[];
|
|
18
|
+
createdAt: string;
|
|
19
|
+
lastUsedAt?: string;
|
|
20
|
+
expiresAt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreatedApiKeyResponse {
|
|
24
|
+
key: ApiKeySummary;
|
|
25
|
+
token: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeviceAuthorizationResponse {
|
|
29
|
+
device_code: string;
|
|
30
|
+
user_code: string;
|
|
31
|
+
verification_uri: string;
|
|
32
|
+
verification_uri_complete: string;
|
|
33
|
+
expires_in: number;
|
|
34
|
+
interval: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DeviceVerificationDetails {
|
|
38
|
+
userCode: string;
|
|
39
|
+
clientId: string;
|
|
40
|
+
scope: string;
|
|
41
|
+
status: "pending" | "approved" | "denied" | "consumed" | "expired";
|
|
42
|
+
expiresAt: string;
|
|
43
|
+
passkeyRequired: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TokenResponse {
|
|
47
|
+
access_token: string;
|
|
48
|
+
refresh_token: string;
|
|
49
|
+
token_type: "Bearer";
|
|
50
|
+
expires_in: number;
|
|
51
|
+
scope: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AuthSessionSummary {
|
|
55
|
+
id: string;
|
|
56
|
+
clientId: string;
|
|
57
|
+
scope: string;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
updatedAt: string;
|
|
60
|
+
expiresAt: string;
|
|
61
|
+
lastUsedAt?: string;
|
|
62
|
+
revokedAt?: string;
|
|
63
|
+
active: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WebAppConfigResponse {
|
|
67
|
+
appName: string;
|
|
68
|
+
version: string;
|
|
69
|
+
passkeyAuth: PasskeyAuthStatusResponse;
|
|
70
|
+
logLevel: {
|
|
71
|
+
level: LogLevelName;
|
|
72
|
+
fromEnv: boolean;
|
|
73
|
+
};
|
|
74
|
+
deviceAuth: {
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
};
|
|
77
|
+
apiKeys: {
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WebAppErrorResponse {
|
|
83
|
+
error: string;
|
|
84
|
+
message: string;
|
|
85
|
+
details?: unknown;
|
|
86
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ApiKeySummary, CreatedApiKeyResponse } from "../../contracts";
|
|
2
|
+
import type { WebAppStore } from "./store";
|
|
3
|
+
import { nowIso, randomToken, sha256, secureEqual, isExpired } from "./crypto";
|
|
4
|
+
import { AuthError } from "./types";
|
|
5
|
+
|
|
6
|
+
function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummary {
|
|
7
|
+
return {
|
|
8
|
+
id: record.id,
|
|
9
|
+
name: record.name,
|
|
10
|
+
prefix: record.prefix,
|
|
11
|
+
scopes: record.scopes,
|
|
12
|
+
createdAt: record.createdAt,
|
|
13
|
+
lastUsedAt: record.lastUsedAt,
|
|
14
|
+
expiresAt: record.expiresAt,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function listApiKeys(store: WebAppStore): ApiKeySummary[] {
|
|
19
|
+
return store.listApiKeys().map(summarize);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createApiKey(store: WebAppStore, input: { name?: string; scopes?: string[]; prefix?: string; expiresAt?: string }): CreatedApiKeyResponse {
|
|
23
|
+
const prefix = input.prefix ?? "wapp";
|
|
24
|
+
const token = `${prefix}_${randomToken(32)}`;
|
|
25
|
+
const record = {
|
|
26
|
+
id: crypto.randomUUID(),
|
|
27
|
+
name: input.name?.trim() || "API key",
|
|
28
|
+
prefix,
|
|
29
|
+
tokenHash: sha256(token),
|
|
30
|
+
scopes: input.scopes?.length ? input.scopes : ["*"],
|
|
31
|
+
createdAt: nowIso(),
|
|
32
|
+
expiresAt: input.expiresAt,
|
|
33
|
+
};
|
|
34
|
+
store.saveApiKey(record);
|
|
35
|
+
return { key: summarize(record), token };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function deleteApiKey(store: WebAppStore, id: string): boolean {
|
|
39
|
+
return store.deleteApiKey(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function authenticateApiKey(store: WebAppStore, token: string): { apiKeyId: string; scopes: string[] } | undefined {
|
|
43
|
+
const tokenHash = sha256(token);
|
|
44
|
+
const record = store.getApiKeyByHash(tokenHash);
|
|
45
|
+
if (!record || !secureEqual(record.tokenHash, tokenHash) || (record.expiresAt && isExpired(record.expiresAt))) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
store.touchApiKey(record.id, nowIso());
|
|
49
|
+
return { apiKeyId: record.id, scopes: record.scopes };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertScopes(actual: string[], required: string[]): void {
|
|
53
|
+
if (required.length === 0 || actual.includes("*")) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const scope of required) {
|
|
57
|
+
if (!actual.includes(scope)) {
|
|
58
|
+
throw new AuthError("insufficient_scope", `Missing required scope: ${scope}`, 403);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function randomToken(bytes = 32): string {
|
|
4
|
+
return randomBytes(bytes).toString("base64url");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sha256(value: string): string {
|
|
8
|
+
return createHash("sha256").update(value, "utf8").digest("base64url");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hmacSha256(value: string, secret: string): string {
|
|
12
|
+
return createHmac("sha256", secret).update(value, "utf8").digest("base64url");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function secureEqual(left: string, right: string): boolean {
|
|
16
|
+
const leftBuffer = Buffer.from(left);
|
|
17
|
+
const rightBuffer = Buffer.from(right);
|
|
18
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function nowIso(): string {
|
|
25
|
+
return new Date().toISOString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function addSeconds(seconds: number): string {
|
|
29
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isExpired(iso: string): boolean {
|
|
33
|
+
return new Date(iso).getTime() <= Date.now();
|
|
34
|
+
}
|