@hosty-sdk/app 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ npm install @hosty-sdk/app
11
11
  | Entry | Runtime | Contents |
12
12
  | --- | --- | --- |
13
13
  | `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` schema, URL/env helpers |
14
- | `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory |
14
+ | `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
15
15
  | `@hosty-sdk/app/react` | client | `<AppIdentityBridge />` — probe, silent recovery, fallback cards |
16
16
  | `@hosty-sdk/app/embedder` | client | verified `hosty:auth-required` responder for anything that embeds Hosty apps |
17
17
 
@@ -32,6 +32,32 @@ export const POST = createAppCodeRouteHandler({
32
32
  });
33
33
  ```
34
34
 
35
+ App secrets — the Core-managed keychain for runtime-acquired credentials (OAuth tokens and the
36
+ like), kept by Core outside the app's backed-up data directory:
37
+
38
+ ```ts
39
+ import { getAppSecret, setAppSecret } from "@hosty-sdk/app/server";
40
+
41
+ // null means no secret is stored — an expected "reconnect required" state, not an error.
42
+ const tokens = await getAppSecret("trakt.connection.1.tokens", config);
43
+ await setAppSecret("trakt.connection.1.tokens", refreshed, config);
44
+ ```
45
+
46
+ Reads are served from a write-through cache, namespaced by Core origin and app id; pass
47
+ `{ refresh: true }` to force a live read.
48
+
49
+ Delegated tokens — the credential a browser client (Shell) presents when calling a system
50
+ app's API directly. Core signs them (ECDSA P-256, 5-minute TTL) and injects the verification
51
+ key as `HOSTY_DELEGATED_TOKEN_PUBLIC_KEY`, so validation is fully local — no Core round-trip:
52
+
53
+ ```ts
54
+ import { validateDelegatedToken } from "@hosty-sdk/app/server";
55
+
56
+ // null for anything invalid (bad signature, wrong audience, expired) — treat like a missing token.
57
+ const claims = validateDelegatedToken(bearerToken);
58
+ if (claims?.role !== "host.admin") { /* 401/403 */ }
59
+ ```
60
+
35
61
  The design contract lives in the Hosty repository:
36
62
  [`docs/ideas/hosty-app-sdk.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/ideas/hosty-app-sdk.md).
37
63
 
