@hansenexus/hud 0.2.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.
Files changed (55) hide show
  1. package/README.md +253 -9
  2. package/dist/access-CNvJOwKM.d.ts +54 -0
  3. package/dist/capabilities-CjulXFQX.js +189 -0
  4. package/dist/define-hud-BBzsSl8b.d.ts +36 -0
  5. package/dist/env-DfxpPfs9.js +28 -0
  6. package/dist/env-vEjyJfPW.d.ts +17 -0
  7. package/dist/force-state-BaqWsmrf.js +153 -0
  8. package/dist/force-state-CCqoTfVB.d.ts +23 -0
  9. package/dist/handler-WmE4Lu7l.d.ts +39 -0
  10. package/dist/index-BYsdS-7d.d.ts +55 -0
  11. package/dist/index.d.ts +6 -39
  12. package/dist/index.js +980 -264
  13. package/dist/marker-C66LxOhJ.d.ts +9 -0
  14. package/dist/marker-CMrvxPmS.d.ts +12 -0
  15. package/dist/marker-DjeGvL-F.js +9 -0
  16. package/dist/marker-uZYPOJ4M.js +12 -0
  17. package/dist/markers.d.ts +3 -0
  18. package/dist/markers.js +3 -0
  19. package/dist/next.d.ts +33 -0
  20. package/dist/next.js +52 -0
  21. package/dist/payload-CLKDVZWl.d.ts +68 -0
  22. package/dist/plugins/access/server.d.ts +122 -0
  23. package/dist/plugins/access/server.js +339 -0
  24. package/dist/plugins/access.d.ts +41 -0
  25. package/dist/plugins/access.js +250 -0
  26. package/dist/plugins/agent/server.d.ts +129 -0
  27. package/dist/plugins/agent/server.js +429 -0
  28. package/dist/plugins/agent.d.ts +37 -0
  29. package/dist/plugins/agent.js +411 -0
  30. package/dist/plugins/design.d.ts +125 -0
  31. package/dist/plugins/design.js +619 -0
  32. package/dist/plugins/observe.d.ts +64 -0
  33. package/dist/plugins/observe.js +617 -0
  34. package/dist/plugins/ops/server.d.ts +82 -0
  35. package/dist/plugins/ops/server.js +380 -0
  36. package/dist/plugins/ops.d.ts +26 -0
  37. package/dist/plugins/ops.js +362 -0
  38. package/dist/plugins/vitals.d.ts +25 -0
  39. package/dist/plugins/vitals.js +26 -0
  40. package/dist/plugins-CQ_0w1ot.d.ts +114 -0
  41. package/dist/plugins-DVy_vaYr.js +70 -0
  42. package/dist/review.d.ts +181 -0
  43. package/dist/review.js +1017 -0
  44. package/dist/selector-NSYSe7AZ.js +28 -0
  45. package/dist/server.d.ts +3 -0
  46. package/dist/server.js +106 -0
  47. package/dist/shortcut-U6ftVY06.js +222 -0
  48. package/dist/state-boundary.d.ts +26 -0
  49. package/dist/state-boundary.js +61 -0
  50. package/dist/styles--slk86ZQ.js +332 -0
  51. package/dist/types-DADBRiGk.d.ts +50 -0
  52. package/dist/types-QJUajwWq.js +35 -0
  53. package/dist/types-f3jlNxzw.d.ts +92 -0
  54. package/dist/vitals-panel-Dvc5dnR-.js +87 -0
  55. package/package.json +36 -3
package/README.md CHANGED
@@ -3,8 +3,7 @@
3
3
  A dev HUD for React apps: a status pill and floating panels, rendered in a shadow root so a
4
4
  client site's CSS cannot touch it and it cannot touch the site.
5
5
 
