@hansenexus/hud 0.3.0 → 0.4.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 +186 -4
- package/dist/access-CNvJOwKM.d.ts +54 -0
- package/dist/capabilities-CjulXFQX.js +189 -0
- package/dist/{define-hud-BHQR8Lbv.d.ts → define-hud-BBzsSl8b.d.ts} +6 -1
- package/dist/env-DfxpPfs9.js +28 -0
- package/dist/env-vEjyJfPW.d.ts +17 -0
- package/dist/force-state-BaqWsmrf.js +153 -0
- package/dist/force-state-CCqoTfVB.d.ts +23 -0
- package/dist/handler-WmE4Lu7l.d.ts +39 -0
- package/dist/index-BYsdS-7d.d.ts +55 -0
- package/dist/index.d.ts +6 -36
- package/dist/index.js +119 -202
- package/dist/marker-C66LxOhJ.d.ts +9 -0
- package/dist/marker-CMrvxPmS.d.ts +12 -0
- package/dist/marker-DjeGvL-F.js +9 -0
- package/dist/marker-uZYPOJ4M.js +12 -0
- package/dist/markers.d.ts +3 -0
- package/dist/markers.js +3 -0
- package/dist/next.d.ts +3 -18
- package/dist/next.js +3 -28
- package/dist/payload-CLKDVZWl.d.ts +68 -0
- package/dist/plugins/access/server.d.ts +122 -0
- package/dist/plugins/access/server.js +339 -0
- package/dist/plugins/access.d.ts +41 -0
- package/dist/plugins/access.js +250 -0
- package/dist/plugins/agent/server.d.ts +129 -0
- package/dist/plugins/agent/server.js +429 -0
- package/dist/plugins/agent.d.ts +37 -0
- package/dist/plugins/agent.js +411 -0
- package/dist/plugins/design.d.ts +125 -0
- package/dist/plugins/design.js +619 -0
- package/dist/plugins/observe.d.ts +64 -0
- package/dist/plugins/observe.js +617 -0
- package/dist/plugins/ops/server.d.ts +82 -0
- package/dist/plugins/ops/server.js +380 -0
- package/dist/plugins/ops.d.ts +26 -0
- package/dist/plugins/ops.js +362 -0
- package/dist/plugins/vitals.js +3 -86
- package/dist/{plugins-1QmTmZyL.d.ts → plugins-CQ_0w1ot.d.ts} +39 -1
- package/dist/{plugins-CzUiTn_r.js → plugins-DVy_vaYr.js} +3 -1
- package/dist/review.d.ts +181 -0
- package/dist/review.js +1017 -0
- package/dist/selector-NSYSe7AZ.js +28 -0
- package/dist/server.d.ts +3 -39
- package/dist/server.js +1 -1
- package/dist/shortcut-U6ftVY06.js +222 -0
- package/dist/state-boundary.d.ts +26 -0
- package/dist/state-boundary.js +61 -0
- package/dist/styles--slk86ZQ.js +332 -0
- package/dist/types-DADBRiGk.d.ts +50 -0
- package/dist/types-QJUajwWq.js +35 -0
- package/dist/types-f3jlNxzw.d.ts +92 -0
- package/dist/vitals-panel-Dvc5dnR-.js +87 -0
- package/package.json +23 -3
package/dist/next.d.ts
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
|
-
import { n as HudMountProps } from "./define-hud-
|
|
1
|
+
import { n as HudMountProps } from "./define-hud-BBzsSl8b.js";
|
|
2
|
+
import { n as hudEnvLabel, r as resolveHudEnv, t as HudEnv } from "./env-vEjyJfPW.js";
|
|
2
3
|
import { ComponentType } from "react";
|
|
3
|
-
//#region src/next/env.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* Where the HUD believes it runs. An explicit `NEXT_PUBLIC_HUD_ENV` wins over
|
|
6
|
-
* heuristics because k3s deploys have no `VERCEL_ENV`.
|
|
7
|
-
*/
|
|
8
|
-
type HudEnv = "development" | "preview" | "staging" | "production" | "disabled";
|
|
9
|
-
/**
|
|
10
|
-
* Env ladder: kill switch → `NODE_ENV` (inlined by Next, so production
|
|
11
|
-
* bundles fold the development branch) → `NEXT_PUBLIC_HUD_ENV` (k8s overlays)
|
|
12
|
-
* → `VERCEL_ENV` → production.
|
|
13
|
-
*
|
|
14
|
-
* Each read is a literal `process.env.X` so Next can inline it in client code.
|
|
15
|
-
*/
|
|
16
|
-
export declare function resolveHudEnv(): HudEnv;
|
|
17
|
-
export declare function hudEnvLabel(env?: HudEnv): string;
|
|
18
|
-
//#endregion
|
|
19
4
|
//#region src/next/loader.d.ts
|
|
20
5
|
type HudModuleLoader = () => Promise<{
|
|
21
6
|
default: ComponentType<HudMountProps>;
|
|
@@ -45,4 +30,4 @@ type HudModuleLoader = () => Promise<{
|
|
|
45
30
|
*/
|
|
46
31
|
export declare function createHudLoader(load: HudModuleLoader | null): ComponentType<HudMountProps>;
|
|
47
32
|
//#endregion
|
|
48
|
-
export type
|
|
33
|
+
export { type HudEnv, type HudModuleLoader, type HudMountProps, hudEnvLabel, resolveHudEnv };
|
package/dist/next.js
CHANGED
|
@@ -1,32 +1,6 @@
|
|
|
1
|
+
import { n as resolveHudEnv, t as hudEnvLabel } from "./env-DfxpPfs9.js";
|
|
1
2
|
import { Suspense, lazy, useEffect, useState } from "react";
|
|
2
3
|
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
//#region src/next/env.ts
|
|
4
|
-
/**
|
|
5
|
-
* Env ladder: kill switch → `NODE_ENV` (inlined by Next, so production
|
|
6
|
-
* bundles fold the development branch) → `NEXT_PUBLIC_HUD_ENV` (k8s overlays)
|
|
7
|
-
* → `VERCEL_ENV` → production.
|
|
8
|
-
*
|
|
9
|
-
* Each read is a literal `process.env.X` so Next can inline it in client code.
|
|
10
|
-
*/
|
|
11
|
-
function resolveHudEnv() {
|
|
12
|
-
if (process.env.NEXT_PUBLIC_HUD_DISABLED === "true") return "disabled";
|
|
13
|
-
if (process.env.NODE_ENV === "development") return "development";
|
|
14
|
-
const explicit = process.env.NEXT_PUBLIC_HUD_ENV;
|
|
15
|
-
if (explicit === "staging" || explicit === "preview" || explicit === "production") return explicit;
|
|
16
|
-
if (process.env.VERCEL_ENV === "preview") return "preview";
|
|
17
|
-
return "production";
|
|
18
|
-
}
|
|
19
|
-
const LABELS = {
|
|
20
|
-
development: "DEV",
|
|
21
|
-
preview: "PREVIEW",
|
|
22
|
-
staging: "STAGING",
|
|
23
|
-
production: "PROD",
|
|
24
|
-
disabled: "OFF"
|
|
25
|
-
};
|
|
26
|
-
function hudEnvLabel(env = resolveHudEnv()) {
|
|
27
|
-
return LABELS[env];
|
|
28
|
-
}
|
|
29
|
-
//#endregion
|
|
30
4
|
//#region src/next/loader.tsx
|
|
31
5
|
/**
|
|
32
6
|
* Mounts the app's `defineHud` module, lazily and client side only.
|
|
@@ -64,7 +38,8 @@ function createHudLoader(load) {
|
|
|
64
38
|
fallback: null,
|
|
65
39
|
children: /* @__PURE__ */ jsx(LazyHud, {
|
|
66
40
|
envLabel: hudEnvLabel(env),
|
|
67
|
-
...props
|
|
41
|
+
...props,
|
|
42
|
+
env
|
|
68
43
|
})
|
|
69
44
|
});
|
|
70
45
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
//#region src/core/grabber/inspect.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Which React component rendered a DOM element, read from the fiber React
|
|
4
|
+
* attaches to it in development. Best effort by design: fibers are React
|
|
5
|
+
* internals, so every field is probed rather than trusted, and a page that is
|
|
6
|
+
* not React (or a production build) simply yields `null`.
|
|
7
|
+
*/
|
|
8
|
+
interface GrabComponent {
|
|
9
|
+
/** Nearest named component above the element, e.g. `HeroCta`. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Named components further up, nearest first, at most five. */
|
|
12
|
+
owners: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Where the element's JSX was written, e.g. `src/site.tsx:12:5`. A hint, not
|
|
15
|
+
* a resolution: the file is reliable, the line is the position in the
|
|
16
|
+
* dev server's transformed module and can sit a few lines below the original.
|
|
17
|
+
*/
|
|
18
|
+
source?: string;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/core/grabber/payload.d.ts
|
|
22
|
+
/** Box in viewport percentages, measured at grab time. */
|
|
23
|
+
interface GrabRect {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}
|
|
29
|
+
interface GrabAnchor {
|
|
30
|
+
/** `nth-of-type` path from `<body>`. */
|
|
31
|
+
selector: string;
|
|
32
|
+
/** Human label, e.g. `button “Termin buchen”`. */
|
|
33
|
+
label: string;
|
|
34
|
+
tag: string;
|
|
35
|
+
/**
|
|
36
|
+
* Viewport-relative, so correct only for a grab that is acted on at once.
|
|
37
|
+
* Anything that persists a position (review pins) needs document-relative
|
|
38
|
+
* coordinates instead.
|
|
39
|
+
*/
|
|
40
|
+
rect: GrabRect;
|
|
41
|
+
}
|
|
42
|
+
/** One entry of an app's component manifest: a name and the file it lives in. */
|
|
43
|
+
interface ComponentHint {
|
|
44
|
+
name: string;
|
|
45
|
+
path: string;
|
|
46
|
+
}
|
|
47
|
+
/** What a completed grab hands to plugins (`onGrab`, `ctx.grab`). */
|
|
48
|
+
interface GrabPayload {
|
|
49
|
+
app: string;
|
|
50
|
+
url: string;
|
|
51
|
+
route: string;
|
|
52
|
+
anchor: GrabAnchor;
|
|
53
|
+
/** The React component that rendered the element, when the page is React in development. */
|
|
54
|
+
component: GrabComponent | null;
|
|
55
|
+
viewport: {
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
};
|
|
59
|
+
grabbedAt: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Scores manifest entries against the route and anchor label. Pure token
|
|
63
|
+
* overlap: a hint for the agent, not a resolution. Ported from the old HUD's
|
|
64
|
+
* `grabber/payload.ts`.
|
|
65
|
+
*/
|
|
66
|
+
declare function matchComponentHints(hints: readonly ComponentHint[] | undefined, route: string, label: string, max?: number): ComponentHint[];
|
|
67
|
+
//#endregion
|
|
68
|
+
export { GrabComponent as a, matchComponentHints as i, GrabAnchor as n, GrabPayload as r, ComponentHint as t };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { r as HudCapability } from "../../plugins-CQ_0w1ot.js";
|
|
2
|
+
import { t as HudEnv } from "../../env-vEjyJfPW.js";
|
|
3
|
+
import { a as signHudUnlockToken, c as CAPABILITY_MATRIX, d as hasCapability, f as isHudVisible, i as resolveHudAccess, l as CapabilityMatrixOverride, n as HudAccess, o as verifyHudUnlockToken, r as hudRequiresUnlock, s as ACCESS_UNLOCK_CAPABILITY, t as HUD_UNLOCK_COOKIE, u as HUD_CAPABILITIES } from "../../access-CNvJOwKM.js";
|
|
4
|
+
import { t as HudGuard } from "../../handler-WmE4Lu7l.js";
|
|
5
|
+
//#region src/plugins/access/server/settings.d.ts
|
|
6
|
+
/** What the server half reads from its environment, per request. */
|
|
7
|
+
interface AccessSettings {
|
|
8
|
+
env: HudEnv;
|
|
9
|
+
/** `NEXT_PUBLIC_HUD_ENABLED=true`: the deployed tier opted in. */
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
/** `HUD_ACCESS_PASSWORD`. Server only: never pass it to client code. */
|
|
12
|
+
password: string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* `HUD_ACCESS_SECRET`: optional, server only. When set, the unlock cookie is
|
|
15
|
+
* signed with it instead of the password, so a stolen cookie gives nothing
|
|
16
|
+
* to guess the password against.
|
|
17
|
+
*/
|
|
18
|
+
secret?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
interface AccessServerOptions {
|
|
21
|
+
/** Path `createHudHandler` is mounted at, for the guard's unlock exemption. Default `/api/hud`. */
|
|
22
|
+
basePath?: string;
|
|
23
|
+
/** Replaces the default matrix's list for a deployed tier. Keep it equal to the client half's. */
|
|
24
|
+
matrix?: CapabilityMatrixOverride;
|
|
25
|
+
/** Default: `readAccessSettings`, i.e. `process.env`. A seam for tests and the playground. */
|
|
26
|
+
settings?: () => AccessSettings;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reads the env ladder, the opt-in flag and the secrets, at call time.
|
|
30
|
+
* `HUD_DISABLED=true` is a runtime kill switch: unlike the `NEXT_PUBLIC_*`
|
|
31
|
+
* flags, which are inlined at build time, it takes effect on a restart
|
|
32
|
+
* without a rebuild, and turns every route into a 404.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readAccessSettings(): AccessSettings;
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/plugins/access/server/guard.d.ts
|
|
37
|
+
interface HudGuardRequest {
|
|
38
|
+
headers: {
|
|
39
|
+
get(name: string): string | null;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
type HudGuardResult = {
|
|
43
|
+
ok: true;
|
|
44
|
+
env: HudEnv;
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
status: 401 | 403 | 404;
|
|
48
|
+
error: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Whether this request may exercise `capability` right now.
|
|
52
|
+
*
|
|
53
|
+
* Order matters: a HUD that is not enabled answers 404 (nothing here), a
|
|
54
|
+
* denied capability answers 403 before any password is considered (the
|
|
55
|
+
* password must never be a way to widen the matrix), and only a permitted
|
|
56
|
+
* capability gets as far as the unlock check. A route without a capability
|
|
57
|
+
* is development-only: default-deny means undeclared is denied.
|
|
58
|
+
*/
|
|
59
|
+
export declare function checkHudRequest(request: HudGuardRequest, capability: HudCapability | undefined, options?: AccessServerOptions): Promise<HudGuardResult>;
|
|
60
|
+
/** Whether the request already carries a valid unlock (the status route). */
|
|
61
|
+
export declare function isHudRequestUnlocked(request: HudGuardRequest, options?: AccessServerOptions): Promise<boolean>;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/plugins/access/server/unlock.d.ts
|
|
64
|
+
interface HudUnlockRequest extends HudGuardRequest {
|
|
65
|
+
json(): Promise<unknown>;
|
|
66
|
+
}
|
|
67
|
+
interface HudUnlockResult {
|
|
68
|
+
status: number;
|
|
69
|
+
body: Record<string, unknown>;
|
|
70
|
+
/** `Set-Cookie` value, when the attempt changed the unlock state. */
|
|
71
|
+
setCookie?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* GET: what the client needs to decide between the panels and the lock, and
|
|
75
|
+
* nothing more. The route is reachable while locked, so it names no tier.
|
|
76
|
+
*/
|
|
77
|
+
export declare function handleHudUnlockStatus(request: HudGuardRequest, options?: AccessServerOptions): Promise<HudUnlockResult>;
|
|
78
|
+
/** POST: exchanges the password for a signed, expiring unlock cookie. */
|
|
79
|
+
export declare function handleHudUnlockAttempt(request: HudUnlockRequest, options?: AccessServerOptions): Promise<HudUnlockResult>;
|
|
80
|
+
/** Test seam: the attempt budget is process state, not request state. */
|
|
81
|
+
export declare function resetHudUnlockAttempts(): void;
|
|
82
|
+
/** Test seam: how many clients the attempt budget tracks. */
|
|
83
|
+
export declare function trackedHudUnlockClients(): number;
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/plugins/access/server.d.ts
|
|
86
|
+
/**
|
|
87
|
+
* The guard for `createHudHandler({ guard })` on deployed tiers. It answers
|
|
88
|
+
* exactly like the old `checkHudRequest`: 404 while the tier has not opted in,
|
|
89
|
+
* 403 for a capability the tier's matrix denies (before any password is
|
|
90
|
+
* considered), 401 until the request carries a valid unlock cookie. In
|
|
91
|
+
* development it lets everything through, like `developmentOnly`.
|
|
92
|
+
*
|
|
93
|
+
* The unlock route itself is the one exemption, so a locked HUD can unlock.
|
|
94
|
+
* Pass the handler's `basePath` when it is not `/api/hud`.
|
|
95
|
+
*/
|
|
96
|
+
export declare function accessGuard(options?: AccessServerOptions): HudGuard;
|
|
97
|
+
/**
|
|
98
|
+
* Server half of the access plugin: `GET|POST <basePath>/access/unlock`.
|
|
99
|
+
* GET reports `{ unlocked, configured }`; POST trades `{ password }` for an
|
|
100
|
+
* httpOnly cookie. Wrong passwords are budgeted: ten per client per ten
|
|
101
|
+
* minutes, and past a hundred overall every client with failures of its own
|
|
102
|
+
* is throttled; a throttled client gets 429 even for the right password.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const options = {};
|
|
106
|
+
* export const { GET, POST } = createHudHandler([accessServer(options), ...], {
|
|
107
|
+
* guard: accessGuard(options),
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export declare function accessServer(options?: AccessServerOptions): {
|
|
112
|
+
readonly id: "access";
|
|
113
|
+
readonly capability: "access.unlock";
|
|
114
|
+
readonly routes: {
|
|
115
|
+
readonly "/unlock": {
|
|
116
|
+
readonly GET: (request: Request) => Promise<Response>;
|
|
117
|
+
readonly POST: (request: Request) => Promise<Response>;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
export { ACCESS_UNLOCK_CAPABILITY, type AccessServerOptions, type AccessSettings, CAPABILITY_MATRIX, type CapabilityMatrixOverride, HUD_CAPABILITIES, HUD_UNLOCK_COOKIE, type HudAccess, type HudGuardResult, hasCapability, hudRequiresUnlock, isHudVisible, resolveHudAccess, signHudUnlockToken, verifyHudUnlockToken };
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { i as definePlugin } from "../../plugins-DVy_vaYr.js";
|
|
2
|
+
import { n as resolveHudEnv } from "../../env-DfxpPfs9.js";
|
|
3
|
+
import { a as isHudVisible, c as HUD_UNLOCK_COOKIE, d as hudUnlockTtlMs, f as readCookie, g as verifyHudUnlockToken, h as timingSafeEqual, i as hasCapability, l as hudRequiresUnlock, m as signHudUnlockToken, n as CAPABILITY_MATRIX, o as resolveMatrix, p as resolveHudAccess, r as HUD_CAPABILITIES, t as ACCESS_UNLOCK_CAPABILITY, u as hudUnlockCookieString } from "../../capabilities-CjulXFQX.js";
|
|
4
|
+
//#region src/plugins/access/server/settings.ts
|
|
5
|
+
/** The key material unlock cookies are signed with. */
|
|
6
|
+
function signingSecret({ secret, password }) {
|
|
7
|
+
return secret || password;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Reads the env ladder, the opt-in flag and the secrets, at call time.
|
|
11
|
+
* `HUD_DISABLED=true` is a runtime kill switch: unlike the `NEXT_PUBLIC_*`
|
|
12
|
+
* flags, which are inlined at build time, it takes effect on a restart
|
|
13
|
+
* without a rebuild, and turns every route into a 404.
|
|
14
|
+
*/
|
|
15
|
+
function readAccessSettings() {
|
|
16
|
+
return {
|
|
17
|
+
env: process.env.HUD_DISABLED === "true" ? "disabled" : resolveHudEnv(),
|
|
18
|
+
enabled: process.env.NEXT_PUBLIC_HUD_ENABLED === "true",
|
|
19
|
+
password: process.env.HUD_ACCESS_PASSWORD || void 0,
|
|
20
|
+
secret: process.env.HUD_ACCESS_SECRET || void 0
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/plugins/access/server/guard.ts
|
|
25
|
+
/**
|
|
26
|
+
* Whether this request may exercise `capability` right now.
|
|
27
|
+
*
|
|
28
|
+
* Order matters: a HUD that is not enabled answers 404 (nothing here), a
|
|
29
|
+
* denied capability answers 403 before any password is considered (the
|
|
30
|
+
* password must never be a way to widen the matrix), and only a permitted
|
|
31
|
+
* capability gets as far as the unlock check. A route without a capability
|
|
32
|
+
* is development-only: default-deny means undeclared is denied.
|
|
33
|
+
*/
|
|
34
|
+
async function checkHudRequest(request, capability, options = {}) {
|
|
35
|
+
const settings = (options.settings ?? readAccessSettings)();
|
|
36
|
+
const { env, enabled, password } = settings;
|
|
37
|
+
if (!isHudVisible(env, enabled)) return {
|
|
38
|
+
ok: false,
|
|
39
|
+
status: 404,
|
|
40
|
+
error: "not found"
|
|
41
|
+
};
|
|
42
|
+
if (env !== "development" && (capability === void 0 || !hasCapability(env, capability, resolveMatrix(options.matrix)))) return {
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 403,
|
|
45
|
+
error: "forbidden"
|
|
46
|
+
};
|
|
47
|
+
const access = resolveHudAccess(env);
|
|
48
|
+
if (!hudRequiresUnlock(access)) return {
|
|
49
|
+
ok: true,
|
|
50
|
+
env
|
|
51
|
+
};
|
|
52
|
+
if (!password) return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 401,
|
|
55
|
+
error: "hud unlock is not configured"
|
|
56
|
+
};
|
|
57
|
+
const token = readCookie(request.headers.get("cookie"), HUD_UNLOCK_COOKIE);
|
|
58
|
+
if (!await verifyHudUnlockToken(token, signingSecret(settings), env, { ttlMs: hudUnlockTtlMs(access) })) return {
|
|
59
|
+
ok: false,
|
|
60
|
+
status: 401,
|
|
61
|
+
error: "hud is locked"
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
env
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Whether the request already carries a valid unlock (the status route). */
|
|
69
|
+
async function isHudRequestUnlocked(request, options = {}) {
|
|
70
|
+
const settings = (options.settings ?? readAccessSettings)();
|
|
71
|
+
const access = resolveHudAccess(settings.env);
|
|
72
|
+
if (access === "denied") return false;
|
|
73
|
+
if (!hudRequiresUnlock(access)) return true;
|
|
74
|
+
if (!settings.password) return false;
|
|
75
|
+
const token = readCookie(request.headers.get("cookie"), HUD_UNLOCK_COOKIE);
|
|
76
|
+
return verifyHudUnlockToken(token, signingSecret(settings), settings.env, { ttlMs: hudUnlockTtlMs(access) });
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/plugins/access/server/unlock.ts
|
|
80
|
+
/**
|
|
81
|
+
* The unlock route, `<basePath>/access/unlock`. Verification is an HMAC over
|
|
82
|
+
* the tier and the token's own issue time (see ../access.ts), so nothing is
|
|
83
|
+
* stored server side apart from the failed-attempt budget.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* Failed-attempt budget, per process. The password is the only barrier in
|
|
87
|
+
* front of a deployed HUD, and a pod lives long enough that unlimited
|
|
88
|
+
* guessing is a real risk. Deliberately crude: an in-memory counter is
|
|
89
|
+
* enough friction for an operator-facing surface, and it cannot fail open
|
|
90
|
+
* the way a shared store can when it is unreachable.
|
|
91
|
+
*
|
|
92
|
+
* The throttle is checked before the password, so a throttled client gets
|
|
93
|
+
* 429 even for the right one; a guess that lands while throttled is worth
|
|
94
|
+
* nothing. Two budgets:
|
|
95
|
+
*
|
|
96
|
+
* - ten failures per client;
|
|
97
|
+
* - a ceiling of a hundred across all clients, against address rotation.
|
|
98
|
+
* It blocks only clients with recent failures of their own, so an
|
|
99
|
+
* operator who has not mistyped still unlocks during an attack.
|
|
100
|
+
*/
|
|
101
|
+
const ATTEMPT_WINDOW_MS = 6e5;
|
|
102
|
+
const MAX_FAILED_ATTEMPTS = 10;
|
|
103
|
+
const MAX_FAILED_GLOBAL = 100;
|
|
104
|
+
/** Most clients tracked at once. */
|
|
105
|
+
const MAX_TRACKED = 1e3;
|
|
106
|
+
/**
|
|
107
|
+
* Requests without client headers share this key. It has no per-client
|
|
108
|
+
* limit (it would let anyone lock out everyone), only the global ceiling.
|
|
109
|
+
*/
|
|
110
|
+
const UNKNOWN_CLIENT = "unknown";
|
|
111
|
+
const failures = /* @__PURE__ */ new Map();
|
|
112
|
+
let globalFailures = {
|
|
113
|
+
count: 0,
|
|
114
|
+
firstAt: 0
|
|
115
|
+
};
|
|
116
|
+
function isLive(bucket, now) {
|
|
117
|
+
return now - bucket.firstAt <= ATTEMPT_WINDOW_MS;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* `x-real-ip` when the proxy sets it, else the last `x-forwarded-for` entry:
|
|
121
|
+
* the one the trusted proxy appended. Entries before it are whatever the
|
|
122
|
+
* client sent, so keying on them would let a client pick its own bucket.
|
|
123
|
+
*/
|
|
124
|
+
function clientKey(request) {
|
|
125
|
+
const realIp = request.headers.get("x-real-ip")?.trim();
|
|
126
|
+
if (realIp) return realIp;
|
|
127
|
+
return request.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim() || UNKNOWN_CLIENT;
|
|
128
|
+
}
|
|
129
|
+
function isThrottled(key, now) {
|
|
130
|
+
const entry = failures.get(key);
|
|
131
|
+
if (!entry || !isLive(entry, now)) return false;
|
|
132
|
+
if (key !== UNKNOWN_CLIENT && entry.count >= MAX_FAILED_ATTEMPTS) return true;
|
|
133
|
+
return isLive(globalFailures, now) && globalFailures.count >= MAX_FAILED_GLOBAL;
|
|
134
|
+
}
|
|
135
|
+
/** Makes room for one more client without resetting a bucket close to its limit. */
|
|
136
|
+
function makeRoom(now) {
|
|
137
|
+
if (failures.size < MAX_TRACKED) return true;
|
|
138
|
+
for (const [key, tracked] of failures) if (!isLive(tracked, now)) failures.delete(key);
|
|
139
|
+
if (failures.size < MAX_TRACKED) return true;
|
|
140
|
+
for (const [key, tracked] of failures) if (tracked.count < MAX_FAILED_ATTEMPTS / 2) {
|
|
141
|
+
failures.delete(key);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
function recordFailure(key, now) {
|
|
147
|
+
if (isLive(globalFailures, now)) globalFailures.count += 1;
|
|
148
|
+
else globalFailures = {
|
|
149
|
+
count: 1,
|
|
150
|
+
firstAt: now
|
|
151
|
+
};
|
|
152
|
+
const entry = failures.get(key);
|
|
153
|
+
if (entry && isLive(entry, now)) {
|
|
154
|
+
entry.count += 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
failures.delete(key);
|
|
158
|
+
if (makeRoom(now)) failures.set(key, {
|
|
159
|
+
count: 1,
|
|
160
|
+
firstAt: now
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* GET: what the client needs to decide between the panels and the lock, and
|
|
165
|
+
* nothing more. The route is reachable while locked, so it names no tier.
|
|
166
|
+
*/
|
|
167
|
+
async function handleHudUnlockStatus(request, options = {}) {
|
|
168
|
+
const settings = (options.settings ?? readAccessSettings)();
|
|
169
|
+
const { env, enabled, password } = settings;
|
|
170
|
+
if (!isHudVisible(env, enabled)) return {
|
|
171
|
+
status: 404,
|
|
172
|
+
body: { error: "not found" }
|
|
173
|
+
};
|
|
174
|
+
const required = hudRequiresUnlock(resolveHudAccess(env));
|
|
175
|
+
return {
|
|
176
|
+
status: 200,
|
|
177
|
+
body: {
|
|
178
|
+
unlocked: await isHudRequestUnlocked(request, { settings: () => settings }),
|
|
179
|
+
configured: !required || Boolean(password)
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** POST: exchanges the password for a signed, expiring unlock cookie. */
|
|
184
|
+
async function handleHudUnlockAttempt(request, options = {}) {
|
|
185
|
+
const settings = (options.settings ?? readAccessSettings)();
|
|
186
|
+
const { env, enabled, password: expected } = settings;
|
|
187
|
+
if (!isHudVisible(env, enabled)) return {
|
|
188
|
+
status: 404,
|
|
189
|
+
body: { error: "not found" }
|
|
190
|
+
};
|
|
191
|
+
const access = resolveHudAccess(env);
|
|
192
|
+
if (!hudRequiresUnlock(access)) return {
|
|
193
|
+
status: 200,
|
|
194
|
+
body: {
|
|
195
|
+
unlocked: true,
|
|
196
|
+
required: false
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const secret = signingSecret(settings);
|
|
200
|
+
if (!expected || !secret) return {
|
|
201
|
+
status: 503,
|
|
202
|
+
body: {
|
|
203
|
+
unlocked: false,
|
|
204
|
+
configured: false,
|
|
205
|
+
error: "hud unlock is not configured"
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const key = clientKey(request);
|
|
210
|
+
if (isThrottled(key, now)) return {
|
|
211
|
+
status: 429,
|
|
212
|
+
body: {
|
|
213
|
+
unlocked: false,
|
|
214
|
+
error: "too many attempts, try again later"
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
let submitted = "";
|
|
218
|
+
try {
|
|
219
|
+
const payload = await request.json();
|
|
220
|
+
if (typeof payload?.password === "string") submitted = payload.password;
|
|
221
|
+
} catch {}
|
|
222
|
+
if (!submitted || !timingSafeEqual(submitted, expected)) {
|
|
223
|
+
recordFailure(key, now);
|
|
224
|
+
return {
|
|
225
|
+
status: 401,
|
|
226
|
+
body: {
|
|
227
|
+
unlocked: false,
|
|
228
|
+
error: "wrong password"
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
failures.delete(key);
|
|
233
|
+
const ttlMs = hudUnlockTtlMs(access);
|
|
234
|
+
return {
|
|
235
|
+
status: 200,
|
|
236
|
+
body: {
|
|
237
|
+
unlocked: true,
|
|
238
|
+
expiresInMs: ttlMs
|
|
239
|
+
},
|
|
240
|
+
setCookie: hudUnlockCookieString(await signHudUnlockToken(secret, env, now), ttlMs)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Test seam: the attempt budget is process state, not request state. */
|
|
244
|
+
function resetHudUnlockAttempts() {
|
|
245
|
+
failures.clear();
|
|
246
|
+
globalFailures = {
|
|
247
|
+
count: 0,
|
|
248
|
+
firstAt: 0
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Test seam: how many clients the attempt budget tracks. */
|
|
252
|
+
function trackedHudUnlockClients() {
|
|
253
|
+
return failures.size;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/plugins/access/server.ts
|
|
257
|
+
function toResponse({ status, body, setCookie }) {
|
|
258
|
+
const res = Response.json(body, {
|
|
259
|
+
status,
|
|
260
|
+
headers: { "cache-control": "no-store" }
|
|
261
|
+
});
|
|
262
|
+
if (setCookie) res.headers.append("set-cookie", setCookie);
|
|
263
|
+
return res;
|
|
264
|
+
}
|
|
265
|
+
function splitPath(path) {
|
|
266
|
+
return path.split("/").filter(Boolean);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The guard for `createHudHandler({ guard })` on deployed tiers. It answers
|
|
270
|
+
* exactly like the old `checkHudRequest`: 404 while the tier has not opted in,
|
|
271
|
+
* 403 for a capability the tier's matrix denies (before any password is
|
|
272
|
+
* considered), 401 until the request carries a valid unlock cookie. In
|
|
273
|
+
* development it lets everything through, like `developmentOnly`.
|
|
274
|
+
*
|
|
275
|
+
* The unlock route itself is the one exemption, so a locked HUD can unlock.
|
|
276
|
+
* Pass the handler's `basePath` when it is not `/api/hud`.
|
|
277
|
+
*/
|
|
278
|
+
function accessGuard(options = {}) {
|
|
279
|
+
const settings = options.settings ?? readAccessSettings;
|
|
280
|
+
const unlockPath = [
|
|
281
|
+
...splitPath(options.basePath ?? "/api/hud"),
|
|
282
|
+
"access",
|
|
283
|
+
"unlock"
|
|
284
|
+
].join("/");
|
|
285
|
+
return async (request, capability) => {
|
|
286
|
+
const path = splitPath(new URL(request.url).pathname).join("/");
|
|
287
|
+
if (capability === "access.unlock" && path === unlockPath) {
|
|
288
|
+
const { env, enabled } = settings();
|
|
289
|
+
return isHudVisible(env, enabled) ? null : Response.json({
|
|
290
|
+
ok: false,
|
|
291
|
+
error: "not found"
|
|
292
|
+
}, { status: 404 });
|
|
293
|
+
}
|
|
294
|
+
const result = await checkHudRequest(request, capability, {
|
|
295
|
+
...options,
|
|
296
|
+
settings
|
|
297
|
+
});
|
|
298
|
+
if (result.ok) return null;
|
|
299
|
+
return Response.json({
|
|
300
|
+
ok: false,
|
|
301
|
+
error: result.error
|
|
302
|
+
}, {
|
|
303
|
+
status: result.status,
|
|
304
|
+
headers: { "cache-control": "no-store" }
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Server half of the access plugin: `GET|POST <basePath>/access/unlock`.
|
|
310
|
+
* GET reports `{ unlocked, configured }`; POST trades `{ password }` for an
|
|
311
|
+
* httpOnly cookie. Wrong passwords are budgeted: ten per client per ten
|
|
312
|
+
* minutes, and past a hundred overall every client with failures of its own
|
|
313
|
+
* is throttled; a throttled client gets 429 even for the right password.
|
|
314
|
+
*
|
|
315
|
+
* ```ts
|
|
316
|
+
* const options = {};
|
|
317
|
+
* export const { GET, POST } = createHudHandler([accessServer(options), ...], {
|
|
318
|
+
* guard: accessGuard(options),
|
|
319
|
+
* });
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
function accessServer(options = {}) {
|
|
323
|
+
return definePlugin({
|
|
324
|
+
id: "access",
|
|
325
|
+
capability: ACCESS_UNLOCK_CAPABILITY,
|
|
326
|
+
routes: { "/unlock": {
|
|
327
|
+
GET: async (request) => request.method === "GET" ? toResponse(await handleHudUnlockStatus(request, options)) : Response.json({
|
|
328
|
+
ok: false,
|
|
329
|
+
error: "method not allowed"
|
|
330
|
+
}, {
|
|
331
|
+
status: 405,
|
|
332
|
+
headers: { allow: "GET, POST" }
|
|
333
|
+
}),
|
|
334
|
+
POST: async (request) => toResponse(await handleHudUnlockAttempt(request, options))
|
|
335
|
+
} }
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
export { ACCESS_UNLOCK_CAPABILITY, CAPABILITY_MATRIX, HUD_CAPABILITIES, HUD_UNLOCK_COOKIE, accessGuard, accessServer, checkHudRequest, handleHudUnlockAttempt, handleHudUnlockStatus, hasCapability, hudRequiresUnlock, isHudRequestUnlocked, isHudVisible, readAccessSettings, resetHudUnlockAttempts, resolveHudAccess, signHudUnlockToken, trackedHudUnlockClients, verifyHudUnlockToken };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { c as HudPluginProps, r as HudCapability } from "../plugins-CQ_0w1ot.js";
|
|
2
|
+
import "../index-BYsdS-7d.js";
|
|
3
|
+
import { c as CAPABILITY_MATRIX, d as hasCapability, i as resolveHudAccess, l as CapabilityMatrixOverride, n as HudAccess, r as hudRequiresUnlock, s as ACCESS_UNLOCK_CAPABILITY, u as HUD_CAPABILITIES } from "../access-CNvJOwKM.js";
|
|
4
|
+
//#region src/plugins/access/access-panel.d.ts
|
|
5
|
+
declare function AccessPill(): import("react").JSX.Element;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/plugins/access/unlock-store.d.ts
|
|
8
|
+
type UnlockStatus = "checking" | "locked" | "unlocked" | "unconfigured";
|
|
9
|
+
/**
|
|
10
|
+
* `HudAccessControl.useUnlocked`: development is open, a disabled or unknown
|
|
11
|
+
* tier never unlocks, every deployed tier asks the server once per mount.
|
|
12
|
+
*/
|
|
13
|
+
declare function useUnlocked(envName: string, apiBase: string): boolean;
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/plugins/access/index.d.ts
|
|
16
|
+
export interface AccessOptions {
|
|
17
|
+
/** Replaces the default matrix's list for a deployed tier. Keep it equal to the server half's. */
|
|
18
|
+
matrix?: CapabilityMatrixOverride;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Opt-in for deployed tiers. Without it `defineHud` renders nothing outside
|
|
22
|
+
* development. With it the HUD follows the env ladder: a tier's capability
|
|
23
|
+
* matrix narrows the plugins, and until the operator unlocks with the
|
|
24
|
+
* server's password only this plugin's lock shows.
|
|
25
|
+
*
|
|
26
|
+
* Client half; the password check lives in `@hansenexus/hud/plugins/access/server`
|
|
27
|
+
* (`accessServer()` + `accessGuard()`), and the password never reaches the browser.
|
|
28
|
+
*/
|
|
29
|
+
export declare function access(options?: AccessOptions): {
|
|
30
|
+
readonly id: "access";
|
|
31
|
+
readonly title: "Access";
|
|
32
|
+
readonly capability: "access.unlock";
|
|
33
|
+
readonly pill: typeof AccessPill;
|
|
34
|
+
readonly panel: ({ ctx }: HudPluginProps) => import("react").JSX.Element;
|
|
35
|
+
readonly access: {
|
|
36
|
+
readonly can: (env: string, capability: HudCapability) => boolean;
|
|
37
|
+
readonly useUnlocked: typeof useUnlocked;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { ACCESS_UNLOCK_CAPABILITY, CAPABILITY_MATRIX, type CapabilityMatrixOverride, HUD_CAPABILITIES, type HudAccess, type UnlockStatus, hasCapability, hudRequiresUnlock, resolveHudAccess };
|