@@ -0,0 +1,23 @@
1
+ export interface DelegatedTokenClaims {
2
+ /** Acting Host user id. */
3
+ sub: string;
4
+ /** The actor's Host role at issuance (e.g. "host.admin"); gate admin surfaces on it. */
5
+ role: string;
6
+ /** Audience app id — must equal this app's own id. */
7
+ aud: string;
8
+ iat: number;
9
+ exp: number;
10
+ jti: string;
11
+ }
12
+ export interface ValidateDelegatedTokenOptions {
13
+ /** Audience to require; defaults to HOSTY_APP_ID. Validation fails when neither is set. */
14
+ appId?: string;
15
+ /** Base64 SPKI verification key; defaults to HOSTY_DELEGATED_TOKEN_PUBLIC_KEY. */
16
+ publicKey?: string;
17
+ /** Clock override for tests, ms since epoch. */
18
+ nowMs?: number;
19
+ }
20
+ /** Validates a delegated token locally and returns its claims, or null for anything invalid:
21
+ * bad format, unknown key, wrong audience, expiry, or signature mismatch. Deliberately never
22
+ * throws — a route treats null exactly like a missing token (401). */
23
+ export declare function validateDelegatedToken(token: string, options?: ValidateDelegatedTokenOptions): DelegatedTokenClaims | null;
@@ -0,0 +1,54 @@
1
+ // Delegated-token validation, in its own entry so plain Node services (not just Next apps) can
2
+ // import it: the server slice pulls in "server-only", which throws outside a React server bundle.
3
+ import { createPublicKey, verify as verifySignature } from "node:crypto";
4
+ const DELEGATED_TOKEN_PREFIX = "hosty_delegated";
5
+ const DELEGATED_TOKEN_VERSION = "1";
6
+ /** Validates a delegated token locally and returns its claims, or null for anything invalid:
7
+ * bad format, unknown key, wrong audience, expiry, or signature mismatch. Deliberately never
8
+ * throws — a route treats null exactly like a missing token (401). */
9
+ export function validateDelegatedToken(token, options = {}) {
10
+ const publicKeyBase64 = options.publicKey?.trim() || process.env.HOSTY_DELEGATED_TOKEN_PUBLIC_KEY?.trim();
11
+ const expectedAppId = options.appId?.trim() || process.env.HOSTY_APP_ID?.trim();
12
+ if (!publicKeyBase64 || !expectedAppId) {
13
+ return null;
14
+ }
15
+ const parts = token.split(".");
16
+ const [prefix, version, payloadPart, signaturePart] = parts;
17
+ if (parts.length !== 4 ||
18
+ prefix !== DELEGATED_TOKEN_PREFIX ||
19
+ version !== DELEGATED_TOKEN_VERSION ||
20
+ payloadPart === undefined ||
21
+ signaturePart === undefined) {
22
+ return null;
23
+ }
24
+ try {
25
+ const signingInput = `${prefix}.${version}.${payloadPart}`;
26
+ const key = createPublicKey({
27
+ key: Buffer.from(publicKeyBase64, "base64"),
28
+ format: "der",
29
+ type: "spki",
30
+ });
31
+ const signatureValid = verifySignature("sha256", Buffer.from(signingInput, "utf8"), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signaturePart, "base64url"));
32
+ if (!signatureValid) {
33
+ return null;
34
+ }
35
+ const claims = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8"));
36
+ if (typeof claims.sub !== "string" ||
37
+ typeof claims.role !== "string" ||
38
+ typeof claims.aud !== "string" ||
39
+ typeof claims.exp !== "number") {
40
+ return null;
41
+ }
42
+ if (claims.aud !== expectedAppId) {
43
+ return null;
44
+ }
45
+ const nowSeconds = Math.floor((options.nowMs ?? Date.now()) / 1000);
46
+ if (claims.exp <= nowSeconds) {
47
+ return null;
48
+ }
49
+ return claims;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
package/dist/index.d.ts CHANGED
@@ -62,12 +62,81 @@ export interface PageLocation {
62
62
  * (`redirect_uri_invalid`), and it would not survive the server redirect anyway.
63
63
  */
64
64
  export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId: string, location: PageLocation): string | null;
65
- /** Launch mode drives the logout affordance: embedded apps hide logout entirely (the
66
- * session belongs to the Shell), standalone apps may show one. `window.self !== window.top`
67
- * is the baseline signal; a Core-reported launch channel can replace it later without
68
- * changing callers. */
69
- export type AppLaunchMode = "embedded" | "standalone";
70
- export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): AppLaunchMode;
65
+ /**
66
+ * How a shell is presenting this app.
67
+ *
68
+ * - `embedded` — framed by the browser Shell. The shell renders navigation for this app, and a
69
+ * parent frame exists, so identity recovery is a `hosty:auth-required` post to it.
70
+ * - `native` — the top frame inside a native shell's web view (`apps/shell-swift`). The shell
71
+ * renders navigation, but there is no parent to post to, so recovery takes the standalone
72
+ * redirect — which is exactly the navigation that client intercepts to re-mint a launch code.
73
+ * - `standalone` — a plain browser tab on the app's own origin. Nothing else renders navigation
74
+ * for this app, so it keeps its own.
75
+ *
76
+ * Two independent decisions read this one value and they do not split it the same way: chrome
77
+ * is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while the parent post
78
+ * belongs to `embedded` alone (`decideRecoveryAction`). That asymmetry is the whole reason
79
+ * `native` exists as its own value — calling a native web view `embedded` to get its chrome
80
+ * hidden would send recovery to a parent that is not there.
81
+ */
82
+ export type AppLaunchMode = "embedded" | "native" | "standalone";
83
+ /** The query parameter a shell appends to the launch URL to declare the mode. */
84
+ export declare const LAUNCH_MODE_PARAM = "hosty_launch";
85
+ /** Where a resolved mode is kept for the life of the tab or web view. */
86
+ export declare const LAUNCH_MODE_STORAGE_KEY = "hosty.launch.mode";
87
+ /** The root attribute the launch bridge writes; CSS hides duplicated chrome off it. */
88
+ export declare const LAUNCH_MODE_ATTRIBUTE = "data-hosty-launch";
89
+ /**
90
+ * Marks an element that duplicates chrome a shell already renders — the app's own name and the
91
+ * navigation between its manifest pages. Contextual controls and information a shell does not
92
+ * render (a project picker, a refresh action, an identity badge) are not chrome in this sense
93
+ * and must not carry it.
94
+ */
95
+ export declare const SHELL_DUPLICATED_CHROME_CLASS = "hosty-shell-chrome";
96
+ export declare function normalizeLaunchMode(value: unknown): AppLaunchMode | null;
97
+ /**
98
+ * The structural signal: is this document framed? It can only ever answer `embedded` or
99
+ * `standalone` — a native shell's web view makes the app the top frame, so it reads as
100
+ * `standalone`, which is why the declared `hosty_launch` parameter exists at all.
101
+ *
102
+ * This stays the input to the *recovery* decision, because whether a parent exists is a
103
+ * structural fact that a declared value cannot override: a stale `embedded` would otherwise
104
+ * post into a window with no shell listening.
105
+ */
106
+ export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): Exclude<AppLaunchMode, "native">;
107
+ export interface LaunchModeResolution {
108
+ mode: AppLaunchMode;
109
+ /** True when the mode came from the URL parameter, and so is worth persisting for this tab. */
110
+ fromParam: boolean;
111
+ }
112
+ /**
113
+ * Resolves the launch mode with explicit precedence: the URL parameter a shell just sent, then
114
+ * the value persisted for this tab, then the structural heuristic.
115
+ *
116
+ * An unrecognized parameter value is ignored rather than honoured or stored — a newer shell must
117
+ * degrade an older app to its previous behavior, never to a mode it cannot render.
118
+ */
119
+ export declare function resolveLaunchMode(input: {
120
+ param?: string | null;
121
+ stored?: string | null;
122
+ heuristic: AppLaunchMode;
123
+ }): LaunchModeResolution;
124
+ /**
125
+ * True when a shell around this app already renders its name and page navigation, so the app's
126
+ * own copies are duplication. False for `standalone`, where nothing else renders them.
127
+ */
128
+ export declare function hidesAppChrome(mode: AppLaunchMode): boolean;
129
+ /**
130
+ * The pre-hydration bootstrap: sets the root attribute from the same precedence
131
+ * `resolveLaunchMode` implements, so chrome that a shell duplicates is never painted before
132
+ * React can hide it. Mount it as an inline `<script>` in the document head.
133
+ *
134
+ * It deliberately does not clean the parameter out of the URL — that is `HostLaunchBridge`'s
135
+ * job, on the same reasoning the theme bridges clean theirs from an effect rather than from
136
+ * their bootstrap: a `history.replaceState` before hydration is a router's business, not a
137
+ * paint-blocking script's.
138
+ */
139
+ export declare const launchModeBootstrapScript: string;
71
140
  /**
72
141
  * The recovery decision: state × launch mode → what the app should do, per the UX
73
142
  * contract (Core `/login` is the only auth UI in the system; apps render no login UI and
package/dist/index.js CHANGED
@@ -72,6 +72,31 @@ export function buildCoreOpenUrl(corePublicOrigin, appId, location) {
72
72
  target.searchParams.set("redirectUri", `${location.origin}${location.pathname}${location.search}`);
73
73
  return target.toString();
74
74
  }
75
+ /** The query parameter a shell appends to the launch URL to declare the mode. */
76
+ export const LAUNCH_MODE_PARAM = "hosty_launch";
77
+ /** Where a resolved mode is kept for the life of the tab or web view. */
78
+ export const LAUNCH_MODE_STORAGE_KEY = "hosty.launch.mode";
79
+ /** The root attribute the launch bridge writes; CSS hides duplicated chrome off it. */
80
+ export const LAUNCH_MODE_ATTRIBUTE = "data-hosty-launch";
81
+ /**
82
+ * Marks an element that duplicates chrome a shell already renders — the app's own name and the
83
+ * navigation between its manifest pages. Contextual controls and information a shell does not
84
+ * render (a project picker, a refresh action, an identity badge) are not chrome in this sense
85
+ * and must not carry it.
86
+ */
87
+ export const SHELL_DUPLICATED_CHROME_CLASS = "hosty-shell-chrome";
88
+ export function normalizeLaunchMode(value) {
89
+ return value === "embedded" || value === "native" || value === "standalone" ? value : null;
90
+ }
91
+ /**
92
+ * The structural signal: is this document framed? It can only ever answer `embedded` or
93
+ * `standalone` — a native shell's web view makes the app the top frame, so it reads as
94
+ * `standalone`, which is why the declared `hosty_launch` parameter exists at all.
95
+ *
96
+ * This stays the input to the *recovery* decision, because whether a parent exists is a
97
+ * structural fact that a declared value cannot override: a stale `embedded` would otherwise
98
+ * post into a window with no shell listening.
99
+ */
75
100
  export function detectLaunchMode(win) {
76
101
  try {
77
102
  return win.self !== win.top ? "embedded" : "standalone";
@@ -82,6 +107,65 @@ export function detectLaunchMode(win) {
82
107
  return "embedded";
83
108
  }
84
109
  }
110
+ /**
111
+ * Resolves the launch mode with explicit precedence: the URL parameter a shell just sent, then
112
+ * the value persisted for this tab, then the structural heuristic.
113
+ *
114
+ * An unrecognized parameter value is ignored rather than honoured or stored — a newer shell must
115
+ * degrade an older app to its previous behavior, never to a mode it cannot render.
116
+ */
117
+ export function resolveLaunchMode(input) {
118
+ const declared = normalizeLaunchMode(input.param);
119
+ if (declared) {
120
+ return { mode: declared, fromParam: true };
121
+ }
122
+ const stored = normalizeLaunchMode(input.stored);
123
+ return { mode: stored ?? input.heuristic, fromParam: false };
124
+ }
125
+ /**
126
+ * True when a shell around this app already renders its name and page navigation, so the app's
127
+ * own copies are duplication. False for `standalone`, where nothing else renders them.
128
+ */
129
+ export function hidesAppChrome(mode) {
130
+ return mode !== "standalone";
131
+ }
132
+ /**
133
+ * The pre-hydration bootstrap: sets the root attribute from the same precedence
134
+ * `resolveLaunchMode` implements, so chrome that a shell duplicates is never painted before
135
+ * React can hide it. Mount it as an inline `<script>` in the document head.
136
+ *
137
+ * It deliberately does not clean the parameter out of the URL — that is `HostLaunchBridge`'s
138
+ * job, on the same reasoning the theme bridges clean theirs from an effect rather than from
139
+ * their bootstrap: a `history.replaceState` before hydration is a router's business, not a
140
+ * paint-blocking script's.
141
+ */
142
+ export const launchModeBootstrapScript = `
143
+ (() => {
144
+ const attribute = ${JSON.stringify(LAUNCH_MODE_ATTRIBUTE)};
145
+ const storageKey = ${JSON.stringify(LAUNCH_MODE_STORAGE_KEY)};
146
+ const known = ["embedded", "native", "standalone"];
147
+ const normalize = (value) => (known.indexOf(value) >= 0 ? value : null);
148
+ try {
149
+ const param = normalize(new URLSearchParams(window.location.search).get(${JSON.stringify(LAUNCH_MODE_PARAM)}));
150
+ let stored = null;
151
+ try {
152
+ stored = normalize(window.sessionStorage.getItem(storageKey));
153
+ } catch {}
154
+ let heuristic;
155
+ try {
156
+ heuristic = window.self !== window.top ? "embedded" : "standalone";
157
+ } catch {
158
+ heuristic = "embedded";
159
+ }
160
+ document.documentElement.setAttribute(attribute, param || stored || heuristic);
161
+ if (param) {
162
+ try {
163
+ window.sessionStorage.setItem(storageKey, param);
164
+ } catch {}
165
+ }
166
+ } catch {}
167
+ })();
168
+ `;
85
169
  export function decideRecoveryAction(input) {
86
170
  const { status, mode, appId, openUrl, redirectAlreadyAttempted } = input;
87
171
  if (status === "active") {
@@ -97,6 +181,11 @@ export function decideRecoveryAction(input) {
97
181
  return { kind: "card", card: "misconfigured" };
98
182
  }
99
183
  // expired / not-present — the recoverable pair.
184
+ //
185
+ // `embedded` is the only mode with a parent to ask. `native` deliberately falls through to the
186
+ // redirect beside `standalone`: a native shell's web view has no parent, and the redirect to
187
+ // Core's `/open` is the navigation that client watches for to re-mint a launch code. Adding a
188
+ // mode must never quietly add a case here — a mode that cannot reach a shell has to redirect.
100
189
  if (mode === "embedded") {
101
190
  return { kind: "post-auth-required", intent: createAuthRequiredIntent(appId) };
102
191
  }
package/dist/react.d.ts CHANGED
@@ -1,10 +1,29 @@
1
- import { type AppSessionStatus } from "./index.js";
1
+ import { type AppLaunchMode, type AppSessionStatus } from "./index.js";
2
2
  /**
3
3
  * Tolerant probe reader: accepts both identity-route shapes in the fleet — a top-level
4
4
  * `status` (marketplace/telemetry) and a nested `appSession.status` (demo-app,
5
5
  * project-manager). The legacy `error` status maps to `unavailable` (transient).
6
6
  */
7
7
  export declare function readProbedSessionStatus(body: unknown): AppSessionStatus | null;
8
+ /**
9
+ * Mount once in the root layout. Applies the resolved launch mode to `<html>` as
10
+ * `data-hosty-launch`, persists a mode the shell declared, and cleans the parameter out of the
11
+ * URL so a copied link cannot carry a shell's presentation into a plain browser tab.
12
+ *
13
+ * Pair it with `launchModeBootstrapScript` in the head: this effect runs after first paint, so
14
+ * without the script an app's own header is painted for a frame before it is hidden. Where that
15
+ * matters most is the native web view, which shows first paint directly — the browser Shell
16
+ * fades its iframe in on load and masks it.
17
+ */
18
+ export declare function HostLaunchBridge(): null;
19
+ /**
20
+ * The resolved launch mode, for logic that needs it — `null` until the first client effect.
21
+ *
22
+ * Rendering off this value costs a frame and risks a hydration mismatch, which is why chrome is
23
+ * hidden with CSS keyed on the root attribute instead. Reach for the hook only where CSS cannot
24
+ * express the decision.
25
+ */
26
+ export declare function useLaunchMode(): AppLaunchMode | null;
8
27
  export interface AppIdentityBridgeProps {
9
28
  /** Probe endpoint returning a session status + recovery params. */
10
29
  probePath?: string;
package/dist/react.js CHANGED
@@ -4,7 +4,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
4
4
  // cards. Prop-less by design: the app id and browser-reachable Core origin arrive in the
5
5
  // probe response's `recovery` field (request-time values, never build-time props).
6
6
  import { useEffect, useState } from "react";
7
- import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, readRecoveryParams, } from "./index.js";
7
+ import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
8
8
  // Once-per-tab guard so a standalone app that returns from Core still unauthorized does
9
9
  // not bounce through /open forever. Cleared on a successful code exchange.
10
10
  const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
@@ -65,6 +65,81 @@ function writeGuard(value) {
65
65
  // sessionStorage may be blocked; recovery still works, only the guard is lost.
66
66
  }
67
67
  }
68
+ function readStoredLaunchMode() {
69
+ try {
70
+ return window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** The mode already applied to the document, or a fresh resolution when nothing applied one. */
77
+ function currentLaunchMode() {
78
+ const applied = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
79
+ if (applied) {
80
+ return applied;
81
+ }
82
+ return resolveLaunchMode({
83
+ param: new URL(window.location.href).searchParams.get(LAUNCH_MODE_PARAM),
84
+ stored: readStoredLaunchMode(),
85
+ heuristic: detectLaunchMode(window),
86
+ }).mode;
87
+ }
88
+ /**
89
+ * Mount once in the root layout. Applies the resolved launch mode to `<html>` as
90
+ * `data-hosty-launch`, persists a mode the shell declared, and cleans the parameter out of the
91
+ * URL so a copied link cannot carry a shell's presentation into a plain browser tab.
92
+ *
93
+ * Pair it with `launchModeBootstrapScript` in the head: this effect runs after first paint, so
94
+ * without the script an app's own header is painted for a frame before it is hidden. Where that
95
+ * matters most is the native web view, which shows first paint directly — the browser Shell
96
+ * fades its iframe in on load and masks it.
97
+ */
98
+ export function HostLaunchBridge() {
99
+ useEffect(() => {
100
+ const url = new URL(window.location.href);
101
+ const param = url.searchParams.get(LAUNCH_MODE_PARAM);
102
+ const { mode, fromParam } = resolveLaunchMode({
103
+ param,
104
+ stored: readStoredLaunchMode(),
105
+ heuristic: detectLaunchMode(window),
106
+ });
107
+ document.documentElement.setAttribute(LAUNCH_MODE_ATTRIBUTE, mode);
108
+ if (fromParam) {
109
+ try {
110
+ window.sessionStorage.setItem(LAUNCH_MODE_STORAGE_KEY, mode);
111
+ }
112
+ catch {
113
+ // sessionStorage may be blocked. The shell re-declares the mode on every launch and on
114
+ // every page switch it drives, so only app-internal navigation loses it.
115
+ }
116
+ }
117
+ // Cleaned whenever it is present, including an unrecognized value: a parameter this app
118
+ // ignored must not survive into a link someone copies.
119
+ if (url.searchParams.has(LAUNCH_MODE_PARAM)) {
120
+ url.searchParams.delete(LAUNCH_MODE_PARAM);
121
+ window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
122
+ }
123
+ }, []);
124
+ return null;
125
+ }
126
+ /**
127
+ * The resolved launch mode, for logic that needs it — `null` until the first client effect.
128
+ *
129
+ * Rendering off this value costs a frame and risks a hydration mismatch, which is why chrome is
130
+ * hidden with CSS keyed on the root attribute instead. Reach for the hook only where CSS cannot
131
+ * express the decision.
132
+ */
133
+ export function useLaunchMode() {
134
+ const [mode, setMode] = useState(null);
135
+ useEffect(() => {
136
+ // Effects run child-first, so a consumer deeper in the tree runs before `HostLaunchBridge`
137
+ // has applied the attribute. Resolving here rather than only reading the attribute makes the
138
+ // hook correct in either order.
139
+ setMode(currentLaunchMode());
140
+ }, []);
141
+ return mode;
142
+ }
68
143
  /**
69
144
  * Mount once at the top of the root layout body. On first load it consumes a `?code` from
70
145
  * the URL (exchanging it for the identity cookie), then probes the session and runs the
package/dist/server.d.ts CHANGED
@@ -106,3 +106,33 @@ export declare function exchangeAppCode(code: string): Promise<AppCodeExchange>;
106
106
  * Framework-agnostic (Web `Request`/`Response`), usable directly as a Next route handler.
107
107
  */
108
108
  export declare function createAppCodeRouteHandler(config: HostyAppConfig): (request: Request) => Promise<Response>;
109
+ /** Thrown when the secrets store could not be reached or rejected a request. A *missing* secret
110
+ * is not an error — {@link getAppSecret} returns null for that. */
111
+ export declare class HostySecretsError extends Error {
112
+ /** Core's HTTP status, or null when Core was never reached. */
113
+ readonly status: number | null;
114
+ /** Machine-readable code from Core's error body, or a local code when Core was unreachable. */
115
+ readonly code: string;
116
+ constructor(message: string, status: number | null, code: string);
117
+ }
118
+ /** Test hook: the cache is process-global by design (route handlers are stateless). */
119
+ export declare function clearAppSecretsCache(): void;
120
+ /**
121
+ * Reads a secret from the app's Core-managed store. Returns `null` when no secret is stored under
122
+ * the key — an expected state (never connected, or the app was reinstalled on a new host), which
123
+ * callers should treat as "reconnect required" rather than an error.
124
+ *
125
+ * Served from a write-through cache unless `refresh` is set.
126
+ */
127
+ export declare function getAppSecret(key: string, config: HostyAppConfig, options?: {
128
+ refresh?: boolean;
129
+ }): Promise<string | null>;
130
+ /** Stores (or replaces) a secret. Throws {@link HostySecretsError} when Core rejects the value
131
+ * (malformed key, empty/oversize value, per-app key limit) or is unreachable. */
132
+ export declare function setAppSecret(key: string, value: string, config: HostyAppConfig): Promise<void>;
133
+ /** Deletes a secret. Deleting an absent key succeeds. */
134
+ export declare function deleteAppSecret(key: string, config: HostyAppConfig): Promise<void>;
135
+ /** Lists the keys this app has stored. Always a live read — Core never returns values here, and
136
+ * the cache only knows the keys this process has touched. */
137
+ export declare function listAppSecretKeys(config: HostyAppConfig): Promise<string[]>;
138
+ export { validateDelegatedToken, type DelegatedTokenClaims, type ValidateDelegatedTokenOptions, } from "./delegated.js";
package/dist/server.js CHANGED
@@ -372,3 +372,186 @@ function readErrorField(payload, field) {
372
372
  function readString(value) {
373
373
  return typeof value === "string" && value.trim() ? value.trim() : null;
374
374
  }
375
+ // ---------------------------------------------------------------------------
376
+ // App secrets — the Core-managed keychain for runtime-acquired credentials.
377
+ // ---------------------------------------------------------------------------
378
+ // Secrets are small and Core is local, but a stalled call must not hold a route open. Slightly
379
+ // more generous than the auth budget: a secret read blocks real work, not just a session check.
380
+ const CORE_SECRETS_TIMEOUT_MS = 3_000;
381
+ /** Thrown when the secrets store could not be reached or rejected a request. A *missing* secret
382
+ * is not an error — {@link getAppSecret} returns null for that. */
383
+ export class HostySecretsError extends Error {
384
+ /** Core's HTTP status, or null when Core was never reached. */
385
+ status;
386
+ /** Machine-readable code from Core's error body, or a local code when Core was unreachable. */
387
+ code;
388
+ constructor(message, status, code) {
389
+ super(message);
390
+ this.name = "HostySecretsError";
391
+ this.status = status;
392
+ this.code = code;
393
+ }
394
+ }
395
+ // Write-through cache, process-global like the revalidation cache above: reads populate it,
396
+ // mutations update it, so a briefly unavailable Core does not break an app that already read its
397
+ // secret. Values are only ever what this process wrote or fetched, so no invalidation is needed.
398
+ // Unbounded is fine here (unlike the token cache): keys are the app's own, and Core caps them at
399
+ // 256 per app.
400
+ //
401
+ // Entries are namespaced by Core origin + effective app id, not by the bare secret name: the app
402
+ // id comes from the environment with a per-config fallback, so one process can legitimately serve
403
+ // more than one identity (multi-tenant hosting, or tests reusing the process). Sharing a cache
404
+ // slot across identities would hand app B a value that only app A's service token could fetch,
405
+ // silently bypassing the isolation Core enforces.
406
+ const secretsCache = new Map();
407
+ // Per-entry mutation counter. A read samples it before calling Core and only writes its result
408
+ // back if it has not changed: without this a GET issued before a set/delete but completing after
409
+ // it would overwrite the newer state, and later cached reads would serve the stale credential
410
+ // (or a stale miss) until someone passed `refresh`.
411
+ const secretsMutations = new Map();
412
+ /** Test hook: the cache is process-global by design (route handlers are stateless). */
413
+ export function clearAppSecretsCache() {
414
+ secretsCache.clear();
415
+ secretsMutations.clear();
416
+ }
417
+ function secretsCacheKey(key, config) {
418
+ // NUL cannot appear in an origin, an app id, or a Core-accepted secret key, so it is an
419
+ // unambiguous separator.
420
+ return `${getCoreOrigin() ?? ""}\u0000${getAppId(config)}\u0000${key}`;
421
+ }
422
+ function recordSecretMutation(cacheKey, value) {
423
+ secretsMutations.set(cacheKey, (secretsMutations.get(cacheKey) ?? 0) + 1);
424
+ secretsCache.set(cacheKey, value);
425
+ }
426
+ /**
427
+ * Reads a secret from the app's Core-managed store. Returns `null` when no secret is stored under
428
+ * the key — an expected state (never connected, or the app was reinstalled on a new host), which
429
+ * callers should treat as "reconnect required" rather than an error.
430
+ *
431
+ * Served from a write-through cache unless `refresh` is set.
432
+ */
433
+ export async function getAppSecret(key, config, options) {
434
+ const cacheKey = secretsCacheKey(key, config);
435
+ if (!options?.refresh && secretsCache.has(cacheKey)) {
436
+ return secretsCache.get(cacheKey) ?? null;
437
+ }
438
+ const generation = secretsMutations.get(cacheKey) ?? 0;
439
+ const response = await secretsFetch("GET", key, config, undefined);
440
+ let value = null;
441
+ if (response !== null) {
442
+ // Deliberately not readString: a secret is opaque bytes, so trimming it would corrupt a value
443
+ // whose whitespace is significant. Core stores and returns it verbatim.
444
+ const raw = (await readJsonOrThrow(response))?.value;
445
+ if (typeof raw !== "string") {
446
+ // A 200 without a usable value is a broken Core/proxy, not an absent secret; reporting it
447
+ // as "no value" would send the app into a reconnect loop instead of surfacing the fault.
448
+ throw new HostySecretsError("Core returned a secret response without a value.", response.status, "core_response_invalid");
449
+ }
450
+ value = raw;
451
+ }
452
+ // Skip the cache write when a set/delete landed while this read was in flight: its state is
453
+ // newer than ours, and overwriting it would serve the stale value to every later reader.
454
+ if ((secretsMutations.get(cacheKey) ?? 0) === generation) {
455
+ secretsCache.set(cacheKey, value);
456
+ }
457
+ return value;
458
+ }
459
+ /** Stores (or replaces) a secret. Throws {@link HostySecretsError} when Core rejects the value
460
+ * (malformed key, empty/oversize value, per-app key limit) or is unreachable. */
461
+ export async function setAppSecret(key, value, config) {
462
+ await secretsFetch("PUT", key, config, JSON.stringify({ value }));
463
+ recordSecretMutation(secretsCacheKey(key, config), value);
464
+ }
465
+ /** Deletes a secret. Deleting an absent key succeeds. */
466
+ export async function deleteAppSecret(key, config) {
467
+ await secretsFetch("DELETE", key, config, undefined);
468
+ recordSecretMutation(secretsCacheKey(key, config), null);
469
+ }
470
+ /** Lists the keys this app has stored. Always a live read — Core never returns values here, and
471
+ * the cache only knows the keys this process has touched. */
472
+ export async function listAppSecretKeys(config) {
473
+ // Only a per-key GET can resolve to null (an absent secret); the collection route throws instead.
474
+ const response = await secretsFetch("GET", null, config, undefined);
475
+ const payload = response === null ? null : (await readJsonOrThrow(response));
476
+ if (!Array.isArray(payload?.keys)) {
477
+ // Same reasoning as a valueless secret response: an unusable 200 is a fault to surface, not
478
+ // an app that happens to have stored nothing.
479
+ throw new HostySecretsError("Core returned a secret listing without a keys array.", response?.status ?? null, "core_response_invalid");
480
+ }
481
+ return payload.keys.filter((entry) => typeof entry === "string");
482
+ }
483
+ // Returns null only for a GET whose key is absent (404), which callers translate to "no value";
484
+ // every other non-2xx throws. A null key targets the collection route.
485
+ async function secretsFetch(method, key, config, body) {
486
+ const serviceToken = getServiceToken();
487
+ const coreOrigin = getCoreOrigin();
488
+ if (!serviceToken || !coreOrigin) {
489
+ throw new HostySecretsError(serviceToken ? "HOSTY_CORE_ORIGIN is not configured." : "HOSTY_APP_SERVICE_TOKEN is not configured.", null, serviceToken ? "core_origin_missing" : "app_service_token_missing");
490
+ }
491
+ const appId = getAppId(config);
492
+ const path = key === null
493
+ ? `/api/internal/apps/${encodeURIComponent(appId)}/secrets`
494
+ : `/api/internal/apps/${encodeURIComponent(appId)}/secrets/${encodeURIComponent(key)}`;
495
+ let endpoint;
496
+ try {
497
+ endpoint = new URL(path, coreOrigin).toString();
498
+ }
499
+ catch {
500
+ throw new HostySecretsError("HOSTY_CORE_ORIGIN is not a valid URL.", null, "core_origin_invalid");
501
+ }
502
+ let response;
503
+ try {
504
+ response = await fetch(endpoint, {
505
+ method,
506
+ headers: {
507
+ authorization: `Bearer ${serviceToken}`,
508
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
509
+ },
510
+ body,
511
+ cache: "no-store",
512
+ signal: AbortSignal.timeout(CORE_SECRETS_TIMEOUT_MS),
513
+ });
514
+ }
515
+ catch (error) {
516
+ const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
517
+ throw new HostySecretsError(error instanceof Error ? error.message : "The app secrets request to Core failed.", null, timeout ? "core_secrets_timeout" : "core_secrets_unavailable");
518
+ }
519
+ if (response.ok) {
520
+ return response;
521
+ }
522
+ const payload = (await readJson(response));
523
+ // Core answers 404 on a per-key GET in two distinct cases, and only one is an answer: no secret
524
+ // stored under the key (`app_secret_not_found`) versus the routed app being unknown or removed
525
+ // (`app_not_found`). Collapsing them would report a removed app as a routine reconnect.
526
+ if (method === "GET" &&
527
+ key !== null &&
528
+ response.status === 404 &&
529
+ readErrorField(payload, "code") !== "app_not_found") {
530
+ return null;
531
+ }
532
+ throw new HostySecretsError(readErrorField(payload, "message") ?? `Core returned HTTP ${response.status} for an app secrets request.`, response.status, readErrorField(payload, "code") ?? "app_secrets_request_failed");
533
+ }
534
+ // Lenient: used only for error bodies, where a missing code/message costs nothing.
535
+ async function readJson(response) {
536
+ try {
537
+ return await response.json();
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ }
543
+ // Strict: on a 2xx an unparseable body means Core or something in front of it is broken, and
544
+ // degrading that to "no secret" / "no keys" would hide the fault behind a plausible-looking
545
+ // answer. Mirrors the .NET client and the revalidation path's core_response_invalid outcome.
546
+ async function readJsonOrThrow(response) {
547
+ try {
548
+ return await response.json();
549
+ }
550
+ catch {
551
+ throw new HostySecretsError("Core returned an unreadable app secrets response.", response.status, "core_response_invalid");
552
+ }
553
+ }
554
+ // Delegated tokens live in their own entry ("./delegated") because that module must stay
555
+ // importable from plain Node services (the ai-gateway system app) — this file's "server-only"
556
+ // import would throw there. Re-exported here so Next apps keep one import surface.
557
+ export { validateDelegatedToken, } from "./delegated.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.2.0",
3
+ "version": "0.6.0",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -13,6 +13,10 @@
13
13
  "types": "./dist/server.d.ts",
14
14
  "default": "./dist/server.js"
15
15
  },
16
+ "./delegated": {
17
+ "types": "./dist/delegated.d.ts",
18
+ "default": "./dist/delegated.js"
19
+ },
16
20
  "./react": {
17
21
  "types": "./dist/react.d.ts",
18
22
  "default": "./dist/react.js"
@@ -30,8 +34,8 @@
30
34
  "server-only": "^0.0.1"
31
35
  },
32
36
  "peerDependencies": {
33
- "next": ">=16",
34
- "react": ">=19"
37
+ "next": "^16.2.10",
38
+ "react": "^19.2.8"
35
39
  },
36
40
  "peerDependenciesMeta": {
37
41
  "next": {
@@ -42,10 +46,10 @@
42
46
  }
43
47
  },
44
48
  "devDependencies": {
45
- "typescript": "^5",
46
- "vitest": "^4",
47
49
  "@types/node": "^24",
48
- "@types/react": "^19"
50
+ "@types/react": "^19.2.18",
51
+ "typescript": "^6",
52
+ "vitest": "^4"
49
53
  },
50
54
  "repository": {
51
55
  "type": "git",