@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/src/server/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun";
|
|
|
2
2
|
|
|
3
3
|
export interface WebSocketData {
|
|
4
4
|
filters?: Record<string, string>;
|
|
5
|
+
userId?: string;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export type RealtimeAction = "created" | "updated" | "changed" | "deleted";
|
|
@@ -19,6 +20,7 @@ export type RealtimeTarget = {
|
|
|
19
20
|
resource?: string;
|
|
20
21
|
id?: string;
|
|
21
22
|
scope?: string;
|
|
23
|
+
userId?: string;
|
|
22
24
|
} & Record<string, string | undefined>;
|
|
23
25
|
|
|
24
26
|
export interface RealtimePublishOptions {
|
|
@@ -31,8 +33,12 @@ export type RealtimeMessage<TEvent> =
|
|
|
31
33
|
| { type: "ping" }
|
|
32
34
|
| { type: "pong" };
|
|
33
35
|
|
|
34
|
-
function targetMatches(
|
|
36
|
+
function targetMatches(socket: ServerWebSocket<WebSocketData>, target: RealtimeTarget | undefined): boolean {
|
|
35
37
|
if (!target) return true;
|
|
38
|
+
if (target.userId !== undefined && socket.data.userId !== target.userId) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const filters = socket.data.filters;
|
|
36
42
|
if (!filters) return true;
|
|
37
43
|
for (const [key, value] of Object.entries(filters)) {
|
|
38
44
|
if (value !== undefined && target[key] !== value) {
|
|
@@ -57,7 +63,7 @@ export class RealtimeBus<TEvent = unknown> {
|
|
|
57
63
|
const publishOptions = typeof options === "function" ? { filter: options } : options;
|
|
58
64
|
const payload = JSON.stringify({ type: "event", event } satisfies RealtimeMessage<TEvent>);
|
|
59
65
|
for (const socket of this.sockets) {
|
|
60
|
-
if (targetMatches(socket
|
|
66
|
+
if (targetMatches(socket, publishOptions?.target) && (!publishOptions?.filter || publishOptions.filter(socket))) {
|
|
61
67
|
socket.send(payload);
|
|
62
68
|
}
|
|
63
69
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { HttpMethod, RouteAuth, RouteDefinition, RouteTable, SameOriginMode } from "./routes";
|
|
2
|
+
|
|
3
|
+
const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
4
|
+
|
|
5
|
+
export interface RouteCatalogEntry {
|
|
6
|
+
path: string;
|
|
7
|
+
cliPath: string;
|
|
8
|
+
methods: HttpMethod[];
|
|
9
|
+
auth: RouteAuth;
|
|
10
|
+
sameOrigin: SameOriginMode;
|
|
11
|
+
scopes: string[];
|
|
12
|
+
description?: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
requestSchema?: unknown;
|
|
15
|
+
querySchema?: unknown;
|
|
16
|
+
responseSchema?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouteCatalogMatch {
|
|
20
|
+
entry: RouteCatalogEntry;
|
|
21
|
+
path: string;
|
|
22
|
+
params: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function trimSlashes(value: string): string {
|
|
26
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultCliPath(path: string): string {
|
|
30
|
+
return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function methodsFor(route: RouteDefinition): HttpMethod[] {
|
|
34
|
+
return HTTP_METHODS.filter((method) => typeof route[method] === "function");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createRouteCatalog<TEvent = unknown>(routes: RouteTable<TEvent>): RouteCatalogEntry[] {
|
|
38
|
+
return Object.entries(routes)
|
|
39
|
+
.filter(([, route]) => route.catalog !== false)
|
|
40
|
+
.map(([path, route]) => ({
|
|
41
|
+
path,
|
|
42
|
+
cliPath: route.cliPath ?? defaultCliPath(path),
|
|
43
|
+
methods: methodsFor(route),
|
|
44
|
+
auth: route.auth ?? "required",
|
|
45
|
+
sameOrigin: route.sameOrigin ?? "mutations",
|
|
46
|
+
scopes: route.scopes ?? [],
|
|
47
|
+
description: route.description,
|
|
48
|
+
tags: route.tags ?? [],
|
|
49
|
+
requestSchema: route.requestSchema,
|
|
50
|
+
querySchema: route.querySchema,
|
|
51
|
+
responseSchema: route.responseSchema,
|
|
52
|
+
}))
|
|
53
|
+
.filter((entry) => entry.methods.length > 0)
|
|
54
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeApiPath(input: string): string {
|
|
58
|
+
const [path = ""] = input.trim().split(/[?#]/);
|
|
59
|
+
if (!path) {
|
|
60
|
+
throw new Error("API endpoint is required");
|
|
61
|
+
}
|
|
62
|
+
if (path.startsWith("/api/") || path === "/api") {
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
if (path.startsWith("api/")) {
|
|
66
|
+
return `/${path}`;
|
|
67
|
+
}
|
|
68
|
+
if (path.startsWith("/")) {
|
|
69
|
+
return path;
|
|
70
|
+
}
|
|
71
|
+
return `/api/${path}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeCliPath(input: string): string {
|
|
75
|
+
const [path = ""] = input.trim().split(/[?#]/);
|
|
76
|
+
return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decodePathPart(value: string): string | undefined {
|
|
80
|
+
try {
|
|
81
|
+
return decodeURIComponent(value);
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchPattern(pattern: string, value: string): Record<string, string> | undefined {
|
|
88
|
+
const patternParts = trimSlashes(pattern).split("/").filter(Boolean);
|
|
89
|
+
const valueParts = trimSlashes(value).split("/").filter(Boolean);
|
|
90
|
+
if (patternParts.length !== valueParts.length) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const params: Record<string, string> = {};
|
|
94
|
+
for (let index = 0; index < patternParts.length; index += 1) {
|
|
95
|
+
const patternPart = patternParts[index]!;
|
|
96
|
+
const valuePart = valueParts[index]!;
|
|
97
|
+
if (patternPart.startsWith(":")) {
|
|
98
|
+
const decoded = decodePathPart(valuePart);
|
|
99
|
+
if (decoded === undefined) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
params[patternPart.slice(1)] = decoded;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (patternPart !== valuePart) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return params;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function concretePath(pattern: string, params: Record<string, string>): string {
|
|
113
|
+
return trimSlashes(pattern)
|
|
114
|
+
.split("/")
|
|
115
|
+
.map((part) => part.startsWith(":") ? encodeURIComponent(params[part.slice(1)] ?? "") : part)
|
|
116
|
+
.join("/")
|
|
117
|
+
.replace(/^/, "/");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function specificity(entry: RouteCatalogEntry): number {
|
|
121
|
+
return entry.path.split("/").reduce((score, part) => score + (part && !part.startsWith(":") ? 2 : 1), 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findRouteCatalogEntry(catalog: readonly RouteCatalogEntry[], input: string): RouteCatalogMatch | undefined {
|
|
125
|
+
const apiPath = normalizeApiPath(input);
|
|
126
|
+
const cliPath = normalizeCliPath(input);
|
|
127
|
+
const sorted = [...catalog].sort((left, right) => specificity(right) - specificity(left));
|
|
128
|
+
for (const entry of sorted) {
|
|
129
|
+
if (entry.path === apiPath) {
|
|
130
|
+
return { entry, path: apiPath, params: {} };
|
|
131
|
+
}
|
|
132
|
+
if (entry.cliPath === cliPath) {
|
|
133
|
+
return { entry, path: entry.path, params: {} };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const entry of sorted) {
|
|
137
|
+
const apiParams = matchPattern(entry.path, apiPath);
|
|
138
|
+
if (apiParams) {
|
|
139
|
+
return { entry, path: apiPath, params: apiParams };
|
|
140
|
+
}
|
|
141
|
+
const cliParams = matchPattern(entry.cliPath, cliPath);
|
|
142
|
+
if (cliParams) {
|
|
143
|
+
return { entry, path: concretePath(entry.path, cliParams), params: cliParams };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
package/src/server/routes.ts
CHANGED
|
@@ -1,28 +1,59 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
+
import type { CurrentUser } from "../contracts";
|
|
2
3
|
import type { AuthenticatedRequestState } from "./auth/types";
|
|
3
|
-
import type { RealtimeBus } from "./realtime/bus";
|
|
4
|
+
import type { RealtimeBus, ResourceRealtimeEvent, RealtimeTarget } from "./realtime/bus";
|
|
4
5
|
|
|
5
|
-
export type RouteAuth = "required" | "public" | "optional";
|
|
6
|
+
export type RouteAuth = "required" | "user" | "admin" | "owner" | "public" | "optional";
|
|
6
7
|
export type SameOriginMode = "mutations" | "always" | "never";
|
|
7
8
|
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
9
|
+
export type UserOwnedResource = { userId: string };
|
|
10
|
+
export type UserIdSelector<TResource> = (resource: TResource) => string | undefined;
|
|
11
|
+
|
|
12
|
+
export interface UserScopedRealtimePublisher<TEvent = unknown> {
|
|
13
|
+
publishChanged<TPayload = unknown>(resource: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget }): void;
|
|
14
|
+
publishEntityChanged<TPayload = unknown>(resource: string, id: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget }): void;
|
|
15
|
+
publishDeleted<TPayload = unknown>(resource: string, id: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget }): void;
|
|
16
|
+
publishSettingsChanged<TPayload = unknown>(options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget }): void;
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
export interface RouteContext<TParams extends Record<string, string> = Record<string, string>, TEvent = unknown> {
|
|
10
20
|
params: TParams;
|
|
11
21
|
auth: AuthenticatedRequestState;
|
|
22
|
+
user?: CurrentUser;
|
|
23
|
+
requireUser(): CurrentUser;
|
|
24
|
+
requireAdmin(): CurrentUser;
|
|
25
|
+
requireOwner(): CurrentUser;
|
|
26
|
+
assertUser(userId: string): CurrentUser;
|
|
27
|
+
filterOwned<TResource extends UserOwnedResource>(resources: readonly TResource[]): TResource[];
|
|
28
|
+
filterOwned<TResource>(resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
|
|
29
|
+
requireOwned<TResource extends UserOwnedResource>(resource: TResource | null | undefined): TResource;
|
|
30
|
+
requireOwned<TResource>(resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
|
|
12
31
|
realtime: RealtimeBus<TEvent>;
|
|
32
|
+
userRealtime: UserScopedRealtimePublisher<TEvent>;
|
|
13
33
|
server?: Server<unknown>;
|
|
14
34
|
}
|
|
15
35
|
|
|
16
36
|
export type WebAppRouteHandler<TParams extends Record<string, string> = Record<string, string>, TEvent = unknown> = (
|
|
17
37
|
req: Request,
|
|
18
38
|
ctx: RouteContext<TParams, TEvent>,
|
|
19
|
-
) => Response | Promise<Response>;
|
|
39
|
+
) => Response | undefined | Promise<Response | undefined>;
|
|
40
|
+
|
|
41
|
+
export interface RouteMetadata {
|
|
42
|
+
description?: string;
|
|
43
|
+
cliPath?: string;
|
|
44
|
+
tags?: string[];
|
|
45
|
+
requestSchema?: unknown;
|
|
46
|
+
querySchema?: unknown;
|
|
47
|
+
responseSchema?: unknown;
|
|
48
|
+
catalog?: boolean;
|
|
49
|
+
}
|
|
20
50
|
|
|
21
51
|
export type RouteDefinition<TEvent = unknown> = {
|
|
22
52
|
auth?: RouteAuth;
|
|
23
53
|
sameOrigin?: SameOriginMode;
|
|
24
54
|
scopes?: string[];
|
|
25
|
-
|
|
55
|
+
userParam?: string;
|
|
56
|
+
} & RouteMetadata & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
|
|
26
57
|
|
|
27
58
|
export type RouteTable<TEvent = unknown> = Record<string, RouteDefinition<TEvent>>;
|
|
28
59
|
|
|
@@ -70,7 +70,7 @@ export function readRuntimeConfig(input: {
|
|
|
70
70
|
return {
|
|
71
71
|
appName: input.appName,
|
|
72
72
|
envPrefix,
|
|
73
|
-
host: readEnv(envPrefix, "HOST") || "
|
|
73
|
+
host: readEnv(envPrefix, "HOST") || "localhost",
|
|
74
74
|
port: parsePort(readEnv(envPrefix, "PORT"), envName(envPrefix, "PORT")),
|
|
75
75
|
dataDir: readEnv(envPrefix, "DATA_DIR") || "./data",
|
|
76
76
|
logLevel,
|