@hosty-sdk/app 0.21.2 → 0.23.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 +67 -18
- package/dist/activity-react.d.ts +11 -2
- package/dist/activity-react.js +11 -5
- package/dist/browser-auth.d.ts +4 -0
- package/dist/browser-auth.js +11 -0
- package/dist/index.d.ts +2 -21
- package/dist/index.js +6 -0
- package/dist/install-react.d.ts +8 -2
- package/dist/install-react.js +120 -39
- package/dist/install.d.ts +37 -3
- package/dist/install.js +31 -13
- package/dist/overlay-react.d.ts +7 -0
- package/dist/overlay-react.js +252 -0
- package/dist/react.d.ts +15 -1
- package/dist/react.js +26 -9
- package/dist/server.d.ts +4 -0
- package/dist/server.js +10 -0
- package/dist/session-server.d.ts +21 -0
- package/dist/session-server.js +27 -0
- package/dist/session-types.d.ts +21 -0
- package/dist/session-types.js +15 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -11,37 +11,75 @@ npm install @hosty-sdk/app
|
|
|
11
11
|
| --- | --- | --- |
|
|
12
12
|
| `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` and `hosty:request-delegated-token` schemas, URL/env helpers |
|
|
13
13
|
| `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
|
|
14
|
-
| `@hosty-sdk/app/react` | client | `<
|
|
14
|
+
| `@hosty-sdk/app/react` | client | `<HostyOverlay />` — shared loading, identity, required setup and recovery UI; legacy bridges remain available |
|
|
15
|
+
| `@hosty-sdk/app/session/server` | server | Framework-neutral session response adapter for authenticated Node servers |
|
|
15
16
|
| `@hosty-sdk/app/embedder` | client | theme sender and legacy embedder message parsers; Shell no longer mints app credentials |
|
|
16
17
|
| `@hosty-sdk/app/browser-auth` | client | `appFetch` for app-local API requests and bound Core popup sign-in |
|
|
17
18
|
| `@hosty-sdk/app/providers` | anywhere | permission state, provider descriptors, speech contract types |
|
|
18
19
|
| `@hosty-sdk/app/providers/server` | server only | `ProviderClient`: discovery, speech recognition, assistant handoffs and live credential validation |
|
|
19
20
|
| `@hosty-sdk/app/theme` | anywhere | the shell→app theme protocol: constants, `resolveTheme`, `applyTheme`, `parseShellThemeMessage`, `themeBootstrapScript` / `createThemeBootstrapScript` |
|
|
20
21
|
|
|
21
|
-
Minimal Next.js wiring:
|
|
22
|
+
Minimal Next.js wiring (the configuration object stays server-side):
|
|
22
23
|
|
|
23
24
|
```tsx
|
|
24
|
-
// app/layout.tsx
|
|
25
|
-
import {
|
|
26
|
-
//
|
|
25
|
+
// app/layout.tsx — inside the body and the existing theme/launch providers
|
|
26
|
+
import { HostyOverlay } from "@hosty-sdk/app/react";
|
|
27
|
+
// <HostyOverlay>{children}</HostyOverlay>
|
|
27
28
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// The callback runs before children mount, only for a successful active probe.
|
|
29
|
+
// lib/hosty.ts
|
|
30
|
+
export const hostyConfig = {
|
|
31
|
+
appIdFallback: "com.example.my-app",
|
|
32
|
+
identityCookieName: "my_app_hosty_identity",
|
|
33
|
+
};
|
|
34
34
|
|
|
35
|
-
// app/api/
|
|
36
|
-
import {
|
|
35
|
+
// app/api/hosty/session/route.ts
|
|
36
|
+
import { createHostySessionRouteHandler } from "@hosty-sdk/app/server";
|
|
37
|
+
import { hostyConfig } from "@/lib/hosty";
|
|
37
38
|
export const dynamic = "force-dynamic";
|
|
38
39
|
export const runtime = "nodejs";
|
|
39
|
-
export const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
export const GET = createHostySessionRouteHandler(hostyConfig);
|
|
41
|
+
// Administrator-only app: pass { administratorOnly: true } as the second argument.
|
|
42
|
+
|
|
43
|
+
// app/api/auth/app-code/route.ts
|
|
44
|
+
import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
|
|
45
|
+
import { hostyConfig } from "@/lib/hosty";
|
|
46
|
+
export const POST = createAppCodeRouteHandler(hostyConfig);
|
|
43
47
|
```
|
|
44
48
|
|
|
49
|
+
Replace the protected scope's `AppIdentityBridge`, `AppActivityBridge`, custom sign-in rendering
|
|
50
|
+
and `MissingPermissionsNotice` with one `HostyOverlay`. Keep public routes outside that scope.
|
|
51
|
+
Existing launch/theme bridges and bootstrap scripts stay in place. The only optional overlay props
|
|
52
|
+
are `probePath` and `appCodePath` for existing route names; it has no visual customization API.
|
|
53
|
+
API routes and server-rendered data still enforce their own authorization. The overlay delays
|
|
54
|
+
protected client mounting; it is not a server data-access boundary.
|
|
55
|
+
|
|
56
|
+
The session adapter resolves identity and checks the app's declared required permissions together.
|
|
57
|
+
Only an administrator receives permission details and a Core review action; a regular user sees an
|
|
58
|
+
administrator-setup explanation without buttons. Optional permissions do not block the app.
|
|
59
|
+
A missing setup capability on Core displays a compatibility state instead of exposing content.
|
|
60
|
+
Keep service credentials on the server and use `appFetch` for app-local browser requests.
|
|
61
|
+
|
|
62
|
+
For a plain Node server, call `createHostySessionResponse(validatedSession, recovery, options)` from
|
|
63
|
+
`@hosty-sdk/app/session/server` and forward its response. Pass a server-validated session, never an
|
|
64
|
+
identity supplied by the request body. Harness is the in-repository example. Shell retains its
|
|
65
|
+
HttpOnly-only code exchange; the shared coordinator also accepts a successful exchange without a
|
|
66
|
+
browser-readable token.
|
|
67
|
+
|
|
68
|
+
After first mount, blocked content and app portals become hidden and inert while the component tree
|
|
69
|
+
remains mounted. Same-user renewal restores it. A different stable user ID reloads the app document
|
|
70
|
+
and refuses to replay old pending requests. `APP_SESSION_RESTORED` from `/browser-auth` is an optional
|
|
71
|
+
DOM notification with `detail.userId`; it contains no credential. There is no generic draft persistence
|
|
72
|
+
across reloads or cache-clearing framework. Existing identity idle/absolute lifetimes and fixed
|
|
73
|
+
privileged activity windows still apply.
|
|
74
|
+
|
|
75
|
+
Compatible SDK updates change standard presentation and recovery after a dependency update and
|
|
76
|
+
rebuild, without editing this wiring. New mandatory Core capabilities can require upgrading Core.
|
|
77
|
+
`node packages/app-sdk/scripts/check-overlay-upgrade.mjs` checks the published export layout using
|
|
78
|
+
identical consumer source before and after a dependency-only presentation revision (run SDK build first).
|
|
79
|
+
|
|
80
|
+
Legacy `AppIdentityBridge` exports, `renderState` and `onSession` remain compatible for consumers
|
|
81
|
+
that have not migrated. See [Hosty Overlay](../../docs/features/hosty-overlay/feature.md) for behavior.
|
|
82
|
+
|
|
45
83
|
Following the shell's theme — the launch parameters decide the theme a document loads with, the
|
|
46
84
|
`hosty:shell-theme` post covers changes while the frame is up, and the bootstrap keeps the first
|
|
47
85
|
paint right:
|
|
@@ -138,23 +176,34 @@ Set `publicOrigin` to the Core-injected public origin for your UI endpoint when
|
|
|
138
176
|
exposes an internal request URL. The adapter checks browser `Origin` against this configured
|
|
139
177
|
value, or against the request URL when it is omitted; forwarded-host headers are never trusted.
|
|
140
178
|
|
|
141
|
-
The default dialog
|
|
179
|
+
The default dialog prepares installation and links to Core confirmation:
|
|
142
180
|
|
|
143
181
|
```tsx
|
|
144
182
|
"use client";
|
|
145
183
|
import { createInstallationClient } from "@hosty-sdk/app/install";
|
|
146
184
|
import { InstallDialog } from "@hosty-sdk/app/install/react";
|
|
185
|
+
import { openInstallationConfirmation } from "@hosty-sdk/app/install";
|
|
147
186
|
const client = createInstallationClient();
|
|
148
187
|
|
|
188
|
+
// In the Install click handler, before awaiting source preparation:
|
|
189
|
+
const confirmationWindow = openInstallationConfirmation();
|
|
149
190
|
// Render conditionally while open; unmount it when closed.
|
|
150
191
|
<InstallDialog
|
|
151
192
|
client={client}
|
|
193
|
+
confirmationWindow={confirmationWindow}
|
|
152
194
|
source={{ feedsUrl: "https://example.com/feeds.json", feedId: "stable" }}
|
|
153
195
|
onClose={() => setOpen(false)}
|
|
154
196
|
onInstalled={() => { setOpen(false); refreshInstalledApps(); }}
|
|
155
197
|
/>;
|
|
156
198
|
```
|
|
157
199
|
|
|
200
|
+
The default dialog immediately prepares the selected source and submits defaults. Core alone
|
|
201
|
+
asks for runtime, automatic startup and permissions. Choose the channel before opening the
|
|
202
|
+
dialog; changing the source creates a new request. Missing required configuration does not
|
|
203
|
+
prevent installation: configure the stopped app afterwards. The dialog shows preparation,
|
|
204
|
+
errors, status and an explicit confirmation link when the popup is blocked. An uncertain
|
|
205
|
+
submission retains its ID and offers status-only recovery.
|
|
206
|
+
|
|
158
207
|
For a custom UI, use `useInstallation(client)` or the framework-independent `InstallationFlow`.
|
|
159
208
|
For direct API use, the same client provides the full request sequence:
|
|
160
209
|
|
package/dist/activity-react.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppAuthProtocol } from "./app-code.js";
|
|
2
2
|
/** Keep mounted beside app content so expiry never discards drafts or component state. */
|
|
3
|
-
export
|
|
3
|
+
export type AppActivityOptions = {
|
|
4
4
|
openUrl: string;
|
|
5
5
|
appCodePath: string;
|
|
6
6
|
activeUntil?: string | null;
|
|
@@ -8,4 +8,13 @@ export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, a
|
|
|
8
8
|
appAuthProtocol?: AppAuthProtocol | null;
|
|
9
9
|
probePath?: string;
|
|
10
10
|
native?: boolean;
|
|
11
|
-
|
|
11
|
+
validateSession?: () => Promise<boolean>;
|
|
12
|
+
};
|
|
13
|
+
export declare function useAppActivity(options: AppActivityOptions | null): {
|
|
14
|
+
needed: boolean;
|
|
15
|
+
pending: boolean;
|
|
16
|
+
error: string;
|
|
17
|
+
renew: () => void;
|
|
18
|
+
cancel: () => void;
|
|
19
|
+
};
|
|
20
|
+
export declare function AppActivityBridge(options: AppActivityOptions): import("react").JSX.Element | null;
|
package/dist/activity-react.js
CHANGED
|
@@ -5,8 +5,8 @@ import { AuthNotice, AuthNoticeButton } from "./auth-notice.js";
|
|
|
5
5
|
import { APP_SESSION_ENDED, APP_ACTIVITY_RENEWED, appActivityNeedsRenewal, configureAppActivity, updateAppActivity, renewAppActivity, cancelActivityRenewal } from "./browser-auth.js";
|
|
6
6
|
import { resolveLaunchMode, detectLaunchMode, normalizeLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY } from "./index.js";
|
|
7
7
|
import { acceptAppAuthProtocol, refreshAppAuthProtocol } from "./browser-auth.js";
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
export function useAppActivity(options) {
|
|
9
|
+
const { openUrl, appCodePath = "/api/auth/app-code", activeUntil, activityRequired = false, appAuthProtocol, probePath = "/api/auth/identity", native, validateSession } = options ?? {};
|
|
10
10
|
const [needed, setNeeded] = useState(false);
|
|
11
11
|
const [pending, setPending] = useState(false);
|
|
12
12
|
const [error, setError] = useState("");
|
|
@@ -17,6 +17,8 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
|
|
|
17
17
|
void operation.catch(e => { setNeeded(true); setError(String(e.message ?? e)); }).finally(() => setPending(false));
|
|
18
18
|
};
|
|
19
19
|
useEffect(() => {
|
|
20
|
+
if (!openUrl)
|
|
21
|
+
return;
|
|
20
22
|
let cancelled = false;
|
|
21
23
|
let cleanup;
|
|
22
24
|
const controller = new AbortController();
|
|
@@ -31,7 +33,7 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
|
|
|
31
33
|
const mode = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE)) ?? resolveLaunchMode({
|
|
32
34
|
param: new URL(window.location.href).searchParams.get(LAUNCH_MODE_PARAM), stored, heuristic: detectLaunchMode(window),
|
|
33
35
|
}).mode;
|
|
34
|
-
cleanup = configureAppActivity({ openUrl, appAuthProtocol: protocol, probePath, native: native ?? mode === "native", activeUntil, activityRequired, exchangeCode: async (proof) => {
|
|
36
|
+
cleanup = configureAppActivity({ openUrl, appAuthProtocol: protocol, probePath, native: native ?? mode === "native", activeUntil, activityRequired, validateSession, exchangeCode: async (proof) => {
|
|
35
37
|
const response = await fetch(appCodePath, { method: "POST", credentials: "same-origin", redirect: "error",
|
|
36
38
|
headers: { "Content-Type": "application/json" }, body: JSON.stringify(proof) });
|
|
37
39
|
const body = await response.json();
|
|
@@ -59,9 +61,13 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
|
|
|
59
61
|
window.addEventListener(APP_ACTIVITY_RENEWED, renewed);
|
|
60
62
|
document.addEventListener("click", click, true);
|
|
61
63
|
return () => { cancelled = true; controller.abort(); cleanup?.(); window.removeEventListener(APP_SESSION_ENDED, expired); window.removeEventListener(APP_ACTIVITY_RENEWED, renewed); document.removeEventListener("click", click, true); };
|
|
62
|
-
}, [openUrl, appCodePath, appAuthProtocol, probePath, native]);
|
|
64
|
+
}, [openUrl, appCodePath, appAuthProtocol, probePath, native, validateSession]);
|
|
63
65
|
useEffect(() => { updateAppActivity(activeUntil, activityRequired); }, [activeUntil, activityRequired, openUrl, appCodePath]);
|
|
66
|
+
return { needed, pending, error, renew, cancel: () => { cancelActivityRenewal(); setNeeded(false); } };
|
|
67
|
+
}
|
|
68
|
+
export function AppActivityBridge(options) {
|
|
69
|
+
const { needed, pending, error, renew, cancel } = useAppActivity(options);
|
|
64
70
|
if (!needed)
|
|
65
71
|
return null;
|
|
66
|
-
return _jsxs(AuthNotice, { children: [_jsx("span", { children: error || "Renew access through Core to continue. Your work stays on this page." }), _jsx(AuthNoticeButton, { disabled: pending, onClick: renew, children: pending ? "Waiting for Core…" : "Renew access" }), _jsx(AuthNoticeButton, { secondary: true, onClick:
|
|
72
|
+
return _jsxs(AuthNotice, { children: [_jsx("span", { children: error || "Renew access through Core to continue. Your work stays on this page." }), _jsx(AuthNoticeButton, { disabled: pending, onClick: renew, children: pending ? "Waiting for Core…" : "Renew access" }), _jsx(AuthNoticeButton, { secondary: true, onClick: cancel, children: "Later" })] });
|
|
67
73
|
}
|
package/dist/browser-auth.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export declare function refreshAppAuthProtocol(openUrl: string, probePath?: stri
|
|
|
45
45
|
/** Must be called directly from a user gesture. Native hosts intercept before any request. */
|
|
46
46
|
export declare function openAppSignIn(openUrl: string, signal?: AbortSignal, appAuthProtocol?: AppAuthProtocol, native?: boolean, probePath?: string): Promise<AppSignInResult>;
|
|
47
47
|
export declare const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
|
|
48
|
+
/** Validated restoration of the same actor, before the app resumes protected work. */
|
|
49
|
+
export declare const APP_SESSION_RESTORED = "hosty:app-session-restored";
|
|
48
50
|
export type AppActivityConfig = {
|
|
49
51
|
openUrl: string;
|
|
50
52
|
appAuthProtocol: AppAuthProtocol;
|
|
@@ -56,6 +58,8 @@ export type AppActivityConfig = {
|
|
|
56
58
|
accessToken?: string;
|
|
57
59
|
activeUntil?: string | null;
|
|
58
60
|
}>;
|
|
61
|
+
/** The root coordinator verifies actor and setup before any waiting request can retry. */
|
|
62
|
+
validateSession?: () => Promise<boolean>;
|
|
59
63
|
};
|
|
60
64
|
export declare function configureAppActivity(config: AppActivityConfig): () => void;
|
|
61
65
|
export declare function updateAppActivity(activeUntil?: string | null, activityRequired?: boolean): void;
|
package/dist/browser-auth.js
CHANGED
|
@@ -265,6 +265,11 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
265
265
|
const url = new URL(input, window.location.href);
|
|
266
266
|
if (url.origin !== window.location.origin || url.username || url.password)
|
|
267
267
|
throw new Error("App API requests must stay on this app's origin.");
|
|
268
|
+
// Mounted app effects can still run behind the overlay. Do not send their requests with
|
|
269
|
+
// the newly exchanged credential until the coordinator has checked the actor and setup.
|
|
270
|
+
// SDK identity/protocol probes use recover=false so that validation itself can proceed.
|
|
271
|
+
if (recover && renewal && activityConfig?.validateSession && !await waitForActivity(init.signal))
|
|
272
|
+
return Response.json({ code: "reauth_required", message: "Hosty access has not been restored." }, { status: 401 });
|
|
268
273
|
const current = grant?.origin === url.origin ? grant : null;
|
|
269
274
|
const headers = new Headers(init.headers);
|
|
270
275
|
if (current)
|
|
@@ -521,6 +526,8 @@ function openNativeAppSignIn(attempt, signal) {
|
|
|
521
526
|
});
|
|
522
527
|
}
|
|
523
528
|
export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
|
|
529
|
+
/** Validated restoration of the same actor, before the app resumes protected work. */
|
|
530
|
+
export const APP_SESSION_RESTORED = "hosty:app-session-restored";
|
|
524
531
|
let activityConfig = null;
|
|
525
532
|
let renewal = null;
|
|
526
533
|
let renewalController = null;
|
|
@@ -571,6 +578,10 @@ export function renewAppActivity() {
|
|
|
571
578
|
if (result.accessToken)
|
|
572
579
|
rememberAppGrant(result.accessToken);
|
|
573
580
|
config.activeUntil = result.activeUntil;
|
|
581
|
+
if (config.validateSession && !await config.validateSession())
|
|
582
|
+
throw new Error("Access has not been restored. Complete the Hosty setup before continuing.");
|
|
583
|
+
if (controller.signal.aborted || activityConfig !== config)
|
|
584
|
+
throw new Error("Sign-in was cancelled.");
|
|
574
585
|
recoveryPending = false;
|
|
575
586
|
for (const done of [...waiting])
|
|
576
587
|
done(true);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AppSessionStatus, AppSessionFailureStatus, SessionRecoveryParams } from "./session-types.js";
|
|
2
|
+
export type { AppSessionStatus, AppSessionFailureStatus, SessionRecoveryParams } from "./session-types.js";
|
|
1
3
|
/**
|
|
2
4
|
* The postMessage type for an embedded app's authentication-required intent.
|
|
3
5
|
* A frozen protocol constant: it deliberately does not track product branding (precedent:
|
|
@@ -71,22 +73,6 @@ export interface DelegatedTokenGrant {
|
|
|
71
73
|
export declare function createDelegatedTokenRequest(options?: {
|
|
72
74
|
refresh?: boolean;
|
|
73
75
|
}): DelegatedTokenRequest;
|
|
74
|
-
/**
|
|
75
|
-
* Recovery classification of an app session, per the platform identity error contract:
|
|
76
|
-
* - `not-present`: no token at all (recoverable — same handling as `expired`).
|
|
77
|
-
* - `active`: token revalidated OK.
|
|
78
|
-
* - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
|
|
79
|
-
* (standalone).
|
|
80
|
-
* - `forbidden`: Core 403 or a token minted for a different app — terminal; never
|
|
81
|
-
* auto-redirect (it would loop).
|
|
82
|
-
* - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
|
|
83
|
-
* cookie and offer a retry.
|
|
84
|
-
* - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
|
|
85
|
-
* operator problem; signing in cannot fix it, so never offer a login.
|
|
86
|
-
*/
|
|
87
|
-
export type AppSessionStatus = "not-present" | "active" | "expired" | "forbidden" | "unavailable" | "misconfigured";
|
|
88
|
-
/** Failure statuses (everything but `active`). */
|
|
89
|
-
export type AppSessionFailureStatus = Exclude<AppSessionStatus, "active">;
|
|
90
76
|
/**
|
|
91
77
|
* Maps a Core revalidation HTTP status onto the session classification. Classification is
|
|
92
78
|
* by status code only — never by error-code strings — so new Core codes cannot break an
|
|
@@ -99,11 +85,6 @@ export declare function classifyRevalidationHttpStatus(status: number): AppSessi
|
|
|
99
85
|
* prerendered at image build time, where the HOSTY_* environment does not exist yet
|
|
100
86
|
* (docker-host PR #233 / media-server PR #63). Neither value is secret.
|
|
101
87
|
*/
|
|
102
|
-
export interface SessionRecoveryParams {
|
|
103
|
-
appId: string | null;
|
|
104
|
-
corePublicOrigin: string | null;
|
|
105
|
-
appAuthProtocol: 1 | 2 | null;
|
|
106
|
-
}
|
|
107
88
|
export declare function readRecoveryParams(body: unknown): SessionRecoveryParams;
|
|
108
89
|
/** True for hosts that only resolve on the machine itself. */
|
|
109
90
|
export declare function isLoopbackHost(hostname: string): boolean;
|
package/dist/index.js
CHANGED
|
@@ -86,6 +86,12 @@ export function classifyRevalidationHttpStatus(status) {
|
|
|
86
86
|
}
|
|
87
87
|
return "unavailable";
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Recovery parameters an app's force-dynamic identity/session endpoint hands the browser.
|
|
91
|
+
* They ride in a response — never in a server-component prop — because app pages are
|
|
92
|
+
* prerendered at image build time, where the HOSTY_* environment does not exist yet
|
|
93
|
+
* (docker-host PR #233 / media-server PR #63). Neither value is secret.
|
|
94
|
+
*/
|
|
89
95
|
export function readRecoveryParams(body) {
|
|
90
96
|
const recovery = body && typeof body === "object" ? body.recovery : null;
|
|
91
97
|
if (!recovery || typeof recovery !== "object") {
|
package/dist/install-react.d.ts
CHANGED
|
@@ -5,12 +5,18 @@ export declare function useInstallation(client: InstallationClient): {
|
|
|
5
5
|
request: InstallationRequest | null;
|
|
6
6
|
busy: boolean;
|
|
7
7
|
error: string | null;
|
|
8
|
+
uncertainSubmit: boolean;
|
|
8
9
|
};
|
|
9
10
|
export interface InstallDialogProps {
|
|
10
11
|
client: InstallationClient;
|
|
12
|
+
/** Choose the source/channel before opening. Core freezes that selection during preparation. */
|
|
11
13
|
source?: InstallationSource;
|
|
14
|
+
/** Open synchronously in the Install click handler, before mounting or fetching a source. */
|
|
15
|
+
confirmationWindow?: Window | null;
|
|
12
16
|
onClose: () => void;
|
|
17
|
+
/** Return to the caller's source/connection picker after a known pre-confirmation error. */
|
|
18
|
+
onChooseSource?: () => void;
|
|
13
19
|
onInstalled?: (request: InstallationRequest) => void;
|
|
14
20
|
}
|
|
15
|
-
/**
|
|
16
|
-
export declare function InstallDialog(
|
|
21
|
+
/** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
|
|
22
|
+
export declare function InstallDialog(props: InstallDialogProps): import("react").JSX.Element;
|
package/dist/install-react.js
CHANGED
|
@@ -15,29 +15,83 @@ export function useInstallation(client) {
|
|
|
15
15
|
}, [flow, state.request?.id, state.request?.status]);
|
|
16
16
|
return { ...state, flow };
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const popupOwners = new WeakMap();
|
|
19
|
+
/** One Core confirmation, with app-local preparation/progress only. No installation questionnaire. */
|
|
20
|
+
export function InstallDialog(props) {
|
|
21
|
+
// A changed source is a new review. Never apply an earlier channel's confirmation to it.
|
|
22
|
+
return _jsx(InstallationSession, { ...props }, JSON.stringify(props.source ?? null));
|
|
23
|
+
}
|
|
24
|
+
function InstallationSession({ client, source, confirmationWindow, onClose, onChooseSource, onInstalled }) {
|
|
25
|
+
const { request, busy, error, uncertainSubmit, flow } = useInstallation(client);
|
|
21
26
|
const dialog = useRef(null);
|
|
22
27
|
const titleId = useId();
|
|
23
|
-
const [manifestPath, setManifestPath] = useState(
|
|
24
|
-
const [
|
|
25
|
-
const [autostart, setAutostart] = useState(true);
|
|
26
|
-
const [draftPlanId, setDraftPlanId] = useState(null);
|
|
28
|
+
const [manifestPath, setManifestPath] = useState("");
|
|
29
|
+
const [presentationError, setPresentationError] = useState(null);
|
|
27
30
|
const notified = useRef(null);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
31
|
+
const [initialSource] = useState(source);
|
|
32
|
+
const activePopup = useRef(confirmationWindow ?? null);
|
|
33
|
+
const popupOwner = useRef({});
|
|
34
|
+
const popupShown = useRef(false);
|
|
35
|
+
const mountLease = useRef(0);
|
|
36
|
+
const hasSource = Boolean(initialSource?.manifestPath || initialSource?.feedsUrl);
|
|
37
|
+
const frozen = uncertainSubmit || (request !== null && request.status !== "draft");
|
|
38
|
+
const claimPopup = (popup) => {
|
|
39
|
+
activePopup.current = popup;
|
|
40
|
+
popupShown.current = false;
|
|
41
|
+
if (popup)
|
|
42
|
+
popupOwners.set(popup, popupOwner.current);
|
|
43
|
+
};
|
|
44
|
+
const closeUnusedPopup = (popup = activePopup.current) => {
|
|
45
|
+
if (!popup || popupShown.current || popupOwners.get(popup) !== popupOwner.current)
|
|
46
|
+
return;
|
|
47
|
+
popupOwners.delete(popup);
|
|
48
|
+
popup.close();
|
|
49
|
+
};
|
|
50
|
+
const showConfirmation = (popup, submitted) => {
|
|
51
|
+
// A newer source session may now own the caller's preopened window.
|
|
52
|
+
if (popup && popupOwners.get(popup) !== popupOwner.current)
|
|
53
|
+
return;
|
|
54
|
+
if (!submitted || submitted.status !== "pending") {
|
|
55
|
+
closeUnusedPopup(popup);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
showInstallationConfirmation(popup, submitted);
|
|
60
|
+
popupShown.current = Boolean(popup && !popup.closed);
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
closeUnusedPopup(popup);
|
|
64
|
+
setPresentationError(cause instanceof Error ? cause.message : "Core confirmation could not be opened.");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const lease = ++mountLease.current;
|
|
69
|
+
claimPopup(activePopup.current);
|
|
70
|
+
return () => {
|
|
71
|
+
// StrictMode immediately replays setup. Defer cleanup so that replay, or a new source
|
|
72
|
+
// session adopting the same window, can retain the reservation.
|
|
73
|
+
queueMicrotask(() => { if (mountLease.current === lease)
|
|
74
|
+
closeUnusedPopup(); });
|
|
75
|
+
};
|
|
76
|
+
}, [flow]);
|
|
37
77
|
useEffect(() => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
if (!initialSource?.manifestPath && !initialSource?.feedsUrl)
|
|
79
|
+
return;
|
|
80
|
+
let cancelled = false;
|
|
81
|
+
void (async () => {
|
|
82
|
+
await flow.review(initialSource);
|
|
83
|
+
if (cancelled)
|
|
84
|
+
return;
|
|
85
|
+
if (flow.snapshot().request?.status !== "draft") {
|
|
86
|
+
closeUnusedPopup();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const submitted = await flow.submit();
|
|
90
|
+
if (!cancelled)
|
|
91
|
+
showConfirmation(activePopup.current, submitted);
|
|
92
|
+
})();
|
|
93
|
+
return () => { cancelled = true; };
|
|
94
|
+
}, [flow, initialSource]);
|
|
41
95
|
useEffect(() => {
|
|
42
96
|
const element = dialog.current;
|
|
43
97
|
element?.showModal();
|
|
@@ -49,28 +103,55 @@ export function InstallDialog({ client, source, onClose, onInstalled }) {
|
|
|
49
103
|
onInstalled?.(request);
|
|
50
104
|
}
|
|
51
105
|
}, [request, onInstalled]);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
57
|
-
const submit = async () => {
|
|
106
|
+
const install = async () => {
|
|
107
|
+
if (busy || frozen)
|
|
108
|
+
return;
|
|
109
|
+
// The browser gesture is still active here, before any async preparation.
|
|
58
110
|
const popup = openInstallationConfirmation();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
claimPopup(popup);
|
|
112
|
+
setPresentationError(null);
|
|
113
|
+
await flow.review({ manifestPath: manifestPath.trim() });
|
|
114
|
+
if (flow.snapshot().request?.status !== "draft") {
|
|
115
|
+
closeUnusedPopup(popup);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
showConfirmation(popup, await flow.submit());
|
|
66
119
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
120
|
+
const retry = async () => {
|
|
121
|
+
setPresentationError(null);
|
|
122
|
+
if (uncertainSubmit) {
|
|
123
|
+
await flow.refresh();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const popup = openInstallationConfirmation();
|
|
127
|
+
claimPopup(popup);
|
|
128
|
+
// A known draft submit failure retries its existing identity, without resolving the feed again.
|
|
129
|
+
if (!request)
|
|
130
|
+
await flow.review(initialSource ?? { manifestPath: manifestPath.trim() });
|
|
131
|
+
if (flow.snapshot().request?.status !== "draft") {
|
|
132
|
+
closeUnusedPopup(popup);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
showConfirmation(popup, await flow.submit());
|
|
136
|
+
};
|
|
137
|
+
const close = () => {
|
|
138
|
+
closeUnusedPopup();
|
|
139
|
+
onClose();
|
|
140
|
+
};
|
|
141
|
+
const confirmationUrl = (() => {
|
|
142
|
+
if (request?.status !== "pending")
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
const url = new URL(request.approvalUrl);
|
|
146
|
+
return ["http:", "https:"].includes(url.protocol) ? url.href : null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
return _jsxs("dialog", { ref: dialog, className: "hosty-install", "aria-labelledby": titleId, onCancel: event => { event.preventDefault(); close(); }, children: [_jsx("style", { children: styles }), _jsxs("header", { children: [_jsx("h2", { id: titleId, children: "Install app" }), _jsx("button", { type: "button", onClick: close, "aria-label": "Close installation", children: "\u00D7" })] }), !hasSource && !request && _jsxs("form", { onSubmit: event => { event.preventDefault(); void install(); }, children: [_jsxs("label", { children: ["Manifest, app directory, or URL", _jsx("input", { value: manifestPath, onChange: event => setManifestPath(event.target.value), required: true, disabled: busy })] }), _jsx("button", { className: "primary", disabled: busy || !manifestPath.trim(), children: "Install" })] }), busy && _jsx("p", { role: "status", children: "Preparing Core confirmation\u2026" }), (error || presentationError) && _jsxs("div", { role: "alert", children: [_jsx("p", { children: error || presentationError }), (!frozen || uncertainSubmit) && _jsx("button", { type: "button", disabled: busy, onClick: () => void retry(), children: uncertainSubmit ? "Check request status" : "Retry" }), !frozen && onChooseSource && _jsx("button", { type: "button", disabled: busy, onClick: () => { closeUnusedPopup(); onChooseSource(); }, children: "Change source" })] }), uncertainSubmit && _jsx("p", { role: "status", children: "Core may have received this request. Check its status before continuing." }), request && request.status !== "draft" && _jsxs("section", { role: "status", children: [_jsx("p", { children: request.status === "pending" ? "Confirm runtime, automatic startup and permissions in Hosty Core." : request.status === "executing" ? "Core is installing your app…" : request.status === "succeeded" ? "App installed." : request.status === "denied" ? "Installation cancelled." : "Installation failed. Close this dialog to begin a new request." }), confirmationUrl && _jsx("a", { href: confirmationUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core confirmation" }), _jsx("p", { children: _jsx("small", { children: "Closing this dialog does not cancel an operation already confirmed in Core." }) })] })] });
|
|
72
153
|
}
|
|
73
154
|
const styles = `
|
|
74
|
-
.hosty-install{box-sizing:border-box;width:min(
|
|
75
|
-
.hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install
|
|
155
|
+
.hosty-install{box-sizing:border-box;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:24px;border:1px solid var(--border,#aaa);border-radius:12px;background:var(--background,Canvas);color:var(--foreground,CanvasText);font:inherit;overflow:auto;margin:auto}
|
|
156
|
+
.hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install p{margin:12px 0;font-size:.9rem}.hosty-install small{font-size:.8rem;opacity:.75}.hosty-install label{display:flex;flex-direction:column;gap:6px;margin:16px 0;font-size:.9rem}.hosty-install input{box-sizing:border-box;width:100%;border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 10px;background:transparent;color:inherit;font:inherit}.hosty-install button{border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 12px;font:inherit;background:transparent;color:inherit;cursor:pointer}.hosty-install button:disabled{opacity:.5;cursor:default}.hosty-install .primary{background:#2563eb;color:white;border-color:#2563eb}.hosty-install [role=alert]{color:#d33}.hosty-install a{color:#2563eb;text-decoration:underline}.hosty-install :focus-visible{outline:2px solid #60a5fa;outline-offset:3px}
|
|
76
157
|
`;
|
package/dist/install.d.ts
CHANGED
|
@@ -12,6 +12,16 @@ export interface InstallSetting {
|
|
|
12
12
|
label: string;
|
|
13
13
|
}[] | null;
|
|
14
14
|
}
|
|
15
|
+
export interface InstallationConfigurationReadiness {
|
|
16
|
+
required: boolean;
|
|
17
|
+
missingSettings: string[];
|
|
18
|
+
mounts: {
|
|
19
|
+
key: string;
|
|
20
|
+
label?: string | null;
|
|
21
|
+
reason: string;
|
|
22
|
+
}[];
|
|
23
|
+
error?: string | null;
|
|
24
|
+
}
|
|
15
25
|
export interface InstallPlan {
|
|
16
26
|
appId: string;
|
|
17
27
|
displayName: string;
|
|
@@ -37,6 +47,14 @@ export interface InstallPlan {
|
|
|
37
47
|
development?: boolean;
|
|
38
48
|
}[];
|
|
39
49
|
settings: InstallSetting[];
|
|
50
|
+
runtimeChoices?: {
|
|
51
|
+
key: string;
|
|
52
|
+
type: string;
|
|
53
|
+
default: boolean;
|
|
54
|
+
available: boolean;
|
|
55
|
+
error?: string | null;
|
|
56
|
+
}[];
|
|
57
|
+
configurationReadiness?: InstallationConfigurationReadiness | null;
|
|
40
58
|
}
|
|
41
59
|
export interface InstallationSource {
|
|
42
60
|
manifestPath?: string;
|
|
@@ -97,10 +115,22 @@ export interface InstallationRequest {
|
|
|
97
115
|
plan: InstallPlan | null;
|
|
98
116
|
updatePlan?: {
|
|
99
117
|
appId: string;
|
|
100
|
-
displayName
|
|
118
|
+
displayName?: string | null;
|
|
101
119
|
targetVersion: string;
|
|
102
120
|
targetRuntime: string;
|
|
103
121
|
changes: string[];
|
|
122
|
+
previousRequiredCorePermissions?: string[] | null;
|
|
123
|
+
previousOptionalCorePermissions?: string[] | null;
|
|
124
|
+
feedsUrl?: string | null;
|
|
125
|
+
feedId?: string | null;
|
|
126
|
+
targetRuntimeType?: string | null;
|
|
127
|
+
settingChanges?: {
|
|
128
|
+
key: string;
|
|
129
|
+
label?: string | null;
|
|
130
|
+
change: string;
|
|
131
|
+
detail?: string | null;
|
|
132
|
+
}[];
|
|
133
|
+
configurationReadiness?: InstallationConfigurationReadiness | null;
|
|
104
134
|
} | null;
|
|
105
135
|
permissionPlan?: {
|
|
106
136
|
appId: string;
|
|
@@ -128,7 +158,7 @@ export interface InstallationRequest {
|
|
|
128
158
|
}
|
|
129
159
|
export interface InstallationClient {
|
|
130
160
|
prepare(source: InstallationSource): Promise<InstallationRequest>;
|
|
131
|
-
submit(id: string, settings
|
|
161
|
+
submit(id: string, settings?: Record<string, string | null>, autostart?: boolean): Promise<InstallationRequest>;
|
|
132
162
|
status(id: string): Promise<InstallationRequest>;
|
|
133
163
|
}
|
|
134
164
|
export declare class InstallationError extends Error {
|
|
@@ -145,6 +175,8 @@ export interface InstallationState {
|
|
|
145
175
|
request: InstallationRequest | null;
|
|
146
176
|
busy: boolean;
|
|
147
177
|
error: string | null;
|
|
178
|
+
/** A submit may have succeeded; resolve the existing identity before another mutation. */
|
|
179
|
+
uncertainSubmit: boolean;
|
|
148
180
|
}
|
|
149
181
|
/** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
|
|
150
182
|
export declare class InstallationFlow {
|
|
@@ -153,6 +185,8 @@ export declare class InstallationFlow {
|
|
|
153
185
|
private listeners;
|
|
154
186
|
private generation;
|
|
155
187
|
private disposed;
|
|
188
|
+
private submitting;
|
|
189
|
+
private refreshing;
|
|
156
190
|
constructor(client: InstallationClient);
|
|
157
191
|
snapshot: () => InstallationState;
|
|
158
192
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -161,7 +195,7 @@ export declare class InstallationFlow {
|
|
|
161
195
|
cancelPending(): void;
|
|
162
196
|
dispose(): void;
|
|
163
197
|
review(source: InstallationSource): Promise<void>;
|
|
164
|
-
submit(settings
|
|
198
|
+
submit(settings?: Record<string, string | null>, autostart?: boolean): Promise<InstallationRequest | null>;
|
|
165
199
|
refresh(): Promise<void>;
|
|
166
200
|
}
|
|
167
201
|
/** Open synchronously in a click handler, before awaiting plan/submit requests. */
|