@hosty-sdk/app 0.21.2 → 0.22.1
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 +55 -17
- 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/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:
|
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") {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** Mount once around protected content. Presentation and recovery belong to Hosty. */
|
|
3
|
+
export declare function HostyOverlay({ children, probePath, appCodePath }: {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
probePath?: string;
|
|
6
|
+
appCodePath?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useAppIdentity } from "./react.js";
|
|
6
|
+
import { useAppActivity } from "./activity-react.js";
|
|
7
|
+
import { APP_SESSION_ENDED, APP_SESSION_RESTORED, APP_ACTIVITY_RENEWED, acceptAppAuthProtocol } from "./browser-auth.js";
|
|
8
|
+
import { buildCoreOpenUrl, readRecoveryParams } from "./index.js";
|
|
9
|
+
import { permissionReviewUrl, requestPermissionReview } from "./permissions.js";
|
|
10
|
+
function snapshot(body) {
|
|
11
|
+
const value = body;
|
|
12
|
+
if (value?.status !== "active" || typeof value.userId !== "string" || !value.userId)
|
|
13
|
+
return null;
|
|
14
|
+
const setup = value.hosty?.version === 1 && ["ready", "missing", "unsupported", "unavailable", "incompatible"].includes(String(value.hosty.setup))
|
|
15
|
+
? value.hosty.setup : "incompatible";
|
|
16
|
+
const recovery = readRecoveryParams(body);
|
|
17
|
+
const openUrl = recovery.appId ? buildCoreOpenUrl(recovery.corePublicOrigin, recovery.appId, window.location) : null;
|
|
18
|
+
// The activity policy remains authoritative even when renewal discovery fails.
|
|
19
|
+
return { userId: value.userId, setup, notice: value.hosty?.notice,
|
|
20
|
+
activityRequired: value.activityRequired === true,
|
|
21
|
+
activeUntil: typeof value.activeUntil === "string" ? value.activeUntil : null,
|
|
22
|
+
recoveryReady: !!openUrl && !!acceptAppAuthProtocol(new URL(openUrl).origin, recovery.appAuthProtocol) };
|
|
23
|
+
}
|
|
24
|
+
function activityExpired(session) {
|
|
25
|
+
const deadline = Date.parse(session?.activeUntil ?? "");
|
|
26
|
+
return !!session?.activityRequired && (!Number.isFinite(deadline) || deadline <= Date.now());
|
|
27
|
+
}
|
|
28
|
+
function sessionReady(session) {
|
|
29
|
+
return session?.setup === "ready" && (!session.activityRequired || session.recoveryReady) && !activityExpired(session);
|
|
30
|
+
}
|
|
31
|
+
/** Mount once around protected content. Presentation and recovery belong to Hosty. */
|
|
32
|
+
export function HostyOverlay({ children, probePath = "/api/hosty/session", appCodePath = "/api/auth/app-code" }) {
|
|
33
|
+
const actor = useRef(null);
|
|
34
|
+
const changingActor = useRef(false);
|
|
35
|
+
const [session, setSession] = useState(null);
|
|
36
|
+
const [reviewing, setReviewing] = useState(false);
|
|
37
|
+
const renewedAwaitingReadiness = useRef(false);
|
|
38
|
+
const accept = (body) => {
|
|
39
|
+
const next = snapshot(body);
|
|
40
|
+
if (next && actor.current && actor.current !== next.userId) {
|
|
41
|
+
changingActor.current = true;
|
|
42
|
+
window.location.reload();
|
|
43
|
+
}
|
|
44
|
+
if (next && !actor.current)
|
|
45
|
+
actor.current = next.userId;
|
|
46
|
+
setSession(next);
|
|
47
|
+
};
|
|
48
|
+
const identity = useAppIdentity({ probePath, appCodePath, onSession: accept });
|
|
49
|
+
const refreshRef = useRef(identity.refresh);
|
|
50
|
+
refreshRef.current = async () => {
|
|
51
|
+
const body = await identity.refresh();
|
|
52
|
+
const next = snapshot(body);
|
|
53
|
+
if (renewedAwaitingReadiness.current && next && sessionReady(next) && next.userId === actor.current && !changingActor.current) {
|
|
54
|
+
renewedAwaitingReadiness.current = false;
|
|
55
|
+
window.dispatchEvent(new CustomEvent(APP_SESSION_RESTORED, { detail: { userId: next.userId } }));
|
|
56
|
+
window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
|
|
57
|
+
}
|
|
58
|
+
return body;
|
|
59
|
+
};
|
|
60
|
+
const validate = useRef(async () => {
|
|
61
|
+
const next = snapshot(await refreshRef.current());
|
|
62
|
+
if (!next || changingActor.current || next.userId !== actor.current)
|
|
63
|
+
return false;
|
|
64
|
+
renewedAwaitingReadiness.current = !sessionReady(next);
|
|
65
|
+
if (renewedAwaitingReadiness.current)
|
|
66
|
+
return false;
|
|
67
|
+
window.dispatchEvent(new CustomEvent(APP_SESSION_RESTORED, { detail: { userId: next.userId } }));
|
|
68
|
+
return true;
|
|
69
|
+
}).current;
|
|
70
|
+
const activity = useAppActivity(identity.activity ? { ...identity.activity, probePath, appCodePath, validateSession: validate } : null);
|
|
71
|
+
const expired = activityExpired(session);
|
|
72
|
+
const recoveryUnavailable = session?.activityRequired && !session.recoveryReady;
|
|
73
|
+
const ready = identity.ui.kind === "active" && sessionReady(session) && !activity.needed && !activity.pending && !changingActor.current;
|
|
74
|
+
const mounted = useRef(false);
|
|
75
|
+
if (ready)
|
|
76
|
+
mounted.current = true;
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (session?.setup === "ready")
|
|
79
|
+
setReviewing(false);
|
|
80
|
+
}, [session?.setup]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
// No keepalive polling. Focus and a pending Core review are the only readiness refreshes.
|
|
83
|
+
let loading = false;
|
|
84
|
+
const refresh = () => {
|
|
85
|
+
if (loading || !actor.current || activity.pending || (activity.needed && !renewedAwaitingReadiness.current))
|
|
86
|
+
return;
|
|
87
|
+
loading = true;
|
|
88
|
+
void refreshRef.current().finally(() => { loading = false; });
|
|
89
|
+
};
|
|
90
|
+
window.addEventListener("focus", refresh);
|
|
91
|
+
const timer = reviewing ? window.setInterval(refresh, 2000) : undefined;
|
|
92
|
+
return () => { window.removeEventListener("focus", refresh); window.clearInterval(timer); };
|
|
93
|
+
}, [reviewing, activity.pending, activity.needed]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const metadata = session;
|
|
96
|
+
if (!metadata?.activityRequired || !metadata.activeUntil)
|
|
97
|
+
return;
|
|
98
|
+
const remaining = Date.parse(metadata.activeUntil) - Date.now();
|
|
99
|
+
if (!Number.isFinite(remaining))
|
|
100
|
+
return;
|
|
101
|
+
// Real activity windows are short. Do not expire a malformed far-future value early.
|
|
102
|
+
if (remaining > 2147483647)
|
|
103
|
+
return;
|
|
104
|
+
const timer = window.setTimeout(() => window.dispatchEvent(new Event(APP_SESSION_ENDED)), Math.max(0, remaining));
|
|
105
|
+
return () => window.clearTimeout(timer);
|
|
106
|
+
}, [session?.activeUntil, session?.activityRequired]);
|
|
107
|
+
let title = "Connecting to Hosty";
|
|
108
|
+
let message = "Checking your access…";
|
|
109
|
+
let action;
|
|
110
|
+
let actionLabel = "Retry";
|
|
111
|
+
let reviewUrl;
|
|
112
|
+
const pending = identity.ui.kind === "recovering" || activity.pending || changingActor.current;
|
|
113
|
+
if (identity.ui.kind === "signin") {
|
|
114
|
+
title = "Sign in to continue";
|
|
115
|
+
message = identity.ui.error ?? "Use your Hosty account to open this app.";
|
|
116
|
+
action = identity.hasBeenActive && identity.activity ? activity.renew : identity.ui.signIn;
|
|
117
|
+
actionLabel = "Sign in via Hosty";
|
|
118
|
+
if (!action)
|
|
119
|
+
message = "Open this app from Hosty, or ask the administrator to check its public address.";
|
|
120
|
+
}
|
|
121
|
+
else if (identity.ui.kind === "denied") {
|
|
122
|
+
title = "Access unavailable";
|
|
123
|
+
message = "Your account does not have access to this app.";
|
|
124
|
+
}
|
|
125
|
+
else if (identity.ui.kind === "misconfigured") {
|
|
126
|
+
title = "App setup required";
|
|
127
|
+
message = "This app needs configuration. Contact your administrator.";
|
|
128
|
+
}
|
|
129
|
+
else if (identity.ui.kind === "unavailable") {
|
|
130
|
+
title = "Cannot reach Hosty";
|
|
131
|
+
message = "Check the connection and try again.";
|
|
132
|
+
action = () => { void refreshRef.current(); };
|
|
133
|
+
}
|
|
134
|
+
else if (identity.ui.kind === "active" && session?.setup !== "ready") {
|
|
135
|
+
if (!session || session.setup === "incompatible" || session.setup === "unsupported") {
|
|
136
|
+
title = "Hosty update required";
|
|
137
|
+
message = "This app and Core need compatible versions. Contact your administrator.";
|
|
138
|
+
}
|
|
139
|
+
else if (session.setup === "unavailable") {
|
|
140
|
+
title = "Cannot check app setup";
|
|
141
|
+
message = "Hosty could not check required permissions. Try again.";
|
|
142
|
+
action = () => { void refreshRef.current(); };
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
title = "App setup required";
|
|
146
|
+
message = "This app needs configuration by an administrator.";
|
|
147
|
+
const notice = session.notice;
|
|
148
|
+
if (notice?.hostRole === "host.admin" && notice.corePublicOrigin && notice.permissions?.reviewAvailable !== false) {
|
|
149
|
+
message = reviewing ? "Review the required permissions in Core, then return here." : "Review this app’s required permissions in Hosty Core.";
|
|
150
|
+
actionLabel = "Review permissions";
|
|
151
|
+
action = () => { requestPermissionReview(notice); setReviewing(true); };
|
|
152
|
+
if (reviewing)
|
|
153
|
+
reviewUrl = permissionReviewUrl(notice.corePublicOrigin, notice.appId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (identity.ui.kind === "active" && recoveryUnavailable) {
|
|
158
|
+
title = "Cannot verify access renewal";
|
|
159
|
+
message = "Hosty could not verify how to renew this app’s access. Try again.";
|
|
160
|
+
action = () => { void refreshRef.current(); };
|
|
161
|
+
}
|
|
162
|
+
else if (expired || activity.needed || activity.pending) {
|
|
163
|
+
title = activity.pending ? "Connecting to Hosty" : "Renew access to continue";
|
|
164
|
+
message = activity.error || (activity.pending ? "Complete the confirmation in Core." : "Confirm your access through Hosty Core.");
|
|
165
|
+
action = activity.renew;
|
|
166
|
+
actionLabel = "Renew access";
|
|
167
|
+
}
|
|
168
|
+
return _jsxs(_Fragment, { children: [_jsx("div", { "data-hosty-content": true, hidden: !ready, inert: !ready, style: ready ? { display: "contents" } : { display: "none" }, children: mounted.current ? children : null }), !ready && _jsx(OverlaySurface, { pending: pending, title: title, message: message, action: action, actionLabel: actionLabel, reviewUrl: reviewUrl })] });
|
|
169
|
+
}
|
|
170
|
+
function OverlaySurface({ pending, title, message, action, actionLabel, reviewUrl }) {
|
|
171
|
+
const [portal, setPortal] = useState(null);
|
|
172
|
+
const [showProgress, setShowProgress] = useState(false);
|
|
173
|
+
const dialog = useRef(null);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!pending) {
|
|
176
|
+
setShowProgress(false);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const timer = window.setTimeout(() => setShowProgress(true), 180);
|
|
180
|
+
return () => window.clearTimeout(timer);
|
|
181
|
+
}, [pending]);
|
|
182
|
+
useLayoutEffect(() => {
|
|
183
|
+
const container = document.createElement("div");
|
|
184
|
+
container.dataset.hostyOverlayPortal = "";
|
|
185
|
+
const previousFocus = document.activeElement;
|
|
186
|
+
const original = new Map();
|
|
187
|
+
document.body.append(container);
|
|
188
|
+
document.documentElement.setAttribute("data-hosty-overlay-blocked", "");
|
|
189
|
+
const block = () => {
|
|
190
|
+
for (const element of Array.from(document.body.children)) {
|
|
191
|
+
if (!(element instanceof HTMLElement) || element === container || original.has(element))
|
|
192
|
+
continue;
|
|
193
|
+
original.set(element, element.inert);
|
|
194
|
+
element.inert = true;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
block();
|
|
198
|
+
const observer = new MutationObserver(block);
|
|
199
|
+
observer.observe(document.body, { childList: true });
|
|
200
|
+
setPortal(container);
|
|
201
|
+
return () => {
|
|
202
|
+
observer.disconnect();
|
|
203
|
+
original.forEach((inert, element) => { element.inert = inert; });
|
|
204
|
+
document.documentElement.removeAttribute("data-hosty-overlay-blocked");
|
|
205
|
+
container.remove();
|
|
206
|
+
if (previousFocus instanceof HTMLElement && previousFocus.isConnected)
|
|
207
|
+
previousFocus.focus();
|
|
208
|
+
};
|
|
209
|
+
}, []);
|
|
210
|
+
useLayoutEffect(() => {
|
|
211
|
+
const element = dialog.current;
|
|
212
|
+
// Replayed effects can still reference the dialog in the removed portal until state catches up.
|
|
213
|
+
if (!element?.isConnected)
|
|
214
|
+
return;
|
|
215
|
+
if (typeof element.showModal === "function")
|
|
216
|
+
element.showModal();
|
|
217
|
+
else
|
|
218
|
+
element.setAttribute("open", "");
|
|
219
|
+
element.focus();
|
|
220
|
+
return () => { if (element.open && typeof element.close === "function")
|
|
221
|
+
element.close(); };
|
|
222
|
+
}, [portal]);
|
|
223
|
+
const content = _jsxs(_Fragment, { children: [_jsx("style", { children: styles }), _jsx("dialog", { ref: dialog, className: "hosty-overlay-dialog", "aria-label": "Hosty access", tabIndex: -1, onCancel: event => event.preventDefault(), "data-hosty-activity-control": true, children: _jsx("section", { className: "hosty-overlay-card", "aria-live": "polite", "aria-busy": pending, children: pending ? _jsx("div", { className: "hosty-overlay-loading", role: "status", "aria-label": title, children: showProgress && _jsxs(_Fragment, { children: [_jsx("span", { className: "hosty-overlay-spinner" }), _jsx("p", { children: message })] }) }) : _jsxs(_Fragment, { children: [_jsxs("span", { className: "hosty-overlay-brand", children: [_jsx("span", { className: "hosty-overlay-brand-mark", children: _jsxs("svg", { viewBox: "0 0 100 100", "aria-hidden": "true", focusable: "false", fill: "none", stroke: "currentColor", strokeWidth: 6, strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M25 36 V64 M75 36 V64 M25 50 H39 M61 50 H75" }), _jsx("rect", { x: "17", y: "17", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "67", y: "17", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "17", y: "67", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "67", y: "67", width: "16", height: "16", rx: "4.5" }), _jsx("rect", { x: "42", y: "42", width: "16", height: "16", rx: "4.5" })] }) }), _jsx("span", { children: "Hosty" })] }), _jsx("h1", { children: title }), _jsx("p", { children: message }), action && _jsx("button", { type: "button", onClick: action, children: actionLabel }), reviewUrl && _jsx("a", { href: reviewUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core permissions" })] }) }) })] });
|
|
224
|
+
return portal ? createPortal(content, portal) : _jsx("div", { className: "hosty-overlay-boot", role: "status", "aria-label": "Connecting to Hosty", children: _jsx("style", { children: styles }) });
|
|
225
|
+
}
|
|
226
|
+
const styles = `
|
|
227
|
+
.hosty-overlay-boot,.hosty-overlay-dialog{--hosty-bg:#f8f9fb;--hosty-fg:#20242c;--hosty-muted:#606875;--hosty-border:#e0e4eb;color-scheme:light}
|
|
228
|
+
html.dark .hosty-overlay-dialog,html[data-hosty-theme=dark] .hosty-overlay-dialog,html.dark .hosty-overlay-boot{--hosty-bg:#14171c;--hosty-fg:#f0f2f5;--hosty-muted:#a2aab7;--hosty-border:#323843;color-scheme:dark}
|
|
229
|
+
.hosty-overlay-boot{position:fixed;inset:0;background:var(--hosty-bg);z-index:2147483647}
|
|
230
|
+
html[data-hosty-overlay-blocked] body>:not([data-hosty-overlay-portal]){visibility:hidden!important}
|
|
231
|
+
html[data-hosty-overlay-blocked],html[data-hosty-overlay-blocked] body{background:#f8f9fb!important}
|
|
232
|
+
html.dark[data-hosty-overlay-blocked],html.dark[data-hosty-overlay-blocked] body{background:#14171c!important}
|
|
233
|
+
html[data-hosty-launch=embedded][data-hosty-overlay-blocked],html[data-hosty-launch=embedded][data-hosty-overlay-blocked] body{background:transparent!important}
|
|
234
|
+
.hosty-overlay-dialog{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;box-sizing:border-box;margin:0;padding:24px;border:0;background:var(--hosty-bg);color:var(--hosty-fg);font:400 14px/1.55 system-ui,sans-serif;outline:0}
|
|
235
|
+
.hosty-overlay-dialog[open]{display:grid;place-items:center}
|
|
236
|
+
.hosty-overlay-dialog::backdrop{background:transparent}
|
|
237
|
+
html[data-hosty-launch=embedded] .hosty-overlay-dialog,html[data-hosty-launch=embedded] .hosty-overlay-boot{background:transparent}
|
|
238
|
+
.hosty-overlay-card{width:min(100%,360px);text-align:center;animation:hosty-overlay-enter 160ms ease-out}
|
|
239
|
+
.hosty-overlay-brand{display:flex;align-items:center;justify-content:center;gap:8px;color:var(--hosty-fg);font-size:14px;font-weight:600;text-transform:uppercase;margin-bottom:22px}
|
|
240
|
+
.hosty-overlay-brand-mark{display:flex;align-items:center;justify-content:center;width:20px;height:20px;flex-shrink:0}
|
|
241
|
+
.hosty-overlay-brand-mark svg{width:24px;height:24px;flex-shrink:0}
|
|
242
|
+
.hosty-overlay-card h1{font:600 22px/1.3 system-ui,sans-serif;letter-spacing:-.025em;margin:0 0 12px;color:var(--hosty-fg)}
|
|
243
|
+
.hosty-overlay-card p{margin:0 0 24px;color:var(--hosty-muted)}
|
|
244
|
+
.hosty-overlay-card button{appearance:none;border:1px solid var(--hosty-border);border-radius:9px;background:var(--hosty-fg);color:var(--hosty-bg);padding:10px 18px;font:600 14px/1.4 system-ui,sans-serif;cursor:pointer}
|
|
245
|
+
.hosty-overlay-card button:focus-visible,.hosty-overlay-card a:focus-visible{outline:2px solid var(--hosty-muted);outline-offset:4px}
|
|
246
|
+
.hosty-overlay-card a{display:block;color:var(--hosty-muted);font-size:13px;margin-top:18px;text-decoration:underline}
|
|
247
|
+
.hosty-overlay-loading{min-height:100px;color:var(--hosty-muted)}
|
|
248
|
+
.hosty-overlay-spinner{display:inline-block;width:20px;height:20px;margin:0 0 16px;border:2px solid var(--hosty-border);border-top-color:var(--hosty-muted);border-radius:50%;animation:hosty-overlay-spin .8s linear infinite}
|
|
249
|
+
@keyframes hosty-overlay-spin{to{transform:rotate(360deg)}}
|
|
250
|
+
@keyframes hosty-overlay-enter{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}
|
|
251
|
+
@media(prefers-reduced-motion:reduce){.hosty-overlay-card,.hosty-overlay-spinner{animation:none}}
|
|
252
|
+
`;
|
package/dist/react.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AppAuthProtocol } from "./app-code.js";
|
|
1
2
|
import type { ReactNode } from "react";
|
|
2
3
|
import { type AppLaunchMode, type AppSessionStatus } from "./index.js";
|
|
3
4
|
import { type HostyResolvedTheme, type HostyThemePreference } from "./theme.js";
|
|
@@ -62,7 +63,19 @@ export interface AppIdentityBridgeProps {
|
|
|
62
63
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
63
64
|
* — never a login UI while recovery is still running.
|
|
64
65
|
*/
|
|
65
|
-
export declare function
|
|
66
|
+
export declare function useAppIdentity({ probePath, appCodePath, onSession, }?: Pick<AppIdentityBridgeProps, "probePath" | "appCodePath" | "onSession">): {
|
|
67
|
+
ui: AppIdentityBridgeState;
|
|
68
|
+
session: unknown;
|
|
69
|
+
activity: {
|
|
70
|
+
openUrl: string;
|
|
71
|
+
appAuthProtocol: AppAuthProtocol;
|
|
72
|
+
activeUntil?: string | null;
|
|
73
|
+
activityRequired?: boolean;
|
|
74
|
+
} | null;
|
|
75
|
+
hasBeenActive: boolean;
|
|
76
|
+
refresh: () => Promise<unknown>;
|
|
77
|
+
};
|
|
78
|
+
export declare function AppIdentityBridge({ renderState, children, ...options }?: AppIdentityBridgeProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
66
79
|
export interface HostThemeBridgeProps {
|
|
67
80
|
/**
|
|
68
81
|
* Whether the operating system decides the theme while no shell has spoken — `true` unless the
|
|
@@ -93,3 +106,4 @@ export interface HostThemeBridgeProps {
|
|
|
93
106
|
export declare function HostThemeBridge({ followSystem, onTheme }?: HostThemeBridgeProps): null;
|
|
94
107
|
export { MissingPermissionsNotice } from "./permissions-react.js";
|
|
95
108
|
export { AppActivityBridge } from "./activity-react.js";
|
|
109
|
+
export { HostyOverlay } from "./overlay-react.js";
|
package/dist/react.js
CHANGED
|
@@ -187,8 +187,10 @@ export function useLaunchMode() {
|
|
|
187
187
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
188
188
|
* — never a login UI while recovery is still running.
|
|
189
189
|
*/
|
|
190
|
-
export function
|
|
190
|
+
export function useAppIdentity({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", onSession, } = {}) {
|
|
191
191
|
const [ui, setUi] = useState({ kind: "recovering" });
|
|
192
|
+
const [session, setSession] = useState(null);
|
|
193
|
+
const refreshRef = useRef(async () => null);
|
|
192
194
|
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
193
195
|
// keep its exchange alive and let the replacement effect await the same response.
|
|
194
196
|
const wasActive = useRef(false);
|
|
@@ -203,10 +205,12 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
203
205
|
useEffect(() => { onSessionRef.current = onSession; }, [onSession]);
|
|
204
206
|
useEffect(() => {
|
|
205
207
|
let cancelled = false;
|
|
208
|
+
let probeSequence = 0;
|
|
206
209
|
const controller = new AbortController();
|
|
207
210
|
if (initialGrantPresent.current === null)
|
|
208
211
|
initialGrantPresent.current = restoreAppGrant();
|
|
209
212
|
async function probeAndRecover() {
|
|
213
|
+
const sequence = ++probeSequence;
|
|
210
214
|
// Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
|
|
211
215
|
// available in every browser (a synchronous throw here would stop the bridge from
|
|
212
216
|
// ever rendering). Aborts on effect teardown or when the probe times out.
|
|
@@ -249,13 +253,12 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
249
253
|
window.clearTimeout(probeTimeout);
|
|
250
254
|
controller.signal.removeEventListener("abort", abortProbe);
|
|
251
255
|
}
|
|
252
|
-
if (cancelled) {
|
|
253
|
-
return;
|
|
256
|
+
if (cancelled || sequence !== probeSequence) {
|
|
257
|
+
return null;
|
|
254
258
|
}
|
|
255
259
|
if (revision !== appGrantRevision()) {
|
|
256
260
|
initialGrantPresent.current ||= restoreAppGrant();
|
|
257
|
-
|
|
258
|
-
return;
|
|
261
|
+
return probeAndRecover();
|
|
259
262
|
}
|
|
260
263
|
forgetRejectedAppGrant(rejectionCode, revision);
|
|
261
264
|
if (openUrl && protocol)
|
|
@@ -295,16 +298,21 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
295
298
|
});
|
|
296
299
|
switch (action.kind) {
|
|
297
300
|
case "none":
|
|
301
|
+
setSession(sessionBody);
|
|
298
302
|
onSessionRef.current?.(sessionBody);
|
|
299
303
|
appSessionActive();
|
|
300
304
|
wasActive.current = true;
|
|
301
305
|
setUi({ kind: "active" });
|
|
302
|
-
return;
|
|
306
|
+
return sessionBody;
|
|
303
307
|
case "post-auth-required": {
|
|
304
308
|
showSignIn();
|
|
305
309
|
return;
|
|
306
310
|
}
|
|
307
311
|
case "redirect":
|
|
312
|
+
if (wasActive.current) {
|
|
313
|
+
showSignIn();
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
308
316
|
if (appId && protocol && beginNavigationSignIn(action.openUrl, appId, protocol, mode === "native" ? "native" : "standalone", shouldUpgrade))
|
|
309
317
|
return;
|
|
310
318
|
showSignIn("Allow app storage, or sign in with a popup.");
|
|
@@ -329,7 +337,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
329
337
|
body: JSON.stringify(proof),
|
|
330
338
|
}, false);
|
|
331
339
|
const body = await response.json();
|
|
332
|
-
if (!response.ok
|
|
340
|
+
if (!response.ok)
|
|
333
341
|
throw new Error("Hosty could not establish this app session.");
|
|
334
342
|
if (cancelled)
|
|
335
343
|
return;
|
|
@@ -337,7 +345,8 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
337
345
|
await probeAndRecover();
|
|
338
346
|
return;
|
|
339
347
|
}
|
|
340
|
-
|
|
348
|
+
if (typeof body.accessToken === "string" && body.accessToken)
|
|
349
|
+
rememberAppGrant(body.accessToken);
|
|
341
350
|
silentError.current = null;
|
|
342
351
|
writeGuard(false);
|
|
343
352
|
await probeAndRecover();
|
|
@@ -346,6 +355,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
346
355
|
} : undefined });
|
|
347
356
|
}
|
|
348
357
|
}
|
|
358
|
+
refreshRef.current = probeAndRecover;
|
|
349
359
|
const recover = () => { if (!cancelled && !wasActive.current) {
|
|
350
360
|
setUi({ kind: "recovering" });
|
|
351
361
|
void probeAndRecover();
|
|
@@ -420,11 +430,17 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
420
430
|
}
|
|
421
431
|
return () => {
|
|
422
432
|
cancelled = true;
|
|
433
|
+
refreshRef.current = async () => null;
|
|
423
434
|
controller.abort();
|
|
424
435
|
window.removeEventListener(APP_SESSION_ENDED, recover);
|
|
425
436
|
};
|
|
426
437
|
}, [probePath, appCodePath]);
|
|
427
|
-
|
|
438
|
+
return { ui, session, activity, hasBeenActive: wasActive.current, refresh: () => refreshRef.current() };
|
|
439
|
+
}
|
|
440
|
+
export function AppIdentityBridge({ renderState, children, ...options } = {}) {
|
|
441
|
+
const { ui, activity, hasBeenActive } = useAppIdentity(options);
|
|
442
|
+
const { appCodePath = "/api/auth/app-code", probePath = "/api/auth/identity" } = options;
|
|
443
|
+
if (hasBeenActive)
|
|
428
444
|
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath, probePath: probePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
429
445
|
if (renderState)
|
|
430
446
|
return renderState(ui);
|
|
@@ -534,3 +550,4 @@ export function HostThemeBridge({ followSystem = true, onTheme } = {}) {
|
|
|
534
550
|
}
|
|
535
551
|
export { MissingPermissionsNotice } from "./permissions-react.js";
|
|
536
552
|
export { AppActivityBridge } from "./activity-react.js";
|
|
553
|
+
export { HostyOverlay } from "./overlay-react.js";
|
package/dist/server.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export type AppSessionResolution = {
|
|
|
43
43
|
error?: ResolutionError;
|
|
44
44
|
};
|
|
45
45
|
export declare function getAppId(config: HostyAppConfig): string;
|
|
46
|
+
/** Standard root-overlay identity and required-setup endpoint, with app-owned access policy. */
|
|
47
|
+
export declare function createHostySessionRouteHandler(config: HostyAppConfig, options?: {
|
|
48
|
+
administratorOnly?: boolean;
|
|
49
|
+
}): (request: Request) => Promise<Response>;
|
|
46
50
|
export declare function getCoreOrigin(): string | null;
|
|
47
51
|
/** Browser-reachable Core origin. Core always injects it (falls back to its loopback
|
|
48
52
|
* listen URL on localhost-only installs), so a missing value is a broken environment. */
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Server slice: Next route-handler factories and Core revalidation. Never reaches a client
|
|
2
2
|
// bundle — the service token lives here.
|
|
3
3
|
import "server-only";
|
|
4
|
+
import { createHostySessionResponse } from "./session-server.js";
|
|
4
5
|
import { resolveAppAuthProtocol, appAuthProtocolMinimum, isValidCodeVerifier } from "./app-code.js";
|
|
5
6
|
export { isValidCodeVerifier } from "./app-code.js";
|
|
6
7
|
import { classifyRevalidationHttpStatus, } from "./index.js";
|
|
@@ -18,6 +19,15 @@ export const HOSTY_APP_IDENTITY_HEADER = "x-docker-host-identity";
|
|
|
18
19
|
export function getAppId(config) {
|
|
19
20
|
return process.env.HOSTY_APP_ID?.trim() || config.appIdFallback;
|
|
20
21
|
}
|
|
22
|
+
/** Standard root-overlay identity and required-setup endpoint, with app-owned access policy. */
|
|
23
|
+
export function createHostySessionRouteHandler(config, options = {}) {
|
|
24
|
+
return async (request) => {
|
|
25
|
+
const [session, recovery] = await Promise.all([
|
|
26
|
+
resolveAppSession(readAppIdentityToken(request.headers, config), config), getRecoveryParams(config),
|
|
27
|
+
]);
|
|
28
|
+
return createHostySessionResponse(session, recovery, { ...options, signal: request.signal });
|
|
29
|
+
};
|
|
30
|
+
}
|
|
21
31
|
export function getCoreOrigin() {
|
|
22
32
|
return process.env.HOSTY_CORE_ORIGIN?.trim() || null;
|
|
23
33
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ProviderServerOptions } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
import type { AppSessionStatus, SessionRecoveryParams } from "./session-types.js";
|
|
3
|
+
/** Framework-neutral server adapter. Call with an already authenticated app session, never request data. */
|
|
4
|
+
export type HostySessionInput = {
|
|
5
|
+
status: AppSessionStatus;
|
|
6
|
+
identity?: {
|
|
7
|
+
userId: string;
|
|
8
|
+
hostRole: string | null;
|
|
9
|
+
activeUntil?: string | null;
|
|
10
|
+
activityRequired?: boolean;
|
|
11
|
+
};
|
|
12
|
+
error?: {
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
status: number | null;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export declare function createHostySessionResponse(session: HostySessionInput, recovery: SessionRecoveryParams, options?: ProviderServerOptions & {
|
|
19
|
+
administratorOnly?: boolean;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}): Promise<Response>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ProviderClient } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
import { ProviderError } from "@hosty-sdk/app/providers";
|
|
3
|
+
export async function createHostySessionResponse(session, recovery, options = {}) {
|
|
4
|
+
const headers = { "Cache-Control": "no-store" };
|
|
5
|
+
if (session.status !== "active" || !session.identity)
|
|
6
|
+
return Response.json({ status: session.status, error: session.error, recovery }, { headers });
|
|
7
|
+
const identity = session.identity;
|
|
8
|
+
if (options.administratorOnly && identity.hostRole !== "host.admin")
|
|
9
|
+
return Response.json({ status: "forbidden", recovery }, { headers });
|
|
10
|
+
let setup = "unavailable";
|
|
11
|
+
let notice;
|
|
12
|
+
try {
|
|
13
|
+
const permissions = await new ProviderClient({ ...options, appId: recovery.appId ?? undefined }).permissions(options.signal);
|
|
14
|
+
if (![permissions.required, permissions.optional, permissions.granted, permissions.unsupportedRequired ?? []].every(list => Array.isArray(list) && list.every(name => typeof name === "string")))
|
|
15
|
+
throw new Error("Invalid permission state.");
|
|
16
|
+
setup = permissions.unsupportedRequired?.length ? "unsupported"
|
|
17
|
+
: permissions.required.some(name => !permissions.granted.includes(name)) ? "missing" : "ready";
|
|
18
|
+
if (identity.hostRole === "host.admin")
|
|
19
|
+
notice = { hostRole: identity.hostRole, appId: recovery.appId ?? "",
|
|
20
|
+
corePublicOrigin: recovery.corePublicOrigin, permissions };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof ProviderError && error.status === 404)
|
|
24
|
+
setup = "incompatible";
|
|
25
|
+
}
|
|
26
|
+
return Response.json({ status: "active", ...identity, recovery, hosty: { version: 1, setup, notice } }, { headers });
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery classification of an app session, per the platform identity error contract:
|
|
3
|
+
* - `not-present`: no token at all (recoverable — same handling as `expired`).
|
|
4
|
+
* - `active`: token revalidated OK.
|
|
5
|
+
* - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
|
|
6
|
+
* (standalone).
|
|
7
|
+
* - `forbidden`: Core 403 or a token minted for a different app — terminal; never
|
|
8
|
+
* auto-redirect (it would loop).
|
|
9
|
+
* - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
|
|
10
|
+
* cookie and offer a retry.
|
|
11
|
+
* - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
|
|
12
|
+
* operator problem; signing in cannot fix it, so never offer a login.
|
|
13
|
+
*/
|
|
14
|
+
export type AppSessionStatus = "not-present" | "active" | "expired" | "forbidden" | "unavailable" | "misconfigured";
|
|
15
|
+
/** Failure statuses (everything but `active`). */
|
|
16
|
+
export type AppSessionFailureStatus = Exclude<AppSessionStatus, "active">;
|
|
17
|
+
export interface SessionRecoveryParams {
|
|
18
|
+
appId: string | null;
|
|
19
|
+
corePublicOrigin: string | null;
|
|
20
|
+
appAuthProtocol: 1 | 2 | null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Shared session types with no DOM or framework dependency.
|
|
2
|
+
/**
|
|
3
|
+
* Recovery classification of an app session, per the platform identity error contract:
|
|
4
|
+
* - `not-present`: no token at all (recoverable — same handling as `expired`).
|
|
5
|
+
* - `active`: token revalidated OK.
|
|
6
|
+
* - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
|
|
7
|
+
* (standalone).
|
|
8
|
+
* - `forbidden`: Core 403 or a token minted for a different app — terminal; never
|
|
9
|
+
* auto-redirect (it would loop).
|
|
10
|
+
* - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
|
|
11
|
+
* cookie and offer a retry.
|
|
12
|
+
* - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
|
|
13
|
+
* operator problem; signing in cannot fix it, so never offer a login.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
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
|
+
"./session/server": {
|
|
17
|
+
"types": "./dist/session-server.d.ts",
|
|
18
|
+
"default": "./dist/session-server.js"
|
|
19
|
+
},
|
|
16
20
|
"./browser-auth": {
|
|
17
21
|
"types": "./dist/browser-auth.d.ts",
|
|
18
22
|
"default": "./dist/browser-auth.js"
|
|
@@ -92,7 +96,8 @@
|
|
|
92
96
|
},
|
|
93
97
|
"peerDependencies": {
|
|
94
98
|
"next": "^16.3.8",
|
|
95
|
-
"react": "^19.2.8"
|
|
99
|
+
"react": "^19.2.8",
|
|
100
|
+
"react-dom": "^19.2.8"
|
|
96
101
|
},
|
|
97
102
|
"peerDependenciesMeta": {
|
|
98
103
|
"next": {
|
|
@@ -100,6 +105,9 @@
|
|
|
100
105
|
},
|
|
101
106
|
"react": {
|
|
102
107
|
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"react-dom": {
|
|
110
|
+
"optional": true
|
|
103
111
|
}
|
|
104
112
|
},
|
|
105
113
|
"devDependencies": {
|