@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/dist/install.js
CHANGED
|
@@ -33,10 +33,12 @@ export function createInstallationClient(options = {}) {
|
|
|
33
33
|
/** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
|
|
34
34
|
export class InstallationFlow {
|
|
35
35
|
client;
|
|
36
|
-
state = { request: null, busy: false, error: null };
|
|
36
|
+
state = { request: null, busy: false, error: null, uncertainSubmit: false };
|
|
37
37
|
listeners = new Set();
|
|
38
38
|
generation = 0;
|
|
39
39
|
disposed = false;
|
|
40
|
+
submitting = false;
|
|
41
|
+
refreshing = false;
|
|
40
42
|
constructor(client) {
|
|
41
43
|
this.client = client;
|
|
42
44
|
}
|
|
@@ -50,72 +52,88 @@ export class InstallationFlow {
|
|
|
50
52
|
listener();
|
|
51
53
|
}
|
|
52
54
|
clearReview() {
|
|
53
|
-
|
|
55
|
+
const status = this.state.request?.status;
|
|
56
|
+
if (this.submitting || this.state.uncertainSubmit || (status && !["draft", "succeeded", "denied", "failed"].includes(status)))
|
|
54
57
|
return;
|
|
55
58
|
this.generation++;
|
|
56
|
-
this.set({ request: null, busy: false, error: null });
|
|
59
|
+
this.set({ request: null, busy: false, error: null, uncertainSubmit: false });
|
|
57
60
|
}
|
|
58
61
|
cancelPending() { this.generation++; }
|
|
59
62
|
dispose() { this.disposed = true; this.generation++; this.listeners.clear(); }
|
|
60
63
|
async review(source) {
|
|
61
|
-
if (this.state.request && this.state.request.status !== "draft")
|
|
64
|
+
if (this.submitting || this.state.uncertainSubmit || (this.state.request && this.state.request.status !== "draft"))
|
|
62
65
|
return;
|
|
63
66
|
const generation = ++this.generation;
|
|
64
|
-
this.set({ request: null, busy: true, error: null });
|
|
67
|
+
this.set({ request: null, busy: true, error: null, uncertainSubmit: false });
|
|
65
68
|
try {
|
|
66
69
|
const request = await this.client.prepare(source);
|
|
67
70
|
if (generation === this.generation)
|
|
68
|
-
this.set({ request, busy: false, error: null });
|
|
71
|
+
this.set({ request, busy: false, error: null, uncertainSubmit: false });
|
|
69
72
|
}
|
|
70
73
|
catch (error) {
|
|
71
74
|
if (generation === this.generation)
|
|
72
|
-
this.set({ request: null, busy: false, error: message(error) });
|
|
75
|
+
this.set({ request: null, busy: false, error: message(error), uncertainSubmit: false });
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
async submit(settings, autostart) {
|
|
76
|
-
if (this.state.busy || this.state.request?.status !== "draft")
|
|
79
|
+
if (this.state.busy || this.state.uncertainSubmit || this.state.request?.status !== "draft")
|
|
77
80
|
return null;
|
|
78
81
|
const id = this.state.request.id;
|
|
79
82
|
const generation = this.generation;
|
|
83
|
+
this.submitting = true;
|
|
80
84
|
this.set({ ...this.state, busy: true, error: null });
|
|
81
85
|
try {
|
|
82
86
|
const request = await this.client.submit(id, settings, autostart);
|
|
83
87
|
if (generation !== this.generation)
|
|
84
88
|
return null;
|
|
85
|
-
this.set({ request, busy: false, error: null });
|
|
89
|
+
this.set({ request, busy: false, error: null, uncertainSubmit: false });
|
|
86
90
|
return request;
|
|
87
91
|
}
|
|
88
92
|
catch (error) {
|
|
93
|
+
if (error instanceof InstallationError && error.status >= 400 && error.status < 500) {
|
|
94
|
+
if (generation === this.generation)
|
|
95
|
+
this.set({ ...this.state, busy: false, error: message(error), uncertainSubmit: false });
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
89
98
|
// A lost response may mean the request was already frozen. Recover by reading its status,
|
|
90
99
|
// not by sending another submit or creating a second installation.
|
|
91
100
|
try {
|
|
92
101
|
const request = await this.client.status(id);
|
|
93
102
|
if (generation !== this.generation)
|
|
94
103
|
return null;
|
|
95
|
-
this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null });
|
|
104
|
+
this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null, uncertainSubmit: false });
|
|
96
105
|
return request.status === "draft" ? null : request;
|
|
97
106
|
}
|
|
98
107
|
catch {
|
|
99
108
|
if (generation === this.generation)
|
|
100
|
-
this.set({ ...this.state, busy: false, error: message(error) });
|
|
109
|
+
this.set({ ...this.state, busy: false, error: message(error), uncertainSubmit: true });
|
|
101
110
|
return null;
|
|
102
111
|
}
|
|
103
112
|
}
|
|
113
|
+
finally {
|
|
114
|
+
this.submitting = false;
|
|
115
|
+
}
|
|
104
116
|
}
|
|
105
117
|
async refresh() {
|
|
106
|
-
if (!this.state.request || this.state.busy)
|
|
118
|
+
if (!this.state.request || this.state.busy || this.refreshing)
|
|
107
119
|
return;
|
|
120
|
+
this.refreshing = true;
|
|
108
121
|
const id = this.state.request.id;
|
|
109
122
|
const generation = this.generation;
|
|
110
123
|
try {
|
|
111
124
|
const request = await this.client.status(id);
|
|
112
125
|
if (generation === this.generation)
|
|
113
|
-
this.set({ request, busy: false,
|
|
126
|
+
this.set({ request, busy: false,
|
|
127
|
+
error: request.error ?? (this.state.uncertainSubmit && request.status === "draft" ? "Core has not received the submission. Retry this request." : null),
|
|
128
|
+
uncertainSubmit: false });
|
|
114
129
|
}
|
|
115
130
|
catch (error) {
|
|
116
131
|
if (generation === this.generation)
|
|
117
132
|
this.set({ ...this.state, error: message(error) });
|
|
118
133
|
}
|
|
134
|
+
finally {
|
|
135
|
+
this.refreshing = false;
|
|
136
|
+
}
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
function message(error) { return error instanceof Error ? error.message : "Installation request failed."; }
|
|
@@ -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
|
+
}
|