@hosty-sdk/app 0.13.0 → 0.14.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 +66 -0
- package/dist/install-react.d.ts +16 -0
- package/dist/install-react.js +76 -0
- package/dist/install-server.d.ts +6 -0
- package/dist/install-server.js +52 -0
- package/dist/install.d.ts +93 -0
- package/dist/install.js +133 -0
- package/dist/react.d.ts +1 -1
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -114,3 +114,69 @@ The design contract lives in the Hosty repository:
|
|
|
114
114
|
[`docs/features/hosty-app-sdk/feature.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/features/hosty-app-sdk/feature.md).
|
|
115
115
|
|
|
116
116
|
License: AGPL-3.0-only.
|
|
117
|
+
|
|
118
|
+
## Installing apps
|
|
119
|
+
|
|
120
|
+
Declare `"corePermissions": ["apps.install"]` in the manifest and have an administrator approve
|
|
121
|
+
that permission when installing/updating your app. This grants the ability to **request** an
|
|
122
|
+
installation; it never grants the ability to approve it on the user's behalf.
|
|
123
|
+
|
|
124
|
+
Mount an app-local handler using the existing Hosty identity-cookie configuration:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// app/api/hosty/installations/[[...path]]/route.ts
|
|
128
|
+
import { createInstallationRouteHandler } from "@hosty-sdk/app/install/server";
|
|
129
|
+
export const GET = createInstallationRouteHandler({
|
|
130
|
+
appIdFallback: "com.example.marketplace",
|
|
131
|
+
identityCookieName: "my_app_hosty_identity",
|
|
132
|
+
}, {
|
|
133
|
+
publicOrigin: process.env.HOSTY_PUBLIC_ORIGIN_HTTP,
|
|
134
|
+
});
|
|
135
|
+
export const POST = GET;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Set `publicOrigin` to the Core-injected public origin for your UI endpoint when the framework
|
|
139
|
+
exposes an internal request URL. The adapter checks browser `Origin` against this configured
|
|
140
|
+
value, or against the request URL when it is omitted; forwarded-host headers are never trusted.
|
|
141
|
+
|
|
142
|
+
The default dialog includes runtime selection, settings and a link to Core confirmation:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
"use client";
|
|
146
|
+
import { createInstallationClient } from "@hosty-sdk/app/install";
|
|
147
|
+
import { InstallDialog } from "@hosty-sdk/app/install/react";
|
|
148
|
+
const client = createInstallationClient();
|
|
149
|
+
|
|
150
|
+
// Render conditionally while open; unmount it when closed.
|
|
151
|
+
<InstallDialog
|
|
152
|
+
client={client}
|
|
153
|
+
source={{ feedsUrl: "https://example.com/feeds.json", feedId: "stable" }}
|
|
154
|
+
onClose={() => setOpen(false)}
|
|
155
|
+
onInstalled={() => { setOpen(false); refreshInstalledApps(); }}
|
|
156
|
+
/>;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
For a custom UI, use `useInstallation(client)` or the framework-independent `InstallationFlow`.
|
|
160
|
+
For direct API use, the same client provides the full request sequence:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const draft = await client.prepare({ manifestPath: "https://example.com/manifest.json" });
|
|
164
|
+
// Render draft.plan and collect settings in your UI.
|
|
165
|
+
const pending = await client.submit(draft.id, { APP_MODE: "standard" }, true);
|
|
166
|
+
// Open pending.approvalUrl from a user gesture, or render it as a new-tab link.
|
|
167
|
+
// Poll client.status(pending.id) until succeeded, denied or failed.
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Open an empty confirmation window synchronously in the click handler with
|
|
171
|
+
`openInstallationConfirmation()`, before awaiting `submit`, then navigate it using
|
|
172
|
+
`showInstallationConfirmation(popup, pending)`. Always retain a visible new-tab link for popup
|
|
173
|
+
blocking. No installation-specific Shell messages or shared administrative credentials are needed.
|
|
174
|
+
`prepare({ updateAppId, planDigest })` similarly requests approval of a reviewed update for clients
|
|
175
|
+
with `apps.update`. A transport override supports existing Core operator clients:
|
|
176
|
+
`createInstallationClient({ baseUrl: coreOrigin + "/api/installations", request: sendCsrfJson })`.
|
|
177
|
+
|
|
178
|
+
Core's final page cannot be embedded or replaced by a custom permission-grant dialog. It requires
|
|
179
|
+
an administrator browser login issued on a dedicated Core hostname, separate from app cookie
|
|
180
|
+
hosts. Existing sessions need a fresh login. Requests expire after 15 minutes and Core restart
|
|
181
|
+
invalidates them. Closing your UI does not cancel a confirmed operation; status is the authority.
|
|
182
|
+
Never automatically retry an execution after a lost response.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { InstallationFlow, type InstallationClient, type InstallationRequest, type InstallationSource } from "./install.js";
|
|
2
|
+
/** Custom dialogs can use this hook without adopting the default presentation. */
|
|
3
|
+
export declare function useInstallation(client: InstallationClient): {
|
|
4
|
+
flow: InstallationFlow;
|
|
5
|
+
request: InstallationRequest | null;
|
|
6
|
+
busy: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
};
|
|
9
|
+
export interface InstallDialogProps {
|
|
10
|
+
client: InstallationClient;
|
|
11
|
+
source?: InstallationSource;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onInstalled?: (request: InstallationRequest) => void;
|
|
14
|
+
}
|
|
15
|
+
/** Mount when opened; unmount on close. Styling is self-contained and has no Next/Tailwind dependency. */
|
|
16
|
+
export declare function InstallDialog({ client, source, onClose, onInstalled }: InstallDialogProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { InstallationFlow, openInstallationConfirmation, showInstallationConfirmation, } from "./install.js";
|
|
5
|
+
/** Custom dialogs can use this hook without adopting the default presentation. */
|
|
6
|
+
export function useInstallation(client) {
|
|
7
|
+
const flow = useMemo(() => new InstallationFlow(client), [client]);
|
|
8
|
+
const state = useSyncExternalStore(flow.subscribe, flow.snapshot, flow.snapshot);
|
|
9
|
+
useEffect(() => () => flow.cancelPending(), [flow]);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!state.request || !["pending", "executing"].includes(state.request.status))
|
|
12
|
+
return;
|
|
13
|
+
const timer = setInterval(() => void flow.refresh(), 1500);
|
|
14
|
+
return () => clearInterval(timer);
|
|
15
|
+
}, [flow, state.request?.id, state.request?.status]);
|
|
16
|
+
return { ...state, flow };
|
|
17
|
+
}
|
|
18
|
+
/** Mount when opened; unmount on close. Styling is self-contained and has no Next/Tailwind dependency. */
|
|
19
|
+
export function InstallDialog({ client, source, onClose, onInstalled }) {
|
|
20
|
+
const { request, busy, error, flow } = useInstallation(client);
|
|
21
|
+
const dialog = useRef(null);
|
|
22
|
+
const titleId = useId();
|
|
23
|
+
const [manifestPath, setManifestPath] = useState(source?.manifestPath ?? "");
|
|
24
|
+
const [settings, setSettings] = useState({});
|
|
25
|
+
const [autostart, setAutostart] = useState(true);
|
|
26
|
+
const [draftPlanId, setDraftPlanId] = useState(null);
|
|
27
|
+
const notified = useRef(null);
|
|
28
|
+
const plan = request?.plan ?? null;
|
|
29
|
+
if (plan && request?.id !== draftPlanId) {
|
|
30
|
+
setDraftPlanId(request.id);
|
|
31
|
+
setSettings(Object.fromEntries(plan.settings.map(setting => [setting.key, setting.secret ? "" : setting.defaultValue ?? ""])));
|
|
32
|
+
setAutostart(plan.defaultAutostart ?? true);
|
|
33
|
+
}
|
|
34
|
+
const initialManifest = source?.manifestPath;
|
|
35
|
+
const initialFeed = source?.feedsUrl;
|
|
36
|
+
const initialFeedId = source?.feedId;
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (initialManifest || initialFeed)
|
|
39
|
+
void flow.review({ manifestPath: initialManifest, feedsUrl: initialFeed, feedId: initialFeedId });
|
|
40
|
+
}, [flow, initialManifest, initialFeed, initialFeedId]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const element = dialog.current;
|
|
43
|
+
element?.showModal();
|
|
44
|
+
return () => element?.close();
|
|
45
|
+
}, []);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (request?.status === "succeeded" && notified.current !== request.id) {
|
|
48
|
+
notified.current = request.id;
|
|
49
|
+
onInstalled?.(request);
|
|
50
|
+
}
|
|
51
|
+
}, [request, onInstalled]);
|
|
52
|
+
const frozen = request !== null && request.status !== "draft";
|
|
53
|
+
const review = (runtime) => flow.review({
|
|
54
|
+
...(source?.feedsUrl ? { feedsUrl: source.feedsUrl, feedId: source.feedId } : { manifestPath: manifestPath.trim() }),
|
|
55
|
+
selectedRuntime: runtime,
|
|
56
|
+
});
|
|
57
|
+
const submit = async () => {
|
|
58
|
+
const popup = openInstallationConfirmation();
|
|
59
|
+
const values = Object.fromEntries((plan?.settings ?? []).filter(setting => !setting.secret || settings[setting.key])
|
|
60
|
+
.map(setting => [setting.key, settings[setting.key] ?? ""]));
|
|
61
|
+
const submitted = await flow.submit(values, autostart);
|
|
62
|
+
if (submitted)
|
|
63
|
+
showInstallationConfirmation(popup, submitted);
|
|
64
|
+
else
|
|
65
|
+
popup?.close();
|
|
66
|
+
};
|
|
67
|
+
return _jsxs("dialog", { ref: dialog, className: "hosty-install", "aria-labelledby": titleId, onCancel: event => { event.preventDefault(); onClose(); }, children: [_jsx("style", { children: styles }), _jsxs("header", { children: [_jsx("h2", { id: titleId, children: "Install app" }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close installation", children: "\u00D7" })] }), _jsx("p", { children: "Prepare the installation here, then confirm it securely in Hosty Core." }), !source?.feedsUrl && !source?.manifestPath && _jsxs("form", { onSubmit: event => { event.preventDefault(); void review(); }, children: [_jsxs("label", { children: ["Manifest, app directory, or URL", _jsx("input", { value: manifestPath, onChange: event => { setManifestPath(event.target.value); flow.clearReview(); }, required: true, disabled: busy || frozen })] }), _jsx("button", { disabled: busy || frozen || !manifestPath.trim(), children: "Review" })] }), busy && _jsx("p", { role: "status", children: "Preparing installation\u2026" }), error && _jsxs("div", { role: "alert", children: [_jsx("p", { children: error }), !frozen && _jsx("button", { type: "button", disabled: busy, onClick: () => void review(), children: "Retry review" })] }), plan && _jsxs("form", { onSubmit: event => { event.preventDefault(); void submit(); }, children: [_jsxs("h3", { children: [plan.displayName, " ", _jsx("small", { children: plan.targetVersion })] }), plan.description && _jsx("p", { children: plan.description }), _jsxs("label", { children: ["Runtime", _jsx("select", { value: plan.targetRuntime, disabled: busy || frozen, onChange: event => void review(event.target.value), children: (plan.runtimeProfiles ?? [{ key: plan.targetRuntime, type: plan.targetRuntimeType }]).map(runtime => _jsxs("option", { value: runtime.key, children: [runtime.key, " (", runtime.type, ")"] }, runtime.key)) })] }), plan.targetRuntimeType === "localCommand" && _jsx("p", { className: "warning", children: "This runtime runs commands directly on your host, outside a container. Only install code you trust." }), plan.system && _jsx("p", { className: "warning", children: "This is a system app. Only host administrators can open it." }), !!plan.corePermissions?.length && _jsxs("section", { children: [_jsx("h3", { children: "Requested Core permissions" }), _jsx("ul", { children: plan.corePermissions.map(permission => _jsx("li", { children: permission }, permission)) })] }), plan.settings.map(setting => _jsxs("label", { children: [setting.label || setting.key, setting.required ? " (required)" : "", setting.type === "boolean" && !setting.secret
|
|
68
|
+
? _jsx("input", { type: "checkbox", checked: ["true", "1", "yes", "on", "enabled"].includes((settings[setting.key] ?? "").toLowerCase()), disabled: busy || frozen, onChange: event => setSettings(current => ({ ...current, [setting.key]: String(event.target.checked) })) })
|
|
69
|
+
: setting.type === "select" && setting.options?.length && !setting.secret
|
|
70
|
+
? _jsxs("select", { value: settings[setting.key] ?? "", disabled: busy || frozen, onChange: event => setSettings(current => ({ ...current, [setting.key]: event.target.value })), children: [_jsx("option", { value: "", children: "Select a value" }), setting.options.map(option => _jsx("option", { value: option.value, children: option.label }, option.value))] })
|
|
71
|
+
: _jsx("input", { type: setting.secret ? "password" : setting.type === "number" ? "number" : setting.type === "url" ? "url" : "text", step: setting.type === "number" ? "any" : undefined, autoComplete: setting.secret ? "new-password" : "off", required: setting.required && !setting.secret, disabled: busy || frozen, value: settings[setting.key] ?? "", onChange: event => setSettings(current => ({ ...current, [setting.key]: event.target.value })) }), setting.description && _jsx("small", { children: setting.description })] }, setting.key)), _jsxs("label", { className: "check", children: [_jsx("input", { type: "checkbox", checked: autostart, disabled: busy || frozen, onChange: event => setAutostart(event.target.checked) }), " Start automatically (now and on Core startup)"] }), !frozen && _jsx("footer", { children: _jsx("button", { className: "primary", disabled: busy, children: "Continue to Core confirmation" }) })] }), frozen && _jsxs("section", { role: "status", children: [_jsx("p", { children: request.status === "pending" ? "Waiting for your confirmation in Hosty Core." : request.status === "executing" ? "Core is applying your confirmed request…" : request.status === "succeeded" ? "App installed." : request.status === "denied" ? "Installation cancelled." : "Installation failed. Close this dialog and review a new plan to retry." }), request.status === "pending" && _jsx("a", { href: request.approvalUrl, target: "_blank", rel: "noopener noreferrer", children: "Open Core confirmation" }), _jsx("p", { children: _jsx("small", { children: "Closing this dialog does not cancel an operation already confirmed in Core." }) })] })] });
|
|
72
|
+
}
|
|
73
|
+
const styles = `
|
|
74
|
+
.hosty-install{box-sizing:border-box;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:24px;border:1px solid var(--border,#aaa);border-radius:12px;background:var(--background,Canvas);color:var(--foreground,CanvasText);font:inherit;overflow:auto;margin:auto}
|
|
75
|
+
.hosty-install::backdrop{background:#0008}.hosty-install header{display:flex;align-items:center;justify-content:space-between;gap:16px}.hosty-install h2{font-size:1.25rem;margin:0}.hosty-install h3{font-size:1rem;margin:20px 0 8px}.hosty-install p{margin:12px 0;font-size:.9rem}.hosty-install small{font-size:.8rem;opacity:.75}.hosty-install label{display:flex;flex-direction:column;gap:6px;margin:16px 0;font-size:.9rem}.hosty-install input:not([type=checkbox]),.hosty-install select{box-sizing:border-box;width:100%;border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 10px;background:transparent;color:inherit;font:inherit}.hosty-install .check{flex-direction:row;align-items:center;gap:10px}.hosty-install input[type=checkbox]{width:16px;height:16px;accent-color:#2563eb}.hosty-install button{border:1px solid var(--border,#aaa);border-radius:6px;padding:8px 12px;font:inherit;background:transparent;color:inherit;cursor:pointer}.hosty-install button:disabled{opacity:.5;cursor:default}.hosty-install .primary{background:#2563eb;color:white;border-color:#2563eb}.hosty-install footer{display:flex;justify-content:flex-end;margin-top:24px}.hosty-install .warning{padding:12px;border:1px solid #b7791f;border-radius:6px}.hosty-install [role=alert]{color:#d33}.hosty-install a{color:#2563eb;text-decoration:underline}.hosty-install :focus-visible{outline:2px solid #60a5fa;outline-offset:3px}
|
|
76
|
+
`;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type HostyAppConfig } from "./server.js";
|
|
3
|
+
/** App-local adapter. Core revalidates the app/user on every call; service credentials stay server-side. */
|
|
4
|
+
export declare function createInstallationRouteHandler(config: HostyAppConfig, options?: {
|
|
5
|
+
publicOrigin?: string | null;
|
|
6
|
+
}): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { getAppId, getCoreOrigin, getServiceToken, readAppIdentityToken } from "./server.js";
|
|
3
|
+
/** App-local adapter. Core revalidates the app/user on every call; service credentials stay server-side. */
|
|
4
|
+
export function createInstallationRouteHandler(config, options = {}) {
|
|
5
|
+
return async (request) => {
|
|
6
|
+
const url = new URL(request.url);
|
|
7
|
+
const match = /\/installations(?:\/([a-f0-9]{48})(\/submit)?)?$/.exec(url.pathname);
|
|
8
|
+
const valid = match && ((request.method === "POST" && (!match[1] || match[2])) || (request.method === "GET" && match[1] && !match[2]));
|
|
9
|
+
if (!valid)
|
|
10
|
+
return Response.json({ message: "Unsupported installation route." }, { status: 404 });
|
|
11
|
+
// Origin is browser-controlled; an installed hostile page cannot submit through the user's
|
|
12
|
+
// app cookie. Do not trust a caller-supplied forwarded host to decide which origins are allowed.
|
|
13
|
+
// Frameworks may expose an internal request URL behind a proxy or custom local hostname.
|
|
14
|
+
// An explicit operator-configured origin is authoritative in that case.
|
|
15
|
+
let expectedOrigin = url.origin;
|
|
16
|
+
if (options.publicOrigin?.trim()) {
|
|
17
|
+
try {
|
|
18
|
+
const configured = new URL(options.publicOrigin);
|
|
19
|
+
if (!["http:", "https:"].includes(configured.protocol))
|
|
20
|
+
throw new Error("Invalid origin");
|
|
21
|
+
expectedOrigin = configured.origin;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return Response.json({ code: "hosty_misconfigured", message: "Installation public origin is invalid." }, { status: 503 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (request.method !== "GET" && (request.headers.get("origin") !== expectedOrigin ||
|
|
28
|
+
!request.headers.get("content-type")?.startsWith("application/json")))
|
|
29
|
+
return Response.json({ code: "origin_denied", message: "A same-origin JSON request is required." }, { status: 403 });
|
|
30
|
+
const token = readAppIdentityToken(request.headers, config);
|
|
31
|
+
if (!token)
|
|
32
|
+
return Response.json({ code: "app_identity_required", message: "Sign in through Hosty." }, { status: 401 });
|
|
33
|
+
const core = getCoreOrigin();
|
|
34
|
+
const service = getServiceToken();
|
|
35
|
+
if (!core || !service)
|
|
36
|
+
return Response.json({ code: "hosty_misconfigured", message: "Core integration is not configured." }, { status: 503 });
|
|
37
|
+
const suffix = match[1] ? `/${match[1]}${match[2] ?? ""}` : "";
|
|
38
|
+
try {
|
|
39
|
+
const body = request.method === "GET" ? undefined : await request.text();
|
|
40
|
+
if (body && body.length > 65536)
|
|
41
|
+
return Response.json({ message: "Request is too large." }, { status: 413 });
|
|
42
|
+
const response = await fetch(new URL(`/api/internal/apps/${encodeURIComponent(getAppId(config))}/installations${suffix}`, core), {
|
|
43
|
+
method: request.method, redirect: "error", cache: "no-store", signal: request.signal,
|
|
44
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${service}`, "X-Hosty-App-Identity": token }, body,
|
|
45
|
+
});
|
|
46
|
+
return new Response(response.body, { status: response.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return Response.json({ code: "core_unavailable", message: "Core could not be reached. Check the request status before trying again." }, { status: 503 });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Framework-independent client for Core's reviewed, user-confirmed installation requests. */
|
|
2
|
+
export interface InstallSetting {
|
|
3
|
+
key: string;
|
|
4
|
+
type: string;
|
|
5
|
+
defaultValue?: string | null;
|
|
6
|
+
secret: boolean;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
label?: string | null;
|
|
9
|
+
description?: string | null;
|
|
10
|
+
options?: {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}[] | null;
|
|
14
|
+
}
|
|
15
|
+
export interface InstallPlan {
|
|
16
|
+
appId: string;
|
|
17
|
+
displayName: string;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
action: string;
|
|
20
|
+
planId?: string | null;
|
|
21
|
+
targetVersion: string;
|
|
22
|
+
targetRuntime: string;
|
|
23
|
+
targetRuntimeType: string;
|
|
24
|
+
targetManifestDigest: string;
|
|
25
|
+
manifestPath: string;
|
|
26
|
+
defaultAutostart?: boolean;
|
|
27
|
+
system?: boolean;
|
|
28
|
+
corePermissions?: string[];
|
|
29
|
+
runtimeProfiles?: {
|
|
30
|
+
key: string;
|
|
31
|
+
type: string;
|
|
32
|
+
default: boolean;
|
|
33
|
+
development?: boolean;
|
|
34
|
+
}[];
|
|
35
|
+
settings: InstallSetting[];
|
|
36
|
+
}
|
|
37
|
+
export interface InstallationSource {
|
|
38
|
+
manifestPath?: string;
|
|
39
|
+
feedsUrl?: string;
|
|
40
|
+
feedId?: string;
|
|
41
|
+
selectedRuntime?: string;
|
|
42
|
+
updateAppId?: string;
|
|
43
|
+
planDigest?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface InstallationRequest {
|
|
46
|
+
id: string;
|
|
47
|
+
status: "draft" | "pending" | "executing" | "succeeded" | "denied" | "failed";
|
|
48
|
+
plan: InstallPlan | null;
|
|
49
|
+
approvalUrl: string;
|
|
50
|
+
expiresAt: string;
|
|
51
|
+
error?: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface InstallationClient {
|
|
54
|
+
prepare(source: InstallationSource): Promise<InstallationRequest>;
|
|
55
|
+
submit(id: string, settings: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest>;
|
|
56
|
+
status(id: string): Promise<InstallationRequest>;
|
|
57
|
+
}
|
|
58
|
+
export declare class InstallationError extends Error {
|
|
59
|
+
readonly status: number;
|
|
60
|
+
readonly code?: string | undefined;
|
|
61
|
+
constructor(message: string, status: number, code?: string | undefined);
|
|
62
|
+
}
|
|
63
|
+
export declare function createInstallationClient(options?: {
|
|
64
|
+
/** App-local server adapter by default; Shell may use Core's browser-session transport. */
|
|
65
|
+
baseUrl?: string;
|
|
66
|
+
request?: (url: string, body?: unknown, method?: string) => Promise<Response>;
|
|
67
|
+
}): InstallationClient;
|
|
68
|
+
export interface InstallationState {
|
|
69
|
+
request: InstallationRequest | null;
|
|
70
|
+
busy: boolean;
|
|
71
|
+
error: string | null;
|
|
72
|
+
}
|
|
73
|
+
/** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
|
|
74
|
+
export declare class InstallationFlow {
|
|
75
|
+
private readonly client;
|
|
76
|
+
private state;
|
|
77
|
+
private listeners;
|
|
78
|
+
private generation;
|
|
79
|
+
private disposed;
|
|
80
|
+
constructor(client: InstallationClient);
|
|
81
|
+
snapshot: () => InstallationState;
|
|
82
|
+
subscribe: (listener: () => void) => (() => void);
|
|
83
|
+
private set;
|
|
84
|
+
clearReview(): void;
|
|
85
|
+
cancelPending(): void;
|
|
86
|
+
dispose(): void;
|
|
87
|
+
review(source: InstallationSource): Promise<void>;
|
|
88
|
+
submit(settings: Record<string, string | null>, autostart: boolean): Promise<InstallationRequest | null>;
|
|
89
|
+
refresh(): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
/** Open synchronously in a click handler, before awaiting plan/submit requests. */
|
|
92
|
+
export declare function openInstallationConfirmation(): Window | null;
|
|
93
|
+
export declare function showInstallationConfirmation(popup: Window | null, request: InstallationRequest): void;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export class InstallationError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, status, code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function createInstallationClient(options = {}) {
|
|
11
|
+
const base = (options.baseUrl ?? "/api/hosty/installations").replace(/\/$/, "");
|
|
12
|
+
const request = options.request ?? ((url, body, method = "POST") => fetch(url, {
|
|
13
|
+
method, credentials: "same-origin", cache: "no-store",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
16
|
+
}));
|
|
17
|
+
async function call(path, body, method = "POST") {
|
|
18
|
+
const response = await request(`${base}${path}`, body, method);
|
|
19
|
+
const data = await response.json().catch(() => null);
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new InstallationError(data?.message ?? `Core returned ${response.status}.`, response.status, data?.code);
|
|
22
|
+
if (!data || typeof data.id !== "string" || typeof data.approvalUrl !== "string" || typeof data.status !== "string")
|
|
23
|
+
throw new InstallationError("Core returned an invalid installation request.", 502, "response_invalid");
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
prepare: source => call("", source),
|
|
28
|
+
submit: (id, settings, autostart) => call(`/${encodeURIComponent(id)}/submit`, { settings, autostart }),
|
|
29
|
+
status: id => call(`/${encodeURIComponent(id)}`, undefined, "GET"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Reusable state flow for custom UIs, independent of React. Never automatically retries a mutation. */
|
|
33
|
+
export class InstallationFlow {
|
|
34
|
+
client;
|
|
35
|
+
state = { request: null, busy: false, error: null };
|
|
36
|
+
listeners = new Set();
|
|
37
|
+
generation = 0;
|
|
38
|
+
disposed = false;
|
|
39
|
+
constructor(client) {
|
|
40
|
+
this.client = client;
|
|
41
|
+
}
|
|
42
|
+
snapshot = () => this.state;
|
|
43
|
+
subscribe = (listener) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
|
|
44
|
+
set(state) {
|
|
45
|
+
if (this.disposed)
|
|
46
|
+
return;
|
|
47
|
+
this.state = state;
|
|
48
|
+
for (const listener of this.listeners)
|
|
49
|
+
listener();
|
|
50
|
+
}
|
|
51
|
+
clearReview() {
|
|
52
|
+
if (this.state.request && this.state.request.status !== "draft")
|
|
53
|
+
return;
|
|
54
|
+
this.generation++;
|
|
55
|
+
this.set({ request: null, busy: false, error: null });
|
|
56
|
+
}
|
|
57
|
+
cancelPending() { this.generation++; }
|
|
58
|
+
dispose() { this.disposed = true; this.generation++; this.listeners.clear(); }
|
|
59
|
+
async review(source) {
|
|
60
|
+
if (this.state.request && this.state.request.status !== "draft")
|
|
61
|
+
return;
|
|
62
|
+
const generation = ++this.generation;
|
|
63
|
+
this.set({ request: null, busy: true, error: null });
|
|
64
|
+
try {
|
|
65
|
+
const request = await this.client.prepare(source);
|
|
66
|
+
if (generation === this.generation)
|
|
67
|
+
this.set({ request, busy: false, error: null });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (generation === this.generation)
|
|
71
|
+
this.set({ request: null, busy: false, error: message(error) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async submit(settings, autostart) {
|
|
75
|
+
if (this.state.busy || this.state.request?.status !== "draft")
|
|
76
|
+
return null;
|
|
77
|
+
const id = this.state.request.id;
|
|
78
|
+
const generation = this.generation;
|
|
79
|
+
this.set({ ...this.state, busy: true, error: null });
|
|
80
|
+
try {
|
|
81
|
+
const request = await this.client.submit(id, settings, autostart);
|
|
82
|
+
if (generation !== this.generation)
|
|
83
|
+
return null;
|
|
84
|
+
this.set({ request, busy: false, error: null });
|
|
85
|
+
return request;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// A lost response may mean the request was already frozen. Recover by reading its status,
|
|
89
|
+
// not by sending another submit or creating a second installation.
|
|
90
|
+
try {
|
|
91
|
+
const request = await this.client.status(id);
|
|
92
|
+
if (generation !== this.generation)
|
|
93
|
+
return null;
|
|
94
|
+
this.set({ request, busy: false, error: request.status === "draft" ? message(error) : null });
|
|
95
|
+
return request.status === "draft" ? null : request;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
if (generation === this.generation)
|
|
99
|
+
this.set({ ...this.state, busy: false, error: message(error) });
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async refresh() {
|
|
105
|
+
if (!this.state.request || this.state.busy)
|
|
106
|
+
return;
|
|
107
|
+
const id = this.state.request.id;
|
|
108
|
+
const generation = this.generation;
|
|
109
|
+
try {
|
|
110
|
+
const request = await this.client.status(id);
|
|
111
|
+
if (generation === this.generation)
|
|
112
|
+
this.set({ request, busy: false, error: request.error ?? null });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (generation === this.generation)
|
|
116
|
+
this.set({ ...this.state, error: message(error) });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function message(error) { return error instanceof Error ? error.message : "Installation request failed."; }
|
|
121
|
+
/** Open synchronously in a click handler, before awaiting plan/submit requests. */
|
|
122
|
+
export function openInstallationConfirmation() {
|
|
123
|
+
return window.open("about:blank", "_blank", "popup,width=640,height=720");
|
|
124
|
+
}
|
|
125
|
+
export function showInstallationConfirmation(popup, request) {
|
|
126
|
+
const url = new URL(request.approvalUrl);
|
|
127
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
128
|
+
throw new Error("Invalid Core confirmation URL.");
|
|
129
|
+
if (popup && !popup.closed) {
|
|
130
|
+
popup.opener = null;
|
|
131
|
+
popup.location.replace(url.href);
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/react.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface AppIdentityBridgeProps {
|
|
|
56
56
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
57
57
|
* — never a login UI while recovery is still running.
|
|
58
58
|
*/
|
|
59
|
-
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, }?: 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> |
|
|
59
|
+
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, }?: AppIdentityBridgeProps): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
|
|
60
60
|
export interface HostThemeBridgeProps {
|
|
61
61
|
/**
|
|
62
62
|
* Whether the operating system decides the theme while no shell has spoken — `true` unless the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,18 @@
|
|
|
36
36
|
"./theme": {
|
|
37
37
|
"types": "./dist/theme.d.ts",
|
|
38
38
|
"default": "./dist/theme.js"
|
|
39
|
+
},
|
|
40
|
+
"./install": {
|
|
41
|
+
"types": "./dist/install.d.ts",
|
|
42
|
+
"default": "./dist/install.js"
|
|
43
|
+
},
|
|
44
|
+
"./install/react": {
|
|
45
|
+
"types": "./dist/install-react.d.ts",
|
|
46
|
+
"default": "./dist/install-react.js"
|
|
47
|
+
},
|
|
48
|
+
"./install/server": {
|
|
49
|
+
"types": "./dist/install-server.d.ts",
|
|
50
|
+
"default": "./dist/install-server.js"
|
|
39
51
|
}
|
|
40
52
|
},
|
|
41
53
|
"scripts": {
|
|
@@ -58,8 +70,8 @@
|
|
|
58
70
|
}
|
|
59
71
|
},
|
|
60
72
|
"devDependencies": {
|
|
61
|
-
"@types/node": "^24",
|
|
62
|
-
"@types/react": "^19.
|
|
73
|
+
"@types/node": "^24.13.4",
|
|
74
|
+
"@types/react": "^19.3.0",
|
|
63
75
|
"typescript": "^6",
|
|
64
76
|
"vitest": "^5.0.0"
|
|
65
77
|
},
|