@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { findRouteCatalogEntry, type RouteCatalogEntry } from "../server/route-catalog";
|
|
3
|
+
import { getAuthorizedHeaders, refreshDeviceCredentials, type StoredDeviceCredentials } from "./device-auth";
|
|
4
|
+
import { readOption, type CliCommandResult } from "./runtime";
|
|
5
|
+
|
|
6
|
+
export interface ApiCliCredentialsStore {
|
|
7
|
+
read(): Promise<StoredDeviceCredentials | undefined>;
|
|
8
|
+
write(value: StoredDeviceCredentials): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ApiCliCommandOptions {
|
|
12
|
+
catalog: readonly RouteCatalogEntry[];
|
|
13
|
+
args: string[];
|
|
14
|
+
mode?: "api" | "schema";
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
credentials?: ApiCliCredentialsStore;
|
|
17
|
+
fetchFn?: typeof fetch;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function schemaJson(schema: unknown): unknown {
|
|
22
|
+
if (!schema) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return z.toJSONSchema(schema as z.ZodTypeAny);
|
|
25
|
+
} catch {
|
|
26
|
+
return schema;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listOutput(catalog: readonly RouteCatalogEntry[]): string {
|
|
31
|
+
return catalog
|
|
32
|
+
.filter((entry) => entry.path.startsWith("/api/"))
|
|
33
|
+
.map((entry) => `${entry.methods.join(", ")} ${entry.cliPath}${entry.description ? ` - ${entry.description}` : ""}`)
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function schemaOutput(entry: RouteCatalogEntry): string {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
path: entry.path,
|
|
40
|
+
cliPath: entry.cliPath,
|
|
41
|
+
methods: entry.methods,
|
|
42
|
+
auth: entry.auth,
|
|
43
|
+
sameOrigin: entry.sameOrigin,
|
|
44
|
+
scopes: entry.scopes,
|
|
45
|
+
description: entry.description,
|
|
46
|
+
tags: entry.tags,
|
|
47
|
+
querySchema: schemaJson(entry.querySchema),
|
|
48
|
+
requestSchema: schemaJson(entry.requestSchema),
|
|
49
|
+
responseSchema: schemaJson(entry.responseSchema),
|
|
50
|
+
}, null, 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function endpointArg(args: string[]): string | undefined {
|
|
54
|
+
return args.find((arg) => !arg.startsWith("--"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function authHeaders(input: ApiCliCommandOptions): Promise<Headers> {
|
|
58
|
+
const headers = new Headers({ accept: "application/json" });
|
|
59
|
+
const stored = await input.credentials?.read();
|
|
60
|
+
if (!stored) return headers;
|
|
61
|
+
const refreshed = await refreshDeviceCredentials({
|
|
62
|
+
credentials: stored,
|
|
63
|
+
store: input.credentials,
|
|
64
|
+
fetchFn: input.fetchFn,
|
|
65
|
+
now: input.now,
|
|
66
|
+
});
|
|
67
|
+
return refreshed ? getAuthorizedHeaders(refreshed, headers) : headers;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runApiCliCommand(input: ApiCliCommandOptions): Promise<CliCommandResult> {
|
|
71
|
+
const endpoint = endpointArg(input.args);
|
|
72
|
+
if (!endpoint) {
|
|
73
|
+
return { exitCode: 0, output: listOutput(input.catalog) };
|
|
74
|
+
}
|
|
75
|
+
const match = findRouteCatalogEntry(input.catalog, endpoint);
|
|
76
|
+
if (!match) {
|
|
77
|
+
return { exitCode: 1, error: `Unknown API endpoint: ${endpoint}` };
|
|
78
|
+
}
|
|
79
|
+
if (input.mode === "schema") {
|
|
80
|
+
return { exitCode: 0, output: schemaOutput(match.entry) };
|
|
81
|
+
}
|
|
82
|
+
const method = (readOption(input.args, ["--method", "-X"]) ?? match.entry.methods[0] ?? "GET").toUpperCase();
|
|
83
|
+
if (!match.entry.methods.includes(method as never)) {
|
|
84
|
+
return { exitCode: 1, error: `Method ${method} is not available for ${match.entry.cliPath}` };
|
|
85
|
+
}
|
|
86
|
+
const payload = readOption(input.args, ["--payload", "--data", "-d"]);
|
|
87
|
+
const baseUrl = (input.baseUrl ?? "http://localhost:3000").replace(/\/+$/, "");
|
|
88
|
+
const url = new URL(`${baseUrl}${match.path}`);
|
|
89
|
+
const headers = await authHeaders(input);
|
|
90
|
+
let body: string | undefined;
|
|
91
|
+
if (payload !== undefined) {
|
|
92
|
+
JSON.parse(payload) as unknown;
|
|
93
|
+
body = payload;
|
|
94
|
+
headers.set("content-type", "application/json");
|
|
95
|
+
}
|
|
96
|
+
const send = () => (input.fetchFn ?? fetch)(url, { method, headers, body });
|
|
97
|
+
let response = await send();
|
|
98
|
+
if (response.status === 401 && input.credentials) {
|
|
99
|
+
const stored = await input.credentials.read();
|
|
100
|
+
if (stored) {
|
|
101
|
+
const refreshed = await refreshDeviceCredentials({ credentials: { ...stored, accessTokenExpiresAt: new Date(0).toISOString() }, store: input.credentials, fetchFn: input.fetchFn, now: input.now });
|
|
102
|
+
if (refreshed) {
|
|
103
|
+
headers.set("authorization", `${refreshed.tokenType} ${refreshed.accessToken}`);
|
|
104
|
+
}
|
|
105
|
+
response = await send();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const text = await response.text();
|
|
109
|
+
let parsed: unknown = text;
|
|
110
|
+
try {
|
|
111
|
+
parsed = text ? JSON.parse(text) as unknown : null;
|
|
112
|
+
} catch {
|
|
113
|
+
parsed = text || null;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
exitCode: response.ok ? 0 : 1,
|
|
117
|
+
output: JSON.stringify({ status: { code: response.status, ok: response.ok, text: response.statusText }, response: parsed }, null, 2),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { chmodSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface JsonFileStore<T> {
|
|
6
|
+
path(): string;
|
|
7
|
+
read(): Promise<T | undefined>;
|
|
8
|
+
write(value: T): Promise<void>;
|
|
9
|
+
clear(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function chmodIfPossible(path: string, mode: number): void {
|
|
13
|
+
try {
|
|
14
|
+
chmodSync(path, mode);
|
|
15
|
+
} catch {
|
|
16
|
+
// Not all platforms/filesystems support POSIX modes.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createJsonFileStore<T>(input: {
|
|
21
|
+
appDirectoryName: string;
|
|
22
|
+
fileName: string;
|
|
23
|
+
envHome?: string;
|
|
24
|
+
parse(value: unknown): T;
|
|
25
|
+
home?: string;
|
|
26
|
+
}): JsonFileStore<T> {
|
|
27
|
+
const stateDir = () => {
|
|
28
|
+
const explicit = input.envHome ? process.env[input.envHome]?.trim() : undefined;
|
|
29
|
+
const home = input.home ?? process.env["HOME"]?.trim();
|
|
30
|
+
if (explicit) return explicit;
|
|
31
|
+
if (!home) throw new Error("HOME is not set");
|
|
32
|
+
return join(home, input.appDirectoryName);
|
|
33
|
+
};
|
|
34
|
+
const filePath = () => join(stateDir(), input.fileName);
|
|
35
|
+
return {
|
|
36
|
+
path: filePath,
|
|
37
|
+
async read() {
|
|
38
|
+
try {
|
|
39
|
+
return input.parse(JSON.parse(await readFile(filePath(), "utf8")) as unknown);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
async write(value) {
|
|
48
|
+
const target = filePath();
|
|
49
|
+
const dir = dirname(target);
|
|
50
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
chmodIfPossible(dir, 0o700);
|
|
52
|
+
const temp = join(dir, `.${input.fileName}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
53
|
+
try {
|
|
54
|
+
await Bun.write(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
55
|
+
chmodIfPossible(temp, 0o600);
|
|
56
|
+
await rename(temp, target);
|
|
57
|
+
chmodIfPossible(target, 0o600);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
await rm(temp, { force: true });
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async clear() {
|
|
64
|
+
await rm(filePath(), { force: true });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createJsonFileStore, type JsonFileStore } from "./credentials";
|
|
2
|
+
|
|
3
|
+
export interface StoredDeviceCredentials {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
accessToken: string;
|
|
7
|
+
refreshToken: string;
|
|
8
|
+
tokenType: "Bearer";
|
|
9
|
+
scope: string;
|
|
10
|
+
accessTokenExpiresAt: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeBaseUrl(value: string): string {
|
|
16
|
+
const url = new URL(value.trim());
|
|
17
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
18
|
+
throw new Error(`Invalid base URL protocol: ${url.protocol}`);
|
|
19
|
+
}
|
|
20
|
+
return url.toString().replace(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseStoredDeviceCredentials(value: unknown): StoredDeviceCredentials {
|
|
24
|
+
if (!value || typeof value !== "object") {
|
|
25
|
+
throw new Error("Stored credentials must be an object");
|
|
26
|
+
}
|
|
27
|
+
const record = value as Record<string, unknown>;
|
|
28
|
+
const credentials = {
|
|
29
|
+
baseUrl: String(record["baseUrl"] ?? ""),
|
|
30
|
+
clientId: String(record["clientId"] ?? ""),
|
|
31
|
+
accessToken: String(record["accessToken"] ?? ""),
|
|
32
|
+
refreshToken: String(record["refreshToken"] ?? ""),
|
|
33
|
+
tokenType: record["tokenType"],
|
|
34
|
+
scope: String(record["scope"] ?? ""),
|
|
35
|
+
accessTokenExpiresAt: String(record["accessTokenExpiresAt"] ?? ""),
|
|
36
|
+
createdAt: String(record["createdAt"] ?? ""),
|
|
37
|
+
updatedAt: String(record["updatedAt"] ?? ""),
|
|
38
|
+
};
|
|
39
|
+
if (!credentials.baseUrl || !credentials.clientId || !credentials.accessToken || !credentials.refreshToken || credentials.tokenType !== "Bearer") {
|
|
40
|
+
throw new Error("Stored credentials are invalid");
|
|
41
|
+
}
|
|
42
|
+
return credentials as StoredDeviceCredentials;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createDeviceCredentialsStore(input: {
|
|
46
|
+
appDirectoryName: string;
|
|
47
|
+
envHome?: string;
|
|
48
|
+
fileName?: string;
|
|
49
|
+
home?: string;
|
|
50
|
+
}): JsonFileStore<StoredDeviceCredentials> {
|
|
51
|
+
return createJsonFileStore({
|
|
52
|
+
appDirectoryName: input.appDirectoryName,
|
|
53
|
+
fileName: input.fileName ?? "device-auth.json",
|
|
54
|
+
envHome: input.envHome,
|
|
55
|
+
home: input.home,
|
|
56
|
+
parse: parseStoredDeviceCredentials,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getAuthorizedHeaders(credentials: StoredDeviceCredentials, headers?: HeadersInit): Headers {
|
|
61
|
+
const result = new Headers(headers);
|
|
62
|
+
result.set("authorization", `${credentials.tokenType} ${credentials.accessToken}`);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isExpired(credentials: StoredDeviceCredentials, now: Date): boolean {
|
|
67
|
+
return new Date(credentials.accessTokenExpiresAt).getTime() <= now.getTime();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function requestJson(fetchFn: typeof fetch, url: string, init?: RequestInit): Promise<{ response: Response; body: unknown }> {
|
|
71
|
+
const headers = new Headers(init?.headers);
|
|
72
|
+
headers.set("accept", "application/json");
|
|
73
|
+
const response = await fetchFn(url, { ...init, headers });
|
|
74
|
+
const text = await response.text();
|
|
75
|
+
if (!text) {
|
|
76
|
+
return { response, body: undefined };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return { response, body: JSON.parse(text) as unknown };
|
|
80
|
+
} catch {
|
|
81
|
+
return { response, body: text };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tokenError(body: unknown, status: number): string {
|
|
86
|
+
if (body && typeof body === "object") {
|
|
87
|
+
const record = body as Record<string, unknown>;
|
|
88
|
+
return String(record["error_description"] ?? record["message"] ?? record["error"] ?? `Request failed with status ${status}`);
|
|
89
|
+
}
|
|
90
|
+
return `Request failed with status ${status}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokenCredentials(baseUrl: string, clientId: string, tokenSet: Record<string, unknown>, now: Date): StoredDeviceCredentials {
|
|
94
|
+
const expiresIn = Number(tokenSet["expires_in"] ?? 0);
|
|
95
|
+
return {
|
|
96
|
+
baseUrl,
|
|
97
|
+
clientId,
|
|
98
|
+
accessToken: String(tokenSet["access_token"] ?? ""),
|
|
99
|
+
refreshToken: String(tokenSet["refresh_token"] ?? ""),
|
|
100
|
+
tokenType: "Bearer",
|
|
101
|
+
scope: String(tokenSet["scope"] ?? ""),
|
|
102
|
+
accessTokenExpiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
|
|
103
|
+
createdAt: now.toISOString(),
|
|
104
|
+
updatedAt: now.toISOString(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function refreshDeviceCredentials(input: {
|
|
109
|
+
credentials: StoredDeviceCredentials;
|
|
110
|
+
store?: { write(value: StoredDeviceCredentials): Promise<void> };
|
|
111
|
+
fetchFn?: typeof fetch;
|
|
112
|
+
now?: () => Date;
|
|
113
|
+
}): Promise<StoredDeviceCredentials | undefined> {
|
|
114
|
+
if (!isExpired(input.credentials, (input.now ?? (() => new Date()))())) {
|
|
115
|
+
return input.credentials;
|
|
116
|
+
}
|
|
117
|
+
const now = input.now?.() ?? new Date();
|
|
118
|
+
const { response, body } = await requestJson(input.fetchFn ?? fetch, `${input.credentials.baseUrl}/api/auth/token`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json" },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
grant_type: "refresh_token",
|
|
123
|
+
refresh_token: input.credentials.refreshToken,
|
|
124
|
+
client_id: input.credentials.clientId,
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const next = tokenCredentials(input.credentials.baseUrl, input.credentials.clientId, body as Record<string, unknown>, now);
|
|
131
|
+
await input.store?.write(next);
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function runDeviceAuthCommand(input: {
|
|
136
|
+
baseUrl: string;
|
|
137
|
+
clientId: string;
|
|
138
|
+
store: JsonFileStore<StoredDeviceCredentials>;
|
|
139
|
+
scope?: string;
|
|
140
|
+
fetchFn?: typeof fetch;
|
|
141
|
+
sleep?: (ms: number) => Promise<void>;
|
|
142
|
+
now?: () => Date;
|
|
143
|
+
out?: (message: string) => void;
|
|
144
|
+
}): Promise<number> {
|
|
145
|
+
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
146
|
+
const fetchFn = input.fetchFn ?? fetch;
|
|
147
|
+
const now = input.now ?? (() => new Date());
|
|
148
|
+
const out = input.out ?? console.log;
|
|
149
|
+
const start = await requestJson(fetchFn, `${baseUrl}/api/auth/device`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ clientId: input.clientId, scope: input.scope }),
|
|
153
|
+
});
|
|
154
|
+
if (!start.response.ok) throw new Error(tokenError(start.body, start.response.status));
|
|
155
|
+
const started = start.body as Record<string, unknown>;
|
|
156
|
+
out(`Open: ${String(started["verification_uri_complete"] ?? started["verification_uri"])}`);
|
|
157
|
+
out(`Code: ${String(started["user_code"] ?? "")}`);
|
|
158
|
+
const interval = Number(started["interval"] ?? 5) * 1000;
|
|
159
|
+
while (true) {
|
|
160
|
+
await (input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(interval);
|
|
161
|
+
const token = await requestJson(fetchFn, `${baseUrl}/api/auth/token`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json" },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
166
|
+
device_code: started["device_code"],
|
|
167
|
+
client_id: input.clientId,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
if (token.response.ok) {
|
|
171
|
+
await input.store.write(tokenCredentials(baseUrl, input.clientId, token.body as Record<string, unknown>, now()));
|
|
172
|
+
out(`Authenticated with ${baseUrl}`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
const error = (token.body as Record<string, unknown> | undefined)?.["error"];
|
|
176
|
+
if (error === "authorization_pending" || error === "slow_down") continue;
|
|
177
|
+
throw new Error(tokenError(token.body, token.response.status));
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface CliCommandResult {
|
|
2
|
+
exitCode: number;
|
|
3
|
+
output?: string;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type CliCommandHandler = (args: string[]) => CliCommandResult | Promise<CliCommandResult>;
|
|
8
|
+
|
|
9
|
+
export function printCliResult(result: CliCommandResult, output: Pick<Console, "log" | "error"> = console): number {
|
|
10
|
+
if (result.output) {
|
|
11
|
+
output.log(result.output);
|
|
12
|
+
}
|
|
13
|
+
if (result.error) {
|
|
14
|
+
output.error(result.error);
|
|
15
|
+
}
|
|
16
|
+
return result.exitCode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readOption(args: readonly string[], names: readonly string[]): string | undefined {
|
|
20
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
21
|
+
const arg = args[index];
|
|
22
|
+
if (!arg) continue;
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
if (arg === name) return args[index + 1];
|
|
25
|
+
if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasFlag(args: readonly string[], names: readonly string[]): boolean {
|
|
32
|
+
return args.some((arg) => names.includes(arg));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function dispatchCliCommand(input: {
|
|
36
|
+
args: string[];
|
|
37
|
+
commands: Record<string, CliCommandHandler>;
|
|
38
|
+
defaultCommand?: string;
|
|
39
|
+
help: string;
|
|
40
|
+
}): Promise<CliCommandResult> {
|
|
41
|
+
const [rawCommand, ...rest] = input.args;
|
|
42
|
+
const command = rawCommand && !rawCommand.startsWith("-") ? rawCommand : input.defaultCommand;
|
|
43
|
+
if (!command || rawCommand === "help" || rawCommand === "--help" || rawCommand === "-h") {
|
|
44
|
+
return { exitCode: rawCommand ? 0 : 1, output: input.help };
|
|
45
|
+
}
|
|
46
|
+
const handler = input.commands[command];
|
|
47
|
+
if (!handler) {
|
|
48
|
+
return { exitCode: 1, error: `Unknown command: ${command}`, output: input.help };
|
|
49
|
+
}
|
|
50
|
+
return await handler(rawCommand === command ? rest : input.args);
|
|
51
|
+
}
|
package/src/contracts/index.ts
CHANGED
|
@@ -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 {
|