@pablozaiden/webapp 0.5.8 → 0.6.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.
Files changed (56) hide show
  1. package/README.md +12 -4
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +33 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/index.ts +1 -0
  27. package/src/server/public-route-dispatch.ts +85 -0
  28. package/src/server/request-schemas.ts +144 -0
  29. package/src/server/responses.ts +69 -3
  30. package/src/server/route-dispatch.ts +109 -0
  31. package/src/server/runtime-config.ts +93 -1
  32. package/src/server/same-origin.ts +1 -1
  33. package/src/server/server-lifecycle.ts +138 -0
  34. package/src/server/server-types.ts +87 -0
  35. package/src/server/web-document.ts +532 -0
  36. package/src/web/WebAppRoot.tsx +69 -1214
  37. package/src/web/api-client.ts +14 -4
  38. package/src/web/app-shell.tsx +198 -0
  39. package/src/web/auth-screens.tsx +186 -0
  40. package/src/web/components/index.tsx +10 -3
  41. package/src/web/index.ts +1 -0
  42. package/src/web/mobile-hooks.ts +194 -0
  43. package/src/web/mobile.ts +12 -0
  44. package/src/web/root-types.ts +61 -0
  45. package/src/web/routing.ts +61 -0
  46. package/src/web/settings/account-section.tsx +52 -0
  47. package/src/web/settings/resource-state.tsx +17 -0
  48. package/src/web/settings/security-section.tsx +119 -0
  49. package/src/web/settings/sessions-section.tsx +66 -0
  50. package/src/web/settings/settings-view.tsx +96 -0
  51. package/src/web/settings/shutdown-section.tsx +95 -0
  52. package/src/web/settings/user-management.tsx +123 -0
  53. package/src/web/settings-view.tsx +3 -0
  54. package/src/web/sidebar-state.ts +108 -0
  55. package/src/web/sidebar-tree.tsx +89 -0
  56. package/src/web/styles.css +76 -64
@@ -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`; provides standard margins/padding and mobile spacing |
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
- Generated screenshots live in `artifacts/screenshots`:
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 `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.
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.5.8",
3
+ "version": "0.6.1",
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, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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 type BunCompileTarget =
7
- | "bun-linux-x64"
8
- | "bun-linux-arm64"
9
- | "bun-darwin-x64"
10
- | "bun-darwin-arm64"
11
- | "bun-windows-x64";
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 raw = argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length);
30
- return raw as BunCompileTarget | undefined;
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 "react-dom/client";
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(resolve(options.entrypoint))};
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";
@@ -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 interface ApiCliCredentialsStore {
7
+ export type ApiCliCredentialsStore = DeviceCredentialsStore & {
7
8
  read(): Promise<StoredDeviceCredentials | undefined>;
8
- write(value: StoredDeviceCredentials): Promise<void>;
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
- async function authHeaders(input: ApiCliCommandOptions): Promise<Headers> {
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 (!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;
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 baseUrl = (input.baseUrl ?? "http://localhost:3000").replace(/\/+$/, "");
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 = await authHeaders(input);
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 });
@@ -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 mkdir(dir, { recursive: true, mode: 0o700 });
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
  }