6
- > Early. The plugin API, pinning, the element grabber and the Next adapter are tracked in
7
- > [the PRD](https://github.com/hansenexus/hud/issues/1).
6
+ > Early. The roadmap is tracked in [the PRD](https://github.com/hansenexus/hud/issues/1).
8
7
 
9
8
  ## Develop
10
9
 
@@ -28,19 +27,264 @@ bun add -d @hansenexus/hud # or npm i -D @hansenexus/hud
28
27
  Peer dependencies: `react` and `react-dom` 19. Nothing else: no Tailwind, no stylesheet to import,
29
28
  no host CSS assumptions. The HUD ships its own styles inside its shadow root.
30
29
 
31
- ## Use
30
+ ## Use (Next.js)
31
+
32
+ Three files. The HUD definition, client only:
33
+
34
+ ```tsx
35
+ // src/components/dev/hud.tsx
36
+ import { defineHud } from "@hansenexus/hud";
37
+ import { vitals } from "@hansenexus/hud/plugins/vitals";
38
+
39
+ export default defineHud({ app: "my-app", plugins: [vitals()] });
40
+ ```
41
+
42
+ The loader, with the build gate as a literal expression around the `import()`. Next inlines both
43
+ env reads, so a production build folds the ternary to `null` and emits no HUD chunk:
44
+
45
+ ```tsx
46
+ // src/components/dev/hud-loader.tsx
47
+ "use client";
48
+ import { createHudLoader } from "@hansenexus/hud/next";
49
+
50
+ export const HudLoader = createHudLoader(
51
+ process.env.NODE_ENV === "development" || process.env.NEXT_PUBLIC_HUD_ENABLED === "true"
52
+ ? () => import("./hud")
53
+ : null
54
+ );
55
+ ```
56
+
57
+ Render `<HudLoader />` in the root layout. Plugin routes mount through one catch-all route, fed
58
+ the plugins' server halves:
59
+
60
+ ```ts
61
+ // src/app/api/hud/[...hud]/route.ts
62
+ import { createHudHandler } from "@hansenexus/hud/server";
63
+
64
+ export const { GET, POST, PUT, PATCH, DELETE } = createHudHandler([/* plugins/<id>/server */]);
65
+ ```
66
+
67
+ `createHudHandler` answers 404 to everything unless `NODE_ENV` is `development`; pass `guard` to
68
+ open deployed tiers deliberately. `HUD_DEV_MARKER` is rendered on the HUD root for a CI grep.
69
+
70
+ ## Deployed tiers (access plugin)
71
+
72
+ Core assumes development: without the `access` plugin `defineHud` renders nothing on any other
73
+ tier and `createHudHandler` answers 404. To open preview, staging or production deliberately, add
74
+ both halves:
32
75
 
33
76
  ```tsx
34
- import { Hud } from "@hansenexus/hud";
77
+ // hud.tsx
78
+ import { access } from "@hansenexus/hud/plugins/access";
79
+ export default defineHud({ app: "my-app", plugins: [access(), vitals()] });
80
+ ```
81
+
82
+ ```ts
83
+ // app/api/hud/[...hud]/route.ts
84
+ import { accessGuard, accessServer } from "@hansenexus/hud/plugins/access/server";
85
+
86
+ export const { GET, POST, PUT, PATCH, DELETE } = createHudHandler([accessServer(), /* ... */], {
87
+ guard: accessGuard(),
88
+ });
89
+ ```
90
+
91
+ The tier comes from the env ladder (`NEXT_PUBLIC_HUD_DISABLED` → `NODE_ENV` → `NEXT_PUBLIC_HUD_ENV`
92
+ → `VERCEL_ENV` → production). A deployed tier also needs `NEXT_PUBLIC_HUD_ENABLED=true` (in the
93
+ loader's build gate and on the server) and `HUD_ACCESS_PASSWORD`. Every plugin route then answers,
94
+ in this order:
95
+
96
+ | status | when |
97
+ | ------ | ---------------------------------------------------------------------------- |
98
+ | 404 | the tier has not opted in, or is disabled |
99
+ | 403 | the tier's matrix denies the plugin's capability, or the route declares none |
100
+ | 401 | permitted, but no valid unlock cookie (or no password configured) |
101
+
102
+ The denial comes before the lock, so a password never widens the matrix. Every 403 carries the
103
+ same `{ "error": "forbidden" }` body, naming neither tier nor capability. The matrix is
104
+ default-deny; production grants `observe.app`, `observe.estate` and `cicd.read`. Pass the same
105
+ `{ matrix: { staging: [...] } }` to `access()` and `accessGuard()` to replace a tier's list.
106
+ Until the operator unlocks (12 h on preview/staging, 2 h on production), the pill shows only the
107
+ lock. Client-side hiding is display only; the guard is what enforces the tier.
108
+
109
+ Server-only variables (never prefix them `NEXT_PUBLIC_`, never pass them to client code):
110
+
111
+ | variable | does |
112
+ | --------------------- | ------------------------------------------------------------------------------------ |
113
+ | `HUD_ACCESS_PASSWORD` | the unlock password. **Make it long and random** (a generated 32+ character string): it is the only barrier in front of the deployed HUD |
114
+ | `HUD_ACCESS_SECRET` | optional. Signs the unlock cookie instead of the password, so a leaked cookie gives nothing to guess the password against |
115
+ | `HUD_DISABLED` | `true` turns every HUD route into a 404 at request time |
116
+
117
+ The cookie (`__Host-hud-unlock`, httpOnly, `SameSite=Strict`) is signed with a key derived per
118
+ tier, so a staging cookie does not open production. Wrong passwords are budgeted per client
119
+ (`x-real-ip`, else the last `x-forwarded-for` entry, which your proxy must append; the leftmost
120
+ entries are client-controlled and ignored): ten per client per ten minutes, then 429 for every
121
+ attempt, the right password included. Past a hundred failures overall, every client with recent
122
+ failures of its own is throttled too, while a client without any still unlocks.
123
+
124
+ `NEXT_PUBLIC_*` flags are inlined at build time: changing `NEXT_PUBLIC_HUD_ENABLED` or
125
+ `NEXT_PUBLIC_HUD_DISABLED` needs a rebuild. To switch a running deployment off, set
126
+ `HUD_DISABLED=true` and restart.
127
+
128
+ In the playground, `?env=staging` runs this flow with the password `playground`.
129
+
130
+ ## Write a plugin
131
+
132
+ A plugin is `{ id, title?, capability?, panel?, pill?, commands?, routes? }`, shipped as two
133
+ halves with the same `id`: the client half (`panel`, `pill`, `commands`) and the server half
134
+ (`routes`), so route code never reaches a client bundle. The panel is a plain React component
135
+ receiving `ctx`:
136
+
137
+ | `ctx` | does |
138
+ | ------------- | --------------------------------------------------------------------- |
139
+ | `can(cap)` | capability check (core grants all; the `access` plugin narrows it) |
140
+ | `api(path)` | JSON fetch from the plugin's routes at `/api/hud/<id>/<path>` |
141
+ | `pin(edge)` | pin the panel to `left`, `right` or `bottom` |
142
+ | `close()` | close the panel |
143
+ | `open()` | open the panel |
144
+ | `grab` | the element grabber: `start()`, `cancel()`, `clear()`; read it with `useGrab(ctx.grab)` |
145
+
146
+ Routes are keyed by path, then method: `routes: { "/runs/:id": { GET: (req, { params }) => ... } }`.
147
+ An unknown path answers 404, a known path with another method 405.
148
+
149
+ `playground/src/plugins/server-info` is a working example: the playground serves its routes
150
+ through MSW with the same `createHudHandler` an app mounts.
151
+
152
+ ## Panels and shortcuts
153
+
154
+ - Open panels from the pill (`⋯` lists every panel and command). Drag by the title bar; a click
155
+ raises a panel.
156
+ - Pin a panel with ◧ ⬓ ◨ or by dropping it at the left, right or bottom edge. Pinning pushes the
157
+ page (padding on `<html>`) instead of covering it; pins on one edge stack. The host's
158
+ `position: fixed` elements and media queries still see the full window.
159
+ - The layout (open panels, positions, pins) is kept per `appSlug` in `localStorage`.
160
+ `⋯ → Reset layout` clears it.
161
+ - Below 768 px panels open as one full-width bottom sheet with a tab per panel; no pinning.
162
+ - `⌘⇧H` (Ctrl+Shift+H elsewhere) hides and restores the layout.
35
163
 
36
- export function DevHud() {
37
- return <Hud appSlug="my-app" />;
164
+ A plugin's panel can pin itself with `ctx.pin("right")`. Plugin `commands` join the registry as
165
+ `<plugin id>.<command id>`, appear in the `⋯` menu and bind their `shortcut`.
166
+
167
+ ## Element grabber
168
+
169
+ `⌘⇧G` or the pill's ⌖ starts a grab: hover outlines an element and labels it with the React
170
+ component and file that rendered it, a click picks it, `Esc` cancels. Open panels stay mounted
171
+ and usable meanwhile. The pick becomes a `GrabPayload` (route, selector, label, component,
172
+ viewport) handed to every plugin's `onGrab(payload, ctx)` and kept in `ctx.grab`. The source line
173
+ comes from React's dev stack and points into the dev server's transformed module, so it can sit a
174
+ few lines below the original.
175
+
176
+ `ElementPicker` is exported on its own and carries no dev marker, for the review entrypoint.
177
+
178
+ ## Agent plugins
179
+
180
+ Two plugins from one module: `agentDispatch()` (Dispatch panel: the last grab plus an
181
+ instruction, sent to a provider; opens itself on a grab) and `agentSessions()` (Sessions panel:
182
+ runs and sessions of every provider).
183
+
184
+ ```tsx
185
+ import { agentDispatch, agentSessions } from "@hansenexus/hud/plugins/agent";
186
+ defineHud({ app: "my-app", plugins: [agentDispatch({ componentHints }), agentSessions()] });
187
+ ```
188
+
189
+ ```ts
190
+ import { agentDispatch, agentSessions, claudeCode, hermes } from "@hansenexus/hud/plugins/agent/server";
191
+
192
+ const providers = [
193
+ claudeCode(), // `claude -p <prompt> --permission-mode acceptEdits` in the dev server's cwd
194
+ hermes({ baseUrl: "http://hn-hub.ts.hansenexus.dev:8642", token: process.env.HERMES_API_TOKEN }),
195
+ ];
196
+ export const { GET, POST } = createHudHandler([agentDispatch({ providers }), agentSessions({ providers })]);
197
+ ```
198
+
199
+ A provider is `{ id, label, status?, dispatch(order), sessions? }`; `order.prompt` is the built
200
+ task text. `POST /dispatch` refuses cross-site requests (`Sec-Fetch-Site`) and anything not
201
+ `application/json`, so another origin cannot start an agent through the developer's browser.
202
+ `claudeCode()` loads Node built-ins on first use, so the server entry also imports where `node:`
203
+ modules are missing.
204
+
205
+ ## Design plugin
206
+
207
+ `@hansenexus/hud/plugins/design` exports three client-only plugins:
208
+
209
+ ```tsx
210
+ import { forceState, tokens, variants } from "@hansenexus/hud/plugins/design";
211
+
212
+ tokens({
213
+ groups: [
214
+ { label: "Theme", tokens: ["--background", "--primary", { cssVar: "--ring", label: "ring" }] },
215
+ { label: "Brand", tokens: ["--brand"], scope: { attribute: "data-theme", value: "brand" } },
216
+ ],
217
+ sourceHint: "src/app/globals.css",
218
+ });
219
+ variants({ categories: async () => loadCategories(), reviewHref: "/dev/review" });
220
+ forceState();
221
+ ```
222
+
223
+ - **Tokens** reads each value from the host document (a hidden probe in `<body>` carrying the
224
+ group's `scope` attribute), so themed and scoped tokens show what the page renders. It re-reads
225
+ when `<html>`/`<body>` attributes, stylesheets or the OS color scheme change.
226
+ - **Variants** lists the app's variant categories through an adapter (a list or an async loader)
227
+ and queues generate briefs when the tier grants `variants.generate`.
228
+ - **States** toggles every mounted `StateBoundary` between real, loading, empty and error. It
229
+ needs `state.force`.
230
+
231
+ `StateBoundary` is app code and production-safe to import. Outside development it renders
232
+ `children(null)` and the bundler drops the store; `?__state=loading|empty|error` forces the
233
+ top-level boundaries and `?__state=<id>:error` one boundary:
234
+
235
+ ```tsx
236
+ import { StateBoundary } from "@hansenexus/hud/state-boundary";
237
+
238
+ <StateBoundary id="blog-post">
239
+ {(forced) => (forced === "loading" || post === undefined ? <Skeleton /> : <Post post={post} />)}
240
+ </StateBoundary>;
241
+ ```
242
+
243
+ `FORCE_STATE_MARKER` (`__HANSENEXUS_HUD_FORCE_STATE__`) is the store's global key; grep production
244
+ chunks for it to prove the fold.
245
+
246
+ ## Review entrypoint (production)
247
+
248
+ `@hansenexus/hud/review` is the client review widget: a pill on the right edge, the element
249
+ picker and a docked review sheet with the page's pins. It is safe to ship in production and
250
+ carries none of the dev shell or its plugins. It needs `convex` (an optional peer dependency).
251
+
252
+ The gate is a prop, not a build flag, so one image can serve a client production with the widget
253
+ off and a staging production with it on. Read the URL on the server per deployment and pass it
254
+ down; empty renders nothing:
255
+
256
+ ```tsx
257
+ "use client";
258
+ import { Review } from "@hansenexus/hud/review";
259
+
260
+ export function ReviewMount({ convexUrl, locale }: { convexUrl?: string; locale: string }) {
261
+ return <Review convexUrl={convexUrl} projectSlug="elbe-akustik" locale={locale} />;
38
262
  }
39
263
  ```
40
264
 
41
- Mount it only in development builds, behind a check the bundler can fold (for example
42
- `process.env.NODE_ENV === "development"` around a lazy import), so production bundles carry no
43
- HUD code. `HUD_DEV_MARKER` is rendered on the HUD root for a CI grep.
265
+ Even with a URL the widget stays invisible until the visitor opens a review link (`?rv=<grant>`,
266
+ redeemed once and stripped from the address bar) or has a stored session. Pins are stored per
267
+ locale-stripped route (`/en/standorte` and `/standorte` are one route) and per viewport bucket;
268
+ a pin that cannot be placed is listed under "Ohne Position", never moved onto a neighbour. The
269
+ widget talks to the review app's Convex functions `access:redeemGrant`, `access:whoami`,
270
+ `pins:listForRoute` and `pins:plant`; pass `backend` (a `ReviewBackend`) instead of `convexUrl`
271
+ to run it against anything else. In the playground, `?review` mounts it with an in-memory
272
+ backend.
273
+
274
+ ### Bundle gate
275
+
276
+ `@hansenexus/hud/markers` exports the literals to grep production client chunks for, with no
277
+ dependencies so a Node gate script can import it:
278
+
279
+ | marker | value | expected in production chunks |
280
+ | ------------------- | --------------------------- | ----------------------------------------- |
281
+ | `HUD_DEV_MARKER` | `__HANSENEXUS_HUD_DEV__` | never |
282
+ | `HUD_REVIEW_MARKER` | `__HANSENEXUS_HUD_REVIEW__` | only where `/review` is meant to ship |
283
+
284
+ CI proves both on every PR: `fixtures/next-app` mounts the dev HUD behind the loader's build gate
285
+ and `<Review>` beside it, installs the packed tarball, runs `next build` and fails on any dev
286
+ marker or dev-only string in `.next/static` (`bun run build && bun run --cwd fixtures/next-app
287
+ gate`). `fixtures/next-app/scripts/check-bundle.js` is a starting point for an app's own gate.
44
288
 
45
289
  ## Release
46
290
 
@@ -0,0 +1,54 @@
1
+ import { r as HudCapability } from "./plugins-CQ_0w1ot.js";
2
+ import { t as HudEnv } from "./env-vEjyJfPW.js";
3
+ //#region src/plugins/access/capabilities.d.ts
4
+ /** The capability vocabulary of the default matrix. Development grants these and any other. */
5
+ declare const HUD_CAPABILITIES: readonly ["observe.app", "observe.estate", "cicd.read", "cicd.trigger", "inject.seed", "mock.toggle", "auth.impersonate", "dispatch.local", "dispatch.hermes", "variants.switch", "variants.vote", "variants.generate", "state.force"];
6
+ /**
7
+ * The access plugin's own capability. The guard lets the unlock route through
8
+ * on every enabled tier, so it never needs a matrix entry; nothing else may use it.
9
+ */
10
+ declare const ACCESS_UNLOCK_CAPABILITY = "access.unlock";
11
+ /** Tiers whose grants an app may replace. Development grants all, disabled nothing. */
12
+ type DeployedTier = "preview" | "staging" | "production";
13
+ /** Per-tier replacement lists, for apps whose plugins bring capabilities of their own. */
14
+ type CapabilityMatrixOverride = Partial<Record<DeployedTier, readonly HudCapability[]>>;
15
+ type CapabilityMatrix = Record<HudEnv, readonly HudCapability[]>;
16
+ /**
17
+ * Default-deny capability matrix. Client filtering is convenience only: the
18
+ * guard re-checks every route against the same matrix server side.
19
+ * Staging's `auth.impersonate` is further flag-gated by the plugin that uses it.
20
+ */
21
+ declare const CAPABILITY_MATRIX: CapabilityMatrix;
22
+ /** Core assumes development, so development grants every capability, listed or not. */
23
+ declare function hasCapability(env: HudEnv, capability: HudCapability, matrix?: CapabilityMatrix): boolean;
24
+ /**
25
+ * Whether the HUD exists at all. Outside development a deployed tier also
26
+ * needs its overlay to set `NEXT_PUBLIC_HUD_ENABLED=true`.
27
+ */
28
+ declare function isHudVisible(env: HudEnv, enabled?: boolean): boolean;
29
+ //#endregion
30
+ //#region src/plugins/access/access.d.ts
31
+ type HudAccess = "open" | "password" | "flag" | "denied";
32
+ /**
33
+ * httpOnly cookie holding the signed unlock token. `__Host-` makes browsers
34
+ * refuse it unless it is Secure, host-only and on `/`, so a sibling subdomain
35
+ * cannot set or shadow it.
36
+ */
37
+ declare const HUD_UNLOCK_COOKIE = "__Host-hud-unlock";
38
+ declare function resolveHudAccess(env: HudEnv): HudAccess;
39
+ /** Whether this tier needs a valid unlock cookie before any HUD data flows. */
40
+ declare function hudRequiresUnlock(access: HudAccess): boolean;
41
+ /**
42
+ * Mints an unlock token for `env`, `<issuedAt>.<hmac>`. The issue time is in
43
+ * the clear so expiry is verifiable without server state, and covered by the
44
+ * signature so it cannot be back-dated. `secret` is `HUD_ACCESS_SECRET` when
45
+ * set, the password otherwise.
46
+ */
47
+ declare function signHudUnlockToken(secret: string, env: HudEnv, issuedAt?: number): Promise<string>;
48
+ interface HudUnlockVerifyOptions {
49
+ now?: number;
50
+ ttlMs?: number;
51
+ }
52
+ declare function verifyHudUnlockToken(token: string | undefined, secret: string | undefined, env: HudEnv, options?: HudUnlockVerifyOptions): Promise<boolean>;
53
+ //#endregion
54
+ export { signHudUnlockToken as a, CAPABILITY_MATRIX as c, hasCapability as d, isHudVisible as f, resolveHudAccess as i, CapabilityMatrixOverride as l, HudAccess as n, verifyHudUnlockToken as o, hudRequiresUnlock as r, ACCESS_UNLOCK_CAPABILITY as s, HUD_UNLOCK_COOKIE as t, HUD_CAPABILITIES as u };
@@ -0,0 +1,189 @@
1
+ //#region src/plugins/access/access.ts
2
+ /**
3
+ * httpOnly cookie holding the signed unlock token. `__Host-` makes browsers
4
+ * refuse it unless it is Secure, host-only and on `/`, so a sibling subdomain
5
+ * cannot set or shadow it.
6
+ */
7
+ const HUD_UNLOCK_COOKIE = "__Host-hud-unlock";
8
+ /** Unlock lifetime per tier. Production is deliberately the shortest window. */
9
+ const HUD_UNLOCK_TTL_MS = {
10
+ open: 0,
11
+ password: 432e5,
12
+ flag: 72e5,
13
+ denied: 0
14
+ };
15
+ function resolveHudAccess(env) {
16
+ if (env === "disabled") return "denied";
17
+ if (env === "development") return "open";
18
+ if (env === "production") return "flag";
19
+ return "password";
20
+ }
21
+ /** Whether this tier needs a valid unlock cookie before any HUD data flows. */
22
+ function hudRequiresUnlock(access) {
23
+ return access === "password" || access === "flag";
24
+ }
25
+ function hudUnlockTtlMs(access) {
26
+ return HUD_UNLOCK_TTL_MS[access];
27
+ }
28
+ const encoder = new TextEncoder();
29
+ /**
30
+ * The HMAC key is never the secret itself: HKDF derives one per tier, so a
31
+ * cookie minted on staging does not verify on production even when both
32
+ * share a password.
33
+ */
34
+ async function signingKey(secret, env) {
35
+ const material = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
36
+ return crypto.subtle.deriveKey({
37
+ name: "HKDF",
38
+ hash: "SHA-256",
39
+ salt: encoder.encode("@hansenexus/hud"),
40
+ info: encoder.encode(`hud-unlock:${env}`)
41
+ }, material, {
42
+ name: "HMAC",
43
+ hash: "SHA-256",
44
+ length: 256
45
+ }, false, ["sign"]);
46
+ }
47
+ async function hmacHex(secret, env, issuedAt) {
48
+ const key = await signingKey(secret, env);
49
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${env}.${issuedAt}`));
50
+ return Array.from(new Uint8Array(signature)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
51
+ }
52
+ /** Length-independent, constant-time comparison of two strings. */
53
+ function timingSafeEqual(a, b) {
54
+ const length = Math.max(a.length, b.length);
55
+ let diff = a.length ^ b.length;
56
+ for (let i = 0; i < length; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
57
+ return diff === 0;
58
+ }
59
+ /**
60
+ * Mints an unlock token for `env`, `<issuedAt>.<hmac>`. The issue time is in
61
+ * the clear so expiry is verifiable without server state, and covered by the
62
+ * signature so it cannot be back-dated. `secret` is `HUD_ACCESS_SECRET` when
63
+ * set, the password otherwise.
64
+ */
65
+ async function signHudUnlockToken(secret, env, issuedAt = Date.now()) {
66
+ return `${issuedAt}.${await hmacHex(secret, env, String(issuedAt))}`;
67
+ }
68
+ async function verifyHudUnlockToken(token, secret, env, options = {}) {
69
+ if (!token || !secret) return false;
70
+ const separator = token.indexOf(".");
71
+ if (separator <= 0) return false;
72
+ const issuedAtRaw = token.slice(0, separator);
73
+ const signature = token.slice(separator + 1);
74
+ const issuedAt = Number(issuedAtRaw);
75
+ if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0) return false;
76
+ const now = options.now ?? Date.now();
77
+ const ttlMs = options.ttlMs ?? HUD_UNLOCK_TTL_MS.flag;
78
+ const age = now - issuedAt;
79
+ if (age < 0 || age > ttlMs) return false;
80
+ return timingSafeEqual(signature, await hmacHex(secret, env, issuedAtRaw));
81
+ }
82
+ /** Parses one cookie out of a raw `Cookie:` header. */
83
+ function readCookie(header, name) {
84
+ if (!header) return void 0;
85
+ for (const part of header.split(";")) {
86
+ const trimmed = part.trim();
87
+ if (trimmed.startsWith(`${name}=`)) try {
88
+ return decodeURIComponent(trimmed.slice(name.length + 1));
89
+ } catch {
90
+ return;
91
+ }
92
+ }
93
+ }
94
+ /** Serialized `Set-Cookie` value for the unlock token, or its removal. */
95
+ function hudUnlockCookieString(token, ttlMs) {
96
+ const base = `${HUD_UNLOCK_COOKIE}=${token ?? ""}; Path=/; HttpOnly; SameSite=Strict; Secure`;
97
+ return token ? `${base}; Max-Age=${Math.floor(ttlMs / 1e3)}` : `${base}; Max-Age=0`;
98
+ }
99
+ //#endregion
100
+ //#region src/plugins/access/capabilities.ts
101
+ /** The capability vocabulary of the default matrix. Development grants these and any other. */
102
+ const HUD_CAPABILITIES = [
103
+ "observe.app",
104
+ "observe.estate",
105
+ "cicd.read",
106
+ "cicd.trigger",
107
+ "inject.seed",
108
+ "mock.toggle",
109
+ "auth.impersonate",
110
+ "dispatch.local",
111
+ "dispatch.hermes",
112
+ "variants.switch",
113
+ "variants.vote",
114
+ "variants.generate",
115
+ "state.force"
116
+ ];
117
+ /**
118
+ * The access plugin's own capability. The guard lets the unlock route through
119
+ * on every enabled tier, so it never needs a matrix entry; nothing else may use it.
120
+ */
121
+ const ACCESS_UNLOCK_CAPABILITY = "access.unlock";
122
+ /**
123
+ * Default-deny capability matrix. Client filtering is convenience only: the
124
+ * guard re-checks every route against the same matrix server side.
125
+ * Staging's `auth.impersonate` is further flag-gated by the plugin that uses it.
126
+ */
127
+ const CAPABILITY_MATRIX = {
128
+ development: HUD_CAPABILITIES,
129
+ staging: [
130
+ "observe.app",
131
+ "observe.estate",
132
+ "cicd.read",
133
+ "mock.toggle",
134
+ "auth.impersonate",
135
+ "variants.switch",
136
+ "variants.vote"
137
+ ],
138
+ preview: [
139
+ "observe.app",
140
+ "observe.estate",
141
+ "cicd.read",
142
+ "mock.toggle",
143
+ "variants.switch",
144
+ "variants.vote"
145
+ ],
146
+ production: [
147
+ "observe.app",
148
+ "observe.estate",
149
+ "cicd.read"
150
+ ],
151
+ disabled: []
152
+ };
153
+ /**
154
+ * The default matrix with an app's tier lists swapped in. `access.unlock` is
155
+ * stripped from them: it is the guard's exemption, never a grant.
156
+ */
157
+ function resolveMatrix(override = {}) {
158
+ const matrix = { ...CAPABILITY_MATRIX };
159
+ for (const [tier, list] of Object.entries(override)) if (list) matrix[tier] = list.filter((capability) => capability !== ACCESS_UNLOCK_CAPABILITY);
160
+ return matrix;
161
+ }
162
+ /** Core assumes development, so development grants every capability, listed or not. */
163
+ function hasCapability(env, capability, matrix = CAPABILITY_MATRIX) {
164
+ if (env === "disabled") return false;
165
+ if (env === "development") return true;
166
+ return matrix[env].includes(capability);
167
+ }
168
+ /**
169
+ * Whether the HUD exists at all. Outside development a deployed tier also
170
+ * needs its overlay to set `NEXT_PUBLIC_HUD_ENABLED=true`.
171
+ */
172
+ function isHudVisible(env, enabled = process.env.NEXT_PUBLIC_HUD_ENABLED === "true") {
173
+ if (env === "disabled") return false;
174
+ if (env === "development") return true;
175
+ return enabled;
176
+ }
177
+ const ENVS = [
178
+ "development",
179
+ "preview",
180
+ "staging",
181
+ "production",
182
+ "disabled"
183
+ ];
184
+ /** Narrows the loader's env string; anything unrecognised is treated as disabled. */
185
+ function toHudEnv(env) {
186
+ return ENVS.includes(env) ? env : "disabled";
187
+ }
188
+ //#endregion
189
+ export { isHudVisible as a, HUD_UNLOCK_COOKIE as c, hudUnlockTtlMs as d, readCookie as f, verifyHudUnlockToken as g, timingSafeEqual as h, hasCapability as i, hudRequiresUnlock as l, signHudUnlockToken as m, CAPABILITY_MATRIX as n, resolveMatrix as o, resolveHudAccess as p, HUD_CAPABILITIES as r, toHudEnv as s, ACCESS_UNLOCK_CAPABILITY as t, hudUnlockCookieString as u };
@@ -0,0 +1,36 @@
1
+ import { o as HudPlugin, r as HudCapability } from "./plugins-CQ_0w1ot.js";
2
+ import { ComponentType } from "react";
3
+ //#region src/core/define-hud.d.ts
4
+ interface DefineHudOptions {
5
+ /** App slug: labels the pill and keys the persisted layout. */
6
+ app: string;
7
+ /** Client halves (`@hansenexus/hud/plugins/<id>`), in pill order. */
8
+ plugins: readonly HudPlugin[];
9
+ /** Base path of the catch-all route serving `createHudHandler`. Default "/api/hud". */
10
+ apiBase?: string;
11
+ /** Capability check. Default: grant all (core assumes development). */
12
+ can?: (capability: HudCapability) => boolean;
13
+ }
14
+ /** Props the loader passes in at mount time. */
15
+ interface HudMountProps {
16
+ /** Badge at the start of the pill. Default "DEV". */
17
+ envLabel?: string;
18
+ /**
19
+ * Tier the loader resolved. Default "development". Any other tier renders
20
+ * nothing unless a plugin brings `access`.
21
+ */
22
+ env?: string;
23
+ }
24
+ /**
25
+ * The app's one HUD file:
26
+ *
27
+ * ```tsx
28
+ * export default defineHud({ app: "hansenexus", plugins: [vitals()] });
29
+ * ```
30
+ *
31
+ * Returns a component. The plugin list is frozen here, so the shell sees a
32
+ * stable array across renders.
33
+ */
34
+ declare function defineHud({ app, plugins, apiBase, can }: DefineHudOptions): ComponentType<HudMountProps>;
35
+ //#endregion
36
+ export { HudMountProps as n, defineHud as r, DefineHudOptions as t };
@@ -0,0 +1,28 @@
1
+ //#region src/next/env.ts
2
+ /**
3
+ * Env ladder: kill switch → `NODE_ENV` (inlined by Next, so production
4
+ * bundles fold the development branch) → `NEXT_PUBLIC_HUD_ENV` (k8s overlays)
5
+ * → `VERCEL_ENV` → production.
6
+ *
7
+ * Each read is a literal `process.env.X` so Next can inline it in client code.
8
+ */
9
+ function resolveHudEnv() {
10
+ if (process.env.NEXT_PUBLIC_HUD_DISABLED === "true") return "disabled";
11
+ if (process.env.NODE_ENV === "development") return "development";
12
+ const explicit = process.env.NEXT_PUBLIC_HUD_ENV;
13
+ if (explicit === "staging" || explicit === "preview" || explicit === "production") return explicit;
14
+ if (process.env.VERCEL_ENV === "preview") return "preview";
15
+ return "production";
16
+ }
17
+ const LABELS = {
18
+ development: "DEV",
19
+ preview: "PREVIEW",
20
+ staging: "STAGING",
21
+ production: "PROD",
22
+ disabled: "OFF"
23
+ };
24
+ function hudEnvLabel(env = resolveHudEnv()) {
25
+ return LABELS[env];
26
+ }
27
+ //#endregion
28
+ export { resolveHudEnv as n, hudEnvLabel as t };
@@ -0,0 +1,17 @@
1
+ //#region src/next/env.d.ts
2
+ /**
3
+ * Where the HUD believes it runs. An explicit `NEXT_PUBLIC_HUD_ENV` wins over
4
+ * heuristics because k3s deploys have no `VERCEL_ENV`.
5
+ */
6
+ type HudEnv = "development" | "preview" | "staging" | "production" | "disabled";
7
+ /**
8
+ * Env ladder: kill switch → `NODE_ENV` (inlined by Next, so production
9
+ * bundles fold the development branch) → `NEXT_PUBLIC_HUD_ENV` (k8s overlays)
10
+ * → `VERCEL_ENV` → production.
11
+ *
12
+ * Each read is a literal `process.env.X` so Next can inline it in client code.
13
+ */
14
+ declare function resolveHudEnv(): HudEnv;
15
+ declare function hudEnvLabel(env?: HudEnv): string;
16
+ //#endregion
17
+ export { hudEnvLabel as n, resolveHudEnv as r, HudEnv as t };