@pablozaiden/webapp 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +25 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +67 -0
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1214
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +76 -64
package/docs/ui-guidelines.md
CHANGED
|
@@ -11,13 +11,21 @@ The framework intentionally provides a consistent base UI:
|
|
|
11
11
|
- Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
|
|
12
12
|
- `WebAppRoot` owns the fixed main title bar and `.wapp-main-content`; app routes should not render or style those shell elements directly.
|
|
13
13
|
|
|
14
|
+
## Mobile breakpoint and viewport synchronization
|
|
15
|
+
|
|
16
|
+
The framework owns the mobile shell breakpoint. `MOBILE_BREAKPOINT_PX` and `MOBILE_MEDIA_QUERY` are exported from `@pablozaiden/webapp/web` for application JavaScript that needs to coordinate with the shell; do not add an independent `innerWidth` threshold for shell behavior. The generated document initializes the `data-wapp-mobile` marker on the root element before the client and styles load, and `WebAppRoot` keeps it synchronized with media-query changes. Custom CSS that follows the framework mobile mode should use that marker rather than repeating a numeric media query.
|
|
17
|
+
|
|
18
|
+
`WebAppRoot` follows `visualViewport` resize/scroll, window resize, orientation changes, focus boundaries, and mobile media-query changes. It schedules an immediate animation-frame sync first. Two named, bounded retries remain for mobile Safari/WebKit and Chromium behavior where focus/blur or orientation notifications can arrive before the final `visualViewport` geometry is available while the keyboard, browser chrome, or rotation is settling. The first retry catches the post-event layout pass; the final retry catches the end of that transition when no further geometry event is emitted. These retries are implementation fallbacks, not general synchronization primitives, and are cancelled with the associated animation frame and listeners when the root is unmounted.
|
|
19
|
+
|
|
20
|
+
The framework mobile mode is separate from the narrower `640px` settings-layout rules. Do not add global touch handlers or arbitrary sleeps to reproduce either behavior; use the framework drawer controls and the existing event-driven viewport lifecycle.
|
|
21
|
+
|
|
14
22
|
## Main content primitives
|
|
15
23
|
|
|
16
24
|
Use these first:
|
|
17
25
|
|
|
18
26
|
| Component | Use |
|
|
19
27
|
| --- | --- |
|
|
20
|
-
| `Page` | Required top-level wrapper for route content rendered by `WebAppRoot.routes`;
|
|
28
|
+
| `Page` | Required top-level wrapper for route content rendered by `WebAppRoot.routes`; uses standard margins/padding and mobile spacing by default, or `layout="full"` for viewport-sized content |
|
|
21
29
|
| `Toolbar` | Page title/actions inside main content |
|
|
22
30
|
| `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
|
|
23
31
|
| `ActionMenu` | Three-line action menu for secondary surfaces; entity-level shell menus should usually come from `SidebarNode.actions` so the framework renders them in the sidebar context menu and fixed title bar |
|
|
@@ -36,11 +44,11 @@ Use these first:
|
|
|
36
44
|
|
|
37
45
|
For entity actions, prefer the framework title bar: define one `ActionMenuItem[]` builder and attach it to `SidebarNode.actions` for the route-backed node. The framework reuses those actions for both sidebar right-click and the active route title-bar menu. Use `WebAppRoot.header.getActions` only for additional actions not owned by a sidebar node.
|
|
38
46
|
|
|
39
|
-
Every route component rendered by `WebAppRoot.routes` should return `Page` at the top level, including loading/error/empty states. Do not render a `Panel`, `DataList`, `EmptyState` or custom div directly into `WebAppRoot`; that skips the standard content margins and recreates the visual bug where cards touch the main content edge. Use `EntityHeader` only when the page needs a content-specific heading distinct from the fixed framework title bar; do not duplicate the active route title immediately below the header.
|
|
47
|
+
Every route component rendered by `WebAppRoot.routes` should return `Page` at the top level, including loading/error/empty states. Do not render a `Panel`, `DataList`, `EmptyState` or custom div directly into `WebAppRoot`; that skips the standard content margins and recreates the visual bug where cards touch the main content edge. For a route whose child owns viewport-sized layout and scrolling, use `<Page layout="full">` rather than overriding `.wapp-page` from the application. Use `EntityHeader` only when the page needs a content-specific heading distinct from the fixed framework title bar; do not duplicate the active route title immediately below the header.
|
|
40
48
|
|
|
41
49
|
## Visual validation captures
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
Reference screenshots live in `artifacts/screenshots`:
|
|
44
52
|
|
|
45
53
|
| Capture | Purpose |
|
|
46
54
|
| --- | --- |
|
|
@@ -56,7 +64,7 @@ Generated screenshots live in `artifacts/screenshots`:
|
|
|
56
64
|
| `kitchen-dialog-dark.png` | Confirm dialog overlay |
|
|
57
65
|
| `kitchen-device-light.png` | Device auth approval flow |
|
|
58
66
|
|
|
59
|
-
Use
|
|
67
|
+
Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths. The framework does not ship a screenshot command or Playwright dependency.
|
|
60
68
|
|
|
61
69
|
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.
|
|
62
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,7 @@
|
|
|
37
37
|
"build:kitchen-sink": "bun run --cwd examples/kitchen-sink build",
|
|
38
38
|
"build:notes-todo": "bun run --cwd examples/notes-todo build",
|
|
39
39
|
"test": "bun test",
|
|
40
|
-
"tsc": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo"
|
|
41
|
-
"screenshots": "bunx playwright install chromium && bun tests/capture-screenshots.ts"
|
|
40
|
+
"tsc": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo"
|
|
42
41
|
},
|
|
43
42
|
"dependencies": {
|
|
44
43
|
"@simplewebauthn/browser": "13.3.0",
|
|
@@ -58,7 +57,6 @@
|
|
|
58
57
|
"@types/bun": "1.3.14",
|
|
59
58
|
"@types/react": "19.2.17",
|
|
60
59
|
"@types/react-dom": "19.2.3",
|
|
61
|
-
"playwright": "1.61.0",
|
|
62
60
|
"react": "19.2.7",
|
|
63
61
|
"react-dom": "19.2.7",
|
|
64
62
|
"typescript": "7.0.2"
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { BunPlugin } from "bun";
|
|
2
2
|
import tailwindPlugin from "bun-plugin-tailwind";
|
|
3
|
-
import { chmodSync,
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, extname, resolve } from "node:path";
|
|
5
|
+
import { findPackageRoot, resolveReactDomClient } from "../package-resolution";
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
export const BUN_COMPILE_TARGETS = [
|
|
8
|
+
"bun-linux-x64",
|
|
9
|
+
"bun-linux-arm64",
|
|
10
|
+
"bun-darwin-x64",
|
|
11
|
+
"bun-darwin-arm64",
|
|
12
|
+
"bun-windows-x64",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type BunCompileTarget = (typeof BUN_COMPILE_TARGETS)[number];
|
|
16
|
+
|
|
17
|
+
const BUN_COMPILE_TARGET_SET = new Set<string>(BUN_COMPILE_TARGETS);
|
|
18
|
+
|
|
19
|
+
export function isBunCompileTarget(value: unknown): value is BunCompileTarget {
|
|
20
|
+
return typeof value === "string" && BUN_COMPILE_TARGET_SET.has(value);
|
|
21
|
+
}
|
|
12
22
|
|
|
13
23
|
export interface BuildWebAppBinaryOptions {
|
|
14
24
|
entrypoint: string;
|
|
@@ -25,14 +35,59 @@ export interface BuildWebAppBinaryOptions {
|
|
|
25
35
|
};
|
|
26
36
|
}
|
|
27
37
|
|
|
38
|
+
function formatTargetValue(value: unknown): string {
|
|
39
|
+
if (value === undefined) return "<missing>";
|
|
40
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function supportedTargetMessage(): string {
|
|
45
|
+
return `Supported Bun compile targets: ${BUN_COMPILE_TARGETS.join(", ")}.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertBunCompileTarget(value: unknown): asserts value is BunCompileTarget {
|
|
49
|
+
if (!isBunCompileTarget(value)) {
|
|
50
|
+
throw new Error(`Invalid Bun compile target ${formatTargetValue(value)}. ${supportedTargetMessage()}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
export function getBunCompileTargetFromArgs(argv = Bun.argv): BunCompileTarget | undefined {
|
|
29
|
-
const
|
|
30
|
-
|
|
55
|
+
const targetArguments: Array<{ argument: string; value: string }> = [];
|
|
56
|
+
for (const [index, argument] of argv.entries()) {
|
|
57
|
+
if (argument === undefined) continue;
|
|
58
|
+
if (argument === "--target" || (argument.startsWith("--target") && !argument.startsWith("--target="))) {
|
|
59
|
+
const nextArgument = argv[index + 1];
|
|
60
|
+
const received = argument === "--target" && nextArgument && !nextArgument.startsWith("--")
|
|
61
|
+
? `${argument} ${nextArgument}`
|
|
62
|
+
: argument;
|
|
63
|
+
throw new Error(`Malformed Bun compile target option ${formatTargetValue(received)}. Expected --target=<target>. ${supportedTargetMessage()}`);
|
|
64
|
+
}
|
|
65
|
+
if (argument.startsWith("--target=")) {
|
|
66
|
+
targetArguments.push({
|
|
67
|
+
argument,
|
|
68
|
+
value: argument.slice("--target=".length),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (targetArguments.length === 0) return undefined;
|
|
73
|
+
if (targetArguments.length > 1) {
|
|
74
|
+
throw new Error(`Duplicate Bun compile target options are not allowed: ${targetArguments.map(({ argument }) => argument).join(", ")}. ${supportedTargetMessage()}`);
|
|
75
|
+
}
|
|
76
|
+
const targetArgument = targetArguments[0];
|
|
77
|
+
if (targetArgument === undefined) return undefined;
|
|
78
|
+
const { value } = targetArgument;
|
|
79
|
+
assertBunCompileTarget(value);
|
|
80
|
+
return value;
|
|
31
81
|
}
|
|
32
82
|
|
|
33
83
|
export async function buildWebAppBinary(options: BuildWebAppBinaryOptions): Promise<void> {
|
|
84
|
+
if (options.target !== undefined) {
|
|
85
|
+
assertBunCompileTarget(options.target);
|
|
86
|
+
}
|
|
87
|
+
const entrypoint = resolve(options.entrypoint);
|
|
88
|
+
const packageRoot = findPackageRoot(dirname(entrypoint));
|
|
89
|
+
const reactDomClientPath = resolveReactDomClient(packageRoot, entrypoint);
|
|
34
90
|
mkdirSync(dirname(options.outfile), { recursive: true });
|
|
35
|
-
const packageRoot = findPackageRoot(dirname(resolve(options.entrypoint)));
|
|
36
91
|
const buildId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
37
92
|
const cacheDir = resolve(packageRoot, ".cache", "webapp-build", buildId);
|
|
38
93
|
try {
|
|
@@ -41,7 +96,7 @@ export async function buildWebAppBinary(options: BuildWebAppBinaryOptions): Prom
|
|
|
41
96
|
const rendererEntry = resolve(cacheDir, "webapp-renderer-prelude.ts");
|
|
42
97
|
const clientEntry = resolve(cacheDir, "webapp-client-entry.ts");
|
|
43
98
|
const browserOutDir = resolve(cacheDir, "browser");
|
|
44
|
-
writeFileSync(rendererEntry, `import { createRoot } from "
|
|
99
|
+
writeFileSync(rendererEntry, `import { createRoot } from ${JSON.stringify(reactDomClientPath.replaceAll("\\", "/"))};
|
|
45
100
|
import { configureWebAppRenderer } from "@pablozaiden/webapp/web";
|
|
46
101
|
|
|
47
102
|
configureWebAppRenderer(createRoot);
|
|
@@ -90,7 +145,7 @@ configureWebAppRenderer(createRoot);
|
|
|
90
145
|
`);
|
|
91
146
|
const compiledEntrypoint = resolve(cacheDir, "entrypoint.ts");
|
|
92
147
|
writeFileSync(compiledEntrypoint, `import "./compiled-webapp-assets";
|
|
93
|
-
import ${JSON.stringify(
|
|
148
|
+
import ${JSON.stringify(entrypoint)};
|
|
94
149
|
`);
|
|
95
150
|
const result = await Bun.build({
|
|
96
151
|
entrypoints: [compiledEntrypoint],
|
|
@@ -114,18 +169,6 @@ import ${JSON.stringify(resolve(options.entrypoint))};
|
|
|
114
169
|
}
|
|
115
170
|
}
|
|
116
171
|
|
|
117
|
-
function findPackageRoot(start: string): string {
|
|
118
|
-
let current = start;
|
|
119
|
-
while (true) {
|
|
120
|
-
if (existsSync(resolve(current, "package.json"))) {
|
|
121
|
-
return current;
|
|
122
|
-
}
|
|
123
|
-
const parent = dirname(current);
|
|
124
|
-
if (parent === current) return start;
|
|
125
|
-
current = parent;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
172
|
function contentTypeForOutput(ext: string): string {
|
|
130
173
|
if (ext === ".js") return "text/javascript; charset=utf-8";
|
|
131
174
|
if (ext === ".css") return "text/css; charset=utf-8";
|
package/src/cli/api-command.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { findRouteCatalogEntry, type RouteCatalogEntry } from "../server/route-catalog";
|
|
3
|
-
import { getAuthorizedHeaders, refreshDeviceCredentials, type StoredDeviceCredentials } from "./device-auth";
|
|
3
|
+
import { getAuthorizedHeaders, refreshDeviceCredentials, type DeviceCredentialsStore, type StoredDeviceCredentials } from "./device-auth";
|
|
4
|
+
import { resolveEnvironmentApiKeyAuth, type CliEnvironment } from "./environment-auth";
|
|
4
5
|
import { readOption, type CliCommandResult } from "./runtime";
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
+
export type ApiCliCredentialsStore = DeviceCredentialsStore & {
|
|
7
8
|
read(): Promise<StoredDeviceCredentials | undefined>;
|
|
8
|
-
|
|
9
|
-
}
|
|
9
|
+
};
|
|
10
10
|
|
|
11
11
|
export interface ApiCliCommandOptions {
|
|
12
12
|
catalog: readonly RouteCatalogEntry[];
|
|
@@ -14,6 +14,8 @@ export interface ApiCliCommandOptions {
|
|
|
14
14
|
mode?: "api" | "schema";
|
|
15
15
|
baseUrl?: string;
|
|
16
16
|
credentials?: ApiCliCredentialsStore;
|
|
17
|
+
envPrefix?: string;
|
|
18
|
+
environment?: CliEnvironment;
|
|
17
19
|
fetchFn?: typeof fetch;
|
|
18
20
|
now?: () => Date;
|
|
19
21
|
}
|
|
@@ -54,17 +56,51 @@ function endpointArg(args: string[]): string | undefined {
|
|
|
54
56
|
return args.find((arg) => !arg.startsWith("--"));
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
type ApiCliAuthSource = "device" | "environment" | "anonymous";
|
|
60
|
+
|
|
61
|
+
interface ResolvedApiCliAuth {
|
|
62
|
+
headers: Headers;
|
|
63
|
+
source: ApiCliAuthSource;
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function apiKeyHeaders(apiKey: string, headers: HeadersInit): Headers {
|
|
68
|
+
const result = new Headers(headers);
|
|
69
|
+
result.set("authorization", `Bearer ${apiKey}`);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveAuth(input: ApiCliCommandOptions): Promise<ResolvedApiCliAuth> {
|
|
58
74
|
const headers = new Headers({ accept: "application/json" });
|
|
59
75
|
const stored = await input.credentials?.read();
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
if (stored) {
|
|
77
|
+
const refreshed = await refreshDeviceCredentials({
|
|
78
|
+
credentials: stored,
|
|
79
|
+
store: input.credentials,
|
|
80
|
+
fetchFn: input.fetchFn,
|
|
81
|
+
now: input.now,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
headers: refreshed ? getAuthorizedHeaders(refreshed, headers) : headers,
|
|
85
|
+
source: "device",
|
|
86
|
+
baseUrl: (refreshed ?? stored).baseUrl,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (input.envPrefix) {
|
|
90
|
+
const environmentAuth = resolveEnvironmentApiKeyAuth({
|
|
91
|
+
envPrefix: input.envPrefix,
|
|
92
|
+
explicitBaseUrl: input.baseUrl,
|
|
93
|
+
environment: input.environment,
|
|
94
|
+
});
|
|
95
|
+
if (environmentAuth) {
|
|
96
|
+
return {
|
|
97
|
+
headers: apiKeyHeaders(environmentAuth.apiKey, headers),
|
|
98
|
+
source: "environment",
|
|
99
|
+
baseUrl: environmentAuth.baseUrl,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { headers, source: "anonymous" };
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
export async function runApiCliCommand(input: ApiCliCommandOptions): Promise<CliCommandResult> {
|
|
@@ -84,9 +120,10 @@ export async function runApiCliCommand(input: ApiCliCommandOptions): Promise<Cli
|
|
|
84
120
|
return { exitCode: 1, error: `Method ${method} is not available for ${match.entry.cliPath}` };
|
|
85
121
|
}
|
|
86
122
|
const payload = readOption(input.args, ["--payload", "--data", "-d"]);
|
|
87
|
-
const
|
|
123
|
+
const auth = await resolveAuth(input);
|
|
124
|
+
const baseUrl = (input.baseUrl ?? auth.baseUrl ?? "http://localhost:3000").replace(/\/+$/, "");
|
|
88
125
|
const url = new URL(`${baseUrl}${match.path}`);
|
|
89
|
-
const headers =
|
|
126
|
+
const headers = auth.headers;
|
|
90
127
|
let body: string | undefined;
|
|
91
128
|
if (payload !== undefined) {
|
|
92
129
|
JSON.parse(payload) as unknown;
|
|
@@ -95,7 +132,7 @@ export async function runApiCliCommand(input: ApiCliCommandOptions): Promise<Cli
|
|
|
95
132
|
}
|
|
96
133
|
const send = () => (input.fetchFn ?? fetch)(url, { method, headers, body });
|
|
97
134
|
let response = await send();
|
|
98
|
-
if (response.status === 401 && input.credentials) {
|
|
135
|
+
if (response.status === 401 && auth.source === "device" && input.credentials) {
|
|
99
136
|
const stored = await input.credentials.read();
|
|
100
137
|
if (stored) {
|
|
101
138
|
const refreshed = await refreshDeviceCredentials({ credentials: { ...stored, accessTokenExpiresAt: new Date(0).toISOString() }, store: input.credentials, fetchFn: input.fetchFn, now: input.now });
|
package/src/cli/credentials.ts
CHANGED
|
@@ -1,12 +1,281 @@
|
|
|
1
1
|
import { chmodSync } from "node:fs";
|
|
2
|
-
import { mkdir, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { link, mkdir, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface JsonFileStoreLockOptions {
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
staleAfterMs?: number;
|
|
8
|
+
pollIntervalMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type JsonFileStoreLockErrorCode = "timeout" | "release";
|
|
12
|
+
|
|
13
|
+
export class JsonFileStoreLockError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
public readonly code: JsonFileStoreLockErrorCode,
|
|
16
|
+
message: string,
|
|
17
|
+
options?: ErrorOptions,
|
|
18
|
+
) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.name = "JsonFileStoreLockError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
|
|
5
24
|
export interface JsonFileStore<T> {
|
|
6
25
|
path(): string;
|
|
7
26
|
read(): Promise<T | undefined>;
|
|
8
27
|
write(value: T): Promise<void>;
|
|
9
28
|
clear(): Promise<void>;
|
|
29
|
+
withLock?<R>(callback: () => Promise<R>, options?: JsonFileStoreLockOptions): Promise<R>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LOCK_METADATA_VERSION = 1;
|
|
33
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
|
34
|
+
const DEFAULT_LOCK_STALE_AFTER_MS = 5 * 60_000;
|
|
35
|
+
const DEFAULT_LOCK_POLL_INTERVAL_MS = 25;
|
|
36
|
+
|
|
37
|
+
interface LockMetadata {
|
|
38
|
+
version: typeof LOCK_METADATA_VERSION;
|
|
39
|
+
pid: number;
|
|
40
|
+
createdAt: number;
|
|
41
|
+
owner: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OwnedLock {
|
|
45
|
+
path: string;
|
|
46
|
+
metadata: LockMetadata;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type LockState = LockMetadata | "invalid" | undefined;
|
|
50
|
+
|
|
51
|
+
function errorCode(error: unknown): string | undefined {
|
|
52
|
+
if (!error || typeof error !== "object" || !("code" in error)) return undefined;
|
|
53
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureFiniteOption(name: string, value: number, minimum: number): number {
|
|
57
|
+
if (!Number.isFinite(value) || value < minimum) {
|
|
58
|
+
throw new RangeError(`${name} must be a finite number >= ${minimum}`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveLockOptions(options?: JsonFileStoreLockOptions): Required<JsonFileStoreLockOptions> {
|
|
64
|
+
return {
|
|
65
|
+
timeoutMs: ensureFiniteOption("timeoutMs", options?.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, 0),
|
|
66
|
+
staleAfterMs: ensureFiniteOption("staleAfterMs", options?.staleAfterMs ?? DEFAULT_LOCK_STALE_AFTER_MS, 0),
|
|
67
|
+
pollIntervalMs: ensureFiniteOption("pollIntervalMs", options?.pollIntervalMs ?? DEFAULT_LOCK_POLL_INTERVAL_MS, 1),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function secureDirectory(path: string): Promise<void> {
|
|
72
|
+
return mkdir(path, { recursive: true, mode: 0o700 }).then(() => {
|
|
73
|
+
chmodIfPossible(path, 0o700);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function lockPathFor(target: string): string {
|
|
78
|
+
return `${target}.lock`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function lockCandidatePath(lockPath: string): string {
|
|
82
|
+
const dir = dirname(lockPath);
|
|
83
|
+
return join(dir, `.${basename(lockPath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseLockMetadata(value: string): LockMetadata | "invalid" {
|
|
87
|
+
if (value.length > 4096) return "invalid";
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(value) as unknown;
|
|
90
|
+
if (!parsed || typeof parsed !== "object") return "invalid";
|
|
91
|
+
const record = parsed as Record<string, unknown>;
|
|
92
|
+
if (
|
|
93
|
+
record["version"] !== LOCK_METADATA_VERSION ||
|
|
94
|
+
!Number.isSafeInteger(record["pid"]) ||
|
|
95
|
+
Number(record["pid"]) <= 0 ||
|
|
96
|
+
typeof record["createdAt"] !== "number" ||
|
|
97
|
+
!Number.isFinite(record["createdAt"]) ||
|
|
98
|
+
typeof record["owner"] !== "string" ||
|
|
99
|
+
record["owner"].length === 0
|
|
100
|
+
) {
|
|
101
|
+
return "invalid";
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
version: LOCK_METADATA_VERSION,
|
|
105
|
+
pid: record["pid"] as number,
|
|
106
|
+
createdAt: record["createdAt"],
|
|
107
|
+
owner: record["owner"],
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return "invalid";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readLockMetadata(path: string): Promise<LockState> {
|
|
115
|
+
try {
|
|
116
|
+
return parseLockMetadata(await readFile(path, "utf8"));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (errorCode(error) === "ENOENT") return undefined;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isProcessAlive(pid: number): boolean {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return errorCode(error) !== "ESRCH";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sameOwner(left: LockMetadata, right: LockMetadata): boolean {
|
|
133
|
+
return left.version === right.version && left.pid === right.pid && left.createdAt === right.createdAt && left.owner === right.owner;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function removePublishedLock(path: string, metadata: LockMetadata): Promise<void> {
|
|
137
|
+
const current = await readLockMetadata(path);
|
|
138
|
+
if (!current || current === "invalid" || !sameOwner(current, metadata)) return;
|
|
139
|
+
await rm(path, { force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function publishLock(path: string, metadata: LockMetadata): Promise<boolean> {
|
|
143
|
+
const candidate = lockCandidatePath(path);
|
|
144
|
+
let linked = false;
|
|
145
|
+
let operationError: unknown;
|
|
146
|
+
try {
|
|
147
|
+
await Bun.write(candidate, `${JSON.stringify(metadata)}\n`);
|
|
148
|
+
chmodIfPossible(candidate, 0o600);
|
|
149
|
+
try {
|
|
150
|
+
await link(candidate, path);
|
|
151
|
+
linked = true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (errorCode(error) !== "EEXIST") operationError = error;
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
operationError = error;
|
|
157
|
+
}
|
|
158
|
+
let cleanupError: unknown;
|
|
159
|
+
try {
|
|
160
|
+
await rm(candidate, { force: true });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
cleanupError = error;
|
|
163
|
+
}
|
|
164
|
+
if (cleanupError !== undefined) {
|
|
165
|
+
const errors: unknown[] = [cleanupError];
|
|
166
|
+
if (operationError !== undefined) errors.unshift(operationError);
|
|
167
|
+
if (linked) {
|
|
168
|
+
try {
|
|
169
|
+
await removePublishedLock(path, metadata);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
errors.push(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new AggregateError(errors, "Unable to create credentials lock");
|
|
175
|
+
}
|
|
176
|
+
if (operationError !== undefined) throw operationError;
|
|
177
|
+
return linked;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function reclaimStaleLock(path: string, expected: LockMetadata): Promise<void> {
|
|
181
|
+
const current = await readLockMetadata(path);
|
|
182
|
+
if (!current || current === "invalid" || !sameOwner(current, expected) || isProcessAlive(current.pid)) return;
|
|
183
|
+
const confirmed = await readLockMetadata(path);
|
|
184
|
+
if (!confirmed || confirmed === "invalid" || !sameOwner(confirmed, expected) || isProcessAlive(confirmed.pid)) return;
|
|
185
|
+
try {
|
|
186
|
+
await rm(path);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function waitForLock(ms: number): Promise<void> {
|
|
193
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function acquireFileLock(target: string, options?: JsonFileStoreLockOptions): Promise<OwnedLock> {
|
|
197
|
+
const resolved = resolveLockOptions(options);
|
|
198
|
+
const path = lockPathFor(target);
|
|
199
|
+
await secureDirectory(dirname(target));
|
|
200
|
+
const metadata: LockMetadata = {
|
|
201
|
+
version: LOCK_METADATA_VERSION,
|
|
202
|
+
pid: process.pid,
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
owner: crypto.randomUUID(),
|
|
205
|
+
};
|
|
206
|
+
const deadline = Date.now() + resolved.timeoutMs;
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
if (await publishLock(path, metadata)) return { path, metadata };
|
|
210
|
+
|
|
211
|
+
const current = await readLockMetadata(path);
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (
|
|
214
|
+
current &&
|
|
215
|
+
current !== "invalid" &&
|
|
216
|
+
current.createdAt <= now - resolved.staleAfterMs &&
|
|
217
|
+
!isProcessAlive(current.pid)
|
|
218
|
+
) {
|
|
219
|
+
await reclaimStaleLock(path, current);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (now >= deadline) {
|
|
223
|
+
throw new JsonFileStoreLockError("timeout", "Timed out acquiring credentials lock");
|
|
224
|
+
}
|
|
225
|
+
await waitForLock(Math.min(resolved.pollIntervalMs, Math.max(1, deadline - now)));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function releaseFileLock(lock: OwnedLock): Promise<void> {
|
|
230
|
+
let current: LockState;
|
|
231
|
+
try {
|
|
232
|
+
current = await readLockMetadata(lock.path);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new JsonFileStoreLockError("release", "Unable to verify credentials lock ownership", { cause: error });
|
|
235
|
+
}
|
|
236
|
+
if (current === undefined) return;
|
|
237
|
+
if (current === "invalid" || !sameOwner(current, lock.metadata)) {
|
|
238
|
+
throw new JsonFileStoreLockError("release", "Unable to verify credentials lock ownership");
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await rm(lock.path);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (errorCode(error) === "ENOENT") return;
|
|
244
|
+
throw new JsonFileStoreLockError("release", "Unable to release credentials lock", { cause: error });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function withFileLock<T>(
|
|
249
|
+
target: string,
|
|
250
|
+
callback: () => Promise<T>,
|
|
251
|
+
options?: JsonFileStoreLockOptions,
|
|
252
|
+
): Promise<T> {
|
|
253
|
+
const lock = await acquireFileLock(target, options);
|
|
254
|
+
let result!: T;
|
|
255
|
+
let callbackFailed = false;
|
|
256
|
+
let callbackError: unknown;
|
|
257
|
+
try {
|
|
258
|
+
result = await callback();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
callbackFailed = true;
|
|
261
|
+
callbackError = error;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let releaseFailed = false;
|
|
265
|
+
let releaseError: unknown;
|
|
266
|
+
try {
|
|
267
|
+
await releaseFileLock(lock);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
releaseFailed = true;
|
|
270
|
+
releaseError = error;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (callbackFailed && releaseFailed) {
|
|
274
|
+
throw new AggregateError([callbackError, releaseError], "Credentials refresh and lock release failed");
|
|
275
|
+
}
|
|
276
|
+
if (callbackFailed) throw callbackError;
|
|
277
|
+
if (releaseFailed) throw releaseError;
|
|
278
|
+
return result;
|
|
10
279
|
}
|
|
11
280
|
|
|
12
281
|
function chmodIfPossible(path: string, mode: number): void {
|
|
@@ -47,8 +316,7 @@ export function createJsonFileStore<T>(input: {
|
|
|
47
316
|
async write(value) {
|
|
48
317
|
const target = filePath();
|
|
49
318
|
const dir = dirname(target);
|
|
50
|
-
await
|
|
51
|
-
chmodIfPossible(dir, 0o700);
|
|
319
|
+
await secureDirectory(dir);
|
|
52
320
|
const temp = join(dir, `.${input.fileName}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
53
321
|
try {
|
|
54
322
|
await Bun.write(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
@@ -63,5 +331,8 @@ export function createJsonFileStore<T>(input: {
|
|
|
63
331
|
async clear() {
|
|
64
332
|
await rm(filePath(), { force: true });
|
|
65
333
|
},
|
|
334
|
+
async withLock<R>(callback: () => Promise<R>, options?: JsonFileStoreLockOptions) {
|
|
335
|
+
return withFileLock(filePath(), callback, options);
|
|
336
|
+
},
|
|
66
337
|
};
|
|
67
338
|
}
|