@hosty-sdk/app 0.1.2 → 0.5.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 +15 -1
- package/dist/index.d.ts +75 -6
- package/dist/index.js +89 -0
- package/dist/react.d.ts +20 -1
- package/dist/react.js +76 -1
- package/dist/server.d.ts +39 -0
- package/dist/server.js +226 -7
- package/package.json +6 -6
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,20 @@ 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
|
+
|
|
35
49
|
The design contract lives in the Hosty repository:
|
|
36
50
|
[`docs/ideas/hosty-app-sdk.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/ideas/hosty-app-sdk.md).
|
|
37
51
|
|
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
|
-
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
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
|
@@ -22,11 +22,21 @@ export interface HostIdentity {
|
|
|
22
22
|
appRole: string | null;
|
|
23
23
|
expiresAt: string | null;
|
|
24
24
|
}
|
|
25
|
+
/** Structured detail for a failed resolution: the Core HTTP status (null when Core was
|
|
26
|
+
* never reached) plus a machine-readable code and human-readable message. Codes pass
|
|
27
|
+
* through from Core's error body where one exists; classification never branches on them
|
|
28
|
+
* (decision 10) — they exist for diagnostics and API responses. */
|
|
29
|
+
export interface ResolutionError {
|
|
30
|
+
status: number | null;
|
|
31
|
+
code: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
25
34
|
export type AppSessionResolution = {
|
|
26
35
|
status: "active";
|
|
27
36
|
identity: HostIdentity;
|
|
28
37
|
} | {
|
|
29
38
|
status: AppSessionFailureStatus;
|
|
39
|
+
error?: ResolutionError;
|
|
30
40
|
};
|
|
31
41
|
export declare function getAppId(config: HostyAppConfig): string;
|
|
32
42
|
export declare function getCoreOrigin(): string | null;
|
|
@@ -96,3 +106,32 @@ export declare function exchangeAppCode(code: string): Promise<AppCodeExchange>;
|
|
|
96
106
|
* Framework-agnostic (Web `Request`/`Response`), usable directly as a Next route handler.
|
|
97
107
|
*/
|
|
98
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[]>;
|
package/dist/server.js
CHANGED
|
@@ -91,7 +91,12 @@ export async function resolveAppSession(token, config) {
|
|
|
91
91
|
const serviceToken = getServiceToken();
|
|
92
92
|
const coreOrigin = getCoreOrigin();
|
|
93
93
|
if (!serviceToken || !coreOrigin) {
|
|
94
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
status: "misconfigured",
|
|
96
|
+
error: serviceToken
|
|
97
|
+
? { status: null, code: "core_origin_missing", message: "HOSTY_CORE_ORIGIN is not configured." }
|
|
98
|
+
: { status: null, code: "app_service_token_missing", message: "HOSTY_APP_SERVICE_TOKEN is not configured." },
|
|
99
|
+
};
|
|
95
100
|
}
|
|
96
101
|
const now = Date.now();
|
|
97
102
|
const cached = revalidationCache.get(token);
|
|
@@ -120,7 +125,10 @@ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
|
|
|
120
125
|
endpoint = new URL("/api/auth/apps/revalidate", coreOrigin).toString();
|
|
121
126
|
}
|
|
122
127
|
catch {
|
|
123
|
-
return {
|
|
128
|
+
return {
|
|
129
|
+
status: "misconfigured",
|
|
130
|
+
error: { status: null, code: "core_origin_invalid", message: "HOSTY_CORE_ORIGIN is not a valid URL." },
|
|
131
|
+
};
|
|
124
132
|
}
|
|
125
133
|
let response;
|
|
126
134
|
try {
|
|
@@ -135,12 +143,28 @@ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
|
|
|
135
143
|
signal: AbortSignal.timeout(CORE_AUTH_TIMEOUT_MS),
|
|
136
144
|
});
|
|
137
145
|
}
|
|
138
|
-
catch {
|
|
146
|
+
catch (error) {
|
|
139
147
|
// Network failure or timeout — transient either way; never cached.
|
|
140
|
-
|
|
148
|
+
const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
149
|
+
return {
|
|
150
|
+
status: "unavailable",
|
|
151
|
+
error: {
|
|
152
|
+
status: null,
|
|
153
|
+
code: timeout ? "core_revalidation_timeout" : "app_session_revalidation_error",
|
|
154
|
+
message: error instanceof Error ? error.message : "Core identity revalidation failed.",
|
|
155
|
+
},
|
|
156
|
+
};
|
|
141
157
|
}
|
|
142
158
|
if (!response.ok) {
|
|
143
|
-
|
|
159
|
+
const body = (await response.json().catch(() => null));
|
|
160
|
+
return {
|
|
161
|
+
status: classifyRevalidationHttpStatus(response.status),
|
|
162
|
+
error: {
|
|
163
|
+
status: response.status,
|
|
164
|
+
code: readErrorField(body, "code") ?? "app_session_revalidation_failed",
|
|
165
|
+
message: readErrorField(body, "message") ?? `Core revalidation returned HTTP ${response.status}.`,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
144
168
|
}
|
|
145
169
|
let payload;
|
|
146
170
|
try {
|
|
@@ -148,12 +172,18 @@ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
|
|
|
148
172
|
}
|
|
149
173
|
catch {
|
|
150
174
|
// A non-JSON / truncated body means the session is unverifiable, not proof it is invalid.
|
|
151
|
-
return {
|
|
175
|
+
return {
|
|
176
|
+
status: "unavailable",
|
|
177
|
+
error: { status: response.status, code: "core_response_invalid", message: "Core returned an unreadable revalidation body." },
|
|
178
|
+
};
|
|
152
179
|
}
|
|
153
180
|
const appId = readString(payload?.appId);
|
|
154
181
|
if (appId && appId !== getAppId(config)) {
|
|
155
182
|
// A token minted for a different app is Core's token_app_mismatch — terminal, like 403.
|
|
156
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
status: "forbidden",
|
|
185
|
+
error: { status: response.status, code: "app_identity_app_mismatch", message: "Identity token was issued for a different app." },
|
|
186
|
+
};
|
|
157
187
|
}
|
|
158
188
|
const userId = readString(payload?.userId);
|
|
159
189
|
// An "active" grant without a subject is unusable; classify it as recoverable so a probe
|
|
@@ -329,6 +359,195 @@ function readCookie(cookieHeader, name) {
|
|
|
329
359
|
}
|
|
330
360
|
return null;
|
|
331
361
|
}
|
|
362
|
+
function readErrorField(payload, field) {
|
|
363
|
+
if (!payload || typeof payload !== "object") {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const nested = payload.error;
|
|
367
|
+
if (nested && typeof nested === "object") {
|
|
368
|
+
return readString(nested[field]);
|
|
369
|
+
}
|
|
370
|
+
return readString(payload[field]);
|
|
371
|
+
}
|
|
332
372
|
function readString(value) {
|
|
333
373
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
334
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"server-only": "^0.0.1"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"next": "
|
|
34
|
-
"react": "
|
|
33
|
+
"next": "^16.2.10",
|
|
34
|
+
"react": "^19.2.8"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
37
37
|
"next": {
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"typescript": "^5",
|
|
46
|
-
"vitest": "^4",
|
|
47
45
|
"@types/node": "^24",
|
|
48
|
-
"@types/react": "^19"
|
|
46
|
+
"@types/react": "^19.2.18",
|
|
47
|
+
"typescript": "^6",
|
|
48
|
+
"vitest": "^4"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|