@hosty-sdk/app 0.24.0 → 0.26.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 +106 -11
- package/dist/assistant.d.ts +15 -0
- package/dist/assistant.js +38 -1
- package/dist/background-server.d.ts +16 -0
- package/dist/background-server.js +52 -0
- package/dist/browser-auth.d.ts +2 -0
- package/dist/browser-auth.js +5 -0
- package/dist/continuity-browser.d.ts +11 -0
- package/dist/continuity-browser.js +92 -0
- package/dist/continuity.d.ts +9 -0
- package/dist/continuity.js +9 -0
- package/dist/overlay-react.d.ts +5 -1
- package/dist/overlay-react.js +7 -4
- package/dist/providers-server.d.ts +3 -1
- package/dist/providers-server.js +4 -2
- package/dist/providers.d.ts +4 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +15 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/session-server.d.ts +2 -0
- package/dist/ui-context.d.ts +2 -0
- package/dist/ui-context.js +3 -0
- package/dist/ui-embedded.d.ts +45 -0
- package/dist/ui-embedded.js +278 -0
- package/dist/ui-frame.d.ts +30 -0
- package/dist/ui-frame.js +104 -0
- package/dist/ui-host.d.ts +80 -0
- package/dist/ui-host.js +439 -0
- package/dist/ui-react.d.ts +35 -0
- package/dist/ui-react.js +225 -0
- package/dist/ui-server.d.ts +32 -0
- package/dist/ui-server.js +123 -0
- package/dist/ui-transport.d.ts +16 -0
- package/dist/ui-transport.js +68 -0
- package/dist/ui-types.d.ts +99 -0
- package/dist/ui-types.js +43 -0
- package/dist/ui.d.ts +20 -0
- package/dist/ui.js +36 -0
- package/package.json +33 -1
package/README.md
CHANGED
|
@@ -69,8 +69,8 @@ After first mount, blocked content and app portals become hidden and inert while
|
|
|
69
69
|
remains mounted. Same-user renewal restores it. A different stable user ID reloads the app document
|
|
70
70
|
and refuses to replay old pending requests. `APP_SESSION_RESTORED` from `/browser-auth` is an optional
|
|
71
71
|
DOM notification with `detail.userId`; it contains no credential. There is no generic draft persistence
|
|
72
|
-
across reloads or cache-clearing framework.
|
|
73
|
-
|
|
72
|
+
across reloads or cache-clearing framework. Current user/app permissions and the authorizing principal's idle/absolute lifetime still apply.
|
|
73
|
+
Core 0.130.0 advertises continuous app sessions; older servers retain their actual activity policy.
|
|
74
74
|
|
|
75
75
|
Compatible SDK updates change standard presentation and recovery after a dependency update and
|
|
76
76
|
rebuild, without editing this wiring. New mandatory Core capabilities can require upgrading Core.
|
|
@@ -289,13 +289,108 @@ reads or `mcp:invoke` for assistant mutations, then apply your application's use
|
|
|
289
289
|
Do not cache validation. Keep `introspectScopedToken` on ordinary APIs: it does not opt into accepting
|
|
290
290
|
MCP-only credentials. Neither token permits skipping the application's own authorization.
|
|
291
291
|
|
|
292
|
-
##
|
|
292
|
+
## Continuous app access (Core 0.130.0 / SDK 0.26.0)
|
|
293
293
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
294
|
+
Pass `continuity`, `activeUntil` and `activityRequired` from Core revalidation through custom identity
|
|
295
|
+
probes. Standard SDK adapters do this automatically. `HostyOverlay` / `useAppIdentity` maintain a
|
|
296
|
+
live browser parent through a Core-owned frame and refresh the app-local probe. Checks coalesce,
|
|
297
|
+
run on mount/return/network recovery, and repeat at a jittered five-minute interval. App backends
|
|
298
|
+
cannot keep the browser parent alive with app credentials alone.
|
|
299
|
+
|
|
300
|
+
Keep protected client requests on `appFetch`. An outage returns 503 without clearing identity or
|
|
301
|
+
replaying a mutation. Real expiry uses normal recovery, preserving same-user state. Optional
|
|
302
|
+
`<HostyOverlay localContent={<LocalNotes />}>…</HostyOverlay>` places explicitly local/public
|
|
303
|
+
content outside the protected root during an outage; do not place private actor-bound data there.
|
|
304
|
+
|
|
305
|
+
For another framework, use `configureAppContinuity` / `ensureAppContinuity` from
|
|
306
|
+
`@hosty-sdk/app/continuity/browser`, with validated metadata from `/continuity` and a probe that
|
|
307
|
+
checks the same actor. The probe must bypass maintenance (`appFetch(path, init, false)`) to avoid
|
|
308
|
+
recursion. Clean up the coordinator on unmount. `APP_CONTINUITY_UPDATED` reports silent success;
|
|
309
|
+
`APP_ACTIVITY_RENEWED` continues to mean explicit recovery. Do not replay handoffs/messages on either.
|
|
310
|
+
Missing continuity metadata keeps older Core's `configureAppActivity`/click renewal behavior.
|
|
311
|
+
|
|
312
|
+
Server-only unattended work requires an explicit Core grant:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { BackgroundGrantClient } from "@hosty-sdk/app/background/server";
|
|
316
|
+
const background = new BackgroundGrantClient(grantId, "health"); // HOSTY_* service configuration
|
|
317
|
+
const response = await background.core("/api/apps/example.target/health");
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The client coalesces credential issuance and refreshes before expiry. It sends each operation once;
|
|
321
|
+
Core enforces the immutable selected scope online. `token()` can supply the existing provider/MCP
|
|
322
|
+
client's user-token input. Keep tokens server-side. See
|
|
323
|
+
[Continuous App Access](../../docs/features/continuous-app-access/feature.md) for creation, logout,
|
|
324
|
+
revocation, exact MCP tools and installation boundaries. Background grants do not create a scheduler.
|
|
325
|
+
|
|
326
|
+
## Shared panels (Core 0.129.0 / SDK 0.25.0)
|
|
327
|
+
|
|
328
|
+
`HostyOverlay` composes one UI owner per top-level browser document. Its default
|
|
329
|
+
`panelLayout="auto"` adds an opened-tool rail, an **Add tool** chooser and a resizable
|
|
330
|
+
workspace. In a directly embedded app it delegates presentation to the verified parent
|
|
331
|
+
and renders no local rail. Use `panelLayout="slot"` with exactly one
|
|
332
|
+
`HostyPanelWorkspace` inside your chrome, or `"none"` for a custom controller renderer.
|
|
333
|
+
Panel hosting requires reviewed optional `apps.panels.read` (or existing `apps.read`);
|
|
334
|
+
participating as a child does not require catalog access.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
// app/api/hosty/ui/[...action]/route.ts — own app configuration/session only
|
|
338
|
+
import { createHostyUiRouteHandler } from "@hosty-sdk/app/server";
|
|
339
|
+
const handler = createHostyUiRouteHandler(config);
|
|
340
|
+
export const GET = handler;
|
|
341
|
+
export const POST = handler;
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Non-Next servers use `createHostyUiResponse` from `@hosty-sdk/app/ui/server` with their
|
|
345
|
+
revalidated own app-user session/token. `UiServerClient` provides only `bootstrap`,
|
|
346
|
+
`listPanels` and `resolvePanel`; never expose a generic Core proxy or caller-supplied credentials.
|
|
347
|
+
Both server adapters return `no-store` metadata. The React root's `uiPath` defaults to
|
|
348
|
+
`/api/hosty/ui` and must be a same-origin path.
|
|
349
|
+
|
|
350
|
+
Finalize a business handoff before requesting placement. Freeze the provider's app ID,
|
|
351
|
+
installation ID and interface key before `ProviderClient.assistant(...)`, then read its
|
|
352
|
+
current descriptor and map the completed result:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
import { resolveAssistantPresentation } from "@hosty-sdk/app/assistant";
|
|
356
|
+
import { buildSurfaceOpenUrl } from "@hosty-sdk/app/ui";
|
|
357
|
+
const presentation = resolveAssistantPresentation(currentProvider, finalized, { expectedProvider });
|
|
358
|
+
const href = buildSurfaceOpenUrl(bootstrap.coreOrigin, presentation.destination);
|
|
359
|
+
// bootstrap.pinnedOpen must be true before relying on this link.
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The pure mapper never selects an assistant or invokes it. Explicit `result.open.panelId`
|
|
363
|
+
selects a matching declared panel; otherwise the unique longest endpoint/path boundary
|
|
364
|
+
wins. Ties return `ambiguous_surface`, navigation-only providers return `no_panel`, and
|
|
365
|
+
both retain the exact same-session destination for explicit separate opening. A changed
|
|
366
|
+
provider installation is refused. Core validates pinned links again after login.
|
|
367
|
+
|
|
368
|
+
```tsx
|
|
369
|
+
import { useHostyUi } from "@hosty-sdk/app/ui/react";
|
|
370
|
+
import { createPresentationRequestId } from "@hosty-sdk/app/ui";
|
|
371
|
+
const ui = useHostyUi();
|
|
372
|
+
const requestId = createPresentationRequestId(); // retain with the completed result
|
|
373
|
+
const result = await ui.openSurface(presentation.target!, { placement: "side", requestId });
|
|
374
|
+
// Reuse requestId and target for retries; do not repeat the business action.
|
|
375
|
+
const current = await ui.getPresentation(requestId);
|
|
376
|
+
const unsubscribe = ui.subscribe(event => { /* environment or presentation update */ });
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`opened` confirms placement, with separate `loading`, `auth-required`, `ready` or
|
|
380
|
+
`unreported` readiness. Providers call `ui.reportSurfaceState({ route, state: "ready" })`
|
|
381
|
+
after their exact route has loaded; iframe load alone is not readiness. Deadline/abort
|
|
382
|
+
returns `uncertain`, not cancellation. Query or retry the same ID. An unsupported host
|
|
383
|
+
never automatically opens a tab or creates a nested workspace. **Open separately** is an
|
|
384
|
+
explicit `target="_blank" rel="noopener noreferrer"` link to the pinned destination.
|
|
385
|
+
|
|
386
|
+
Framework-neutral clients use `createUiHost` from `@hosty-sdk/app/ui/host`, its snapshots,
|
|
387
|
+
`registerFrame`, `openSurface`, `getPresentation`, `setExpanded`, `setWidth` and `dispose`.
|
|
388
|
+
React clients normally use the overlay-owned controller via `useHostyUiHost` and the shared
|
|
389
|
+
`HostyAppFrame`. Core projects the target's minimal microphone/popup policy; an app message
|
|
390
|
+
cannot widen it. No delegated-token responder is required.
|
|
391
|
+
|
|
392
|
+
Trust is limited to exact mounted window/origin, document generation and stateless Core
|
|
393
|
+
peer metadata. Matching opaque Core/user contexts prevents accidental account mixing
|
|
394
|
+
between cooperating apps; it is not attestation of app code or delegated API authority.
|
|
395
|
+
Every target API still authorizes its own session. Older surfaces remain `unreported`.
|
|
396
|
+
See [the panel contract and verification](../../docs/features/sdk-panel-system/feature.md).
|
package/dist/assistant.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ProviderDescriptor } from "./providers.js";
|
|
2
|
+
import { type AppSurfaceRef, type PanelTarget } from "@hosty-sdk/app/ui/types";
|
|
1
3
|
/** Preserve provider refusal details through app-owned server transports. */
|
|
2
4
|
export declare class AssistantError extends Error {
|
|
3
5
|
readonly code: string;
|
|
@@ -12,6 +14,7 @@ export type AssistantContract = {
|
|
|
12
14
|
export type AssistantDestination = {
|
|
13
15
|
endpoint: string;
|
|
14
16
|
path: string;
|
|
17
|
+
panelId?: string;
|
|
15
18
|
};
|
|
16
19
|
export type AssistantHandoffResult = {
|
|
17
20
|
conversationId: string;
|
|
@@ -51,6 +54,18 @@ export declare function resolveAssistantDestination(destination: AssistantDestin
|
|
|
51
54
|
path?: string | null;
|
|
52
55
|
url?: string | null;
|
|
53
56
|
}[]): string;
|
|
57
|
+
/** Pure mapping after finalization; the presentation host separately checks current access. */
|
|
58
|
+
export declare function resolveAssistantPresentation(provider: ProviderDescriptor, result: AssistantHandoffResult, options: {
|
|
59
|
+
expectedProvider: {
|
|
60
|
+
appId: string;
|
|
61
|
+
installationId: string;
|
|
62
|
+
key: string;
|
|
63
|
+
};
|
|
64
|
+
}): {
|
|
65
|
+
target: PanelTarget | null;
|
|
66
|
+
destination: AppSurfaceRef;
|
|
67
|
+
reason?: "no_panel" | "ambiguous_surface";
|
|
68
|
+
};
|
|
54
69
|
export declare class AssistantClient {
|
|
55
70
|
private readonly baseUrl;
|
|
56
71
|
private readonly contract;
|
package/dist/assistant.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSurfaceRoute, routeWithin } from "@hosty-sdk/app/ui/types";
|
|
1
2
|
/** Preserve provider refusal details through app-owned server transports. */
|
|
2
3
|
export class AssistantError extends Error {
|
|
3
4
|
code;
|
|
@@ -27,7 +28,7 @@ export function createAssistantRequestId(now = Date.now()) {
|
|
|
27
28
|
}
|
|
28
29
|
/** Resolve only inside a declared UI surface on the selected app's resolved endpoint. */
|
|
29
30
|
export function resolveAssistantDestination(destination, surfaces) {
|
|
30
|
-
if (!destination ||
|
|
31
|
+
if (!destination || !isSurfaceRoute(destination.path))
|
|
31
32
|
throw new Error("Invalid assistant UI destination.");
|
|
32
33
|
if (/%(?:2f|5c|25)/i.test(destination.path.split(/[?#]/, 1)[0]))
|
|
33
34
|
throw new Error("Invalid assistant UI destination.");
|
|
@@ -42,6 +43,42 @@ export function resolveAssistantDestination(destination, surfaces) {
|
|
|
42
43
|
}
|
|
43
44
|
throw new Error("The assistant returned a destination outside its declared UI.");
|
|
44
45
|
}
|
|
46
|
+
/** Pure mapping after finalization; the presentation host separately checks current access. */
|
|
47
|
+
export function resolveAssistantPresentation(provider, result, options) {
|
|
48
|
+
const expected = options.expectedProvider;
|
|
49
|
+
if (!provider.installationId)
|
|
50
|
+
throw new AssistantError("unsupported_core", "Update Core to identify assistant installations.", 409);
|
|
51
|
+
if (provider.appId !== expected.appId || provider.key !== expected.key || provider.installationId !== expected.installationId)
|
|
52
|
+
throw new AssistantError("provider_changed", "The original assistant installation is no longer available.", 409);
|
|
53
|
+
try {
|
|
54
|
+
resolveAssistantDestination(result.open, provider.uiSurfaces ?? []);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new AssistantError("destination_invalid", "The assistant returned an undeclared destination.", 409);
|
|
58
|
+
}
|
|
59
|
+
const destination = { appId: provider.appId, installationId: provider.installationId,
|
|
60
|
+
endpoint: result.open.endpoint, route: result.open.path };
|
|
61
|
+
const panels = (provider.uiSurfaces ?? []).filter(s => s.kind === "panel" && s.panelId && s.url
|
|
62
|
+
&& s.endpoint === destination.endpoint && routeWithin(destination.route, s.path));
|
|
63
|
+
let selected;
|
|
64
|
+
if (result.open.panelId !== undefined) {
|
|
65
|
+
selected = panels.find(s => s.panelId === result.open.panelId);
|
|
66
|
+
if (!selected)
|
|
67
|
+
throw new AssistantError("destination_invalid", "The assistant panel does not match its destination.", 409);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (!panels.length)
|
|
71
|
+
return { target: null, destination, reason: "no_panel" };
|
|
72
|
+
const length = (path) => new URL(path, "https://surface.invalid").pathname.replace(/\/$/, "").length;
|
|
73
|
+
const longest = Math.max(...panels.map(s => length(s.path)));
|
|
74
|
+
const matches = panels.filter(s => length(s.path) === longest);
|
|
75
|
+
if (new Set(matches.map(s => s.panelId)).size !== 1)
|
|
76
|
+
return { target: null, destination, reason: "ambiguous_surface" };
|
|
77
|
+
selected = matches[0];
|
|
78
|
+
}
|
|
79
|
+
return { target: { panel: { appId: provider.appId, installationId: provider.installationId, panelId: selected.panelId },
|
|
80
|
+
route: destination.route }, destination };
|
|
81
|
+
}
|
|
45
82
|
export class AssistantClient {
|
|
46
83
|
baseUrl;
|
|
47
84
|
contract;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
/** An explicit Core delegation reference. Keep this client and its credentials on the server. */
|
|
3
|
+
export declare class BackgroundGrantClient {
|
|
4
|
+
private readonly grantId;
|
|
5
|
+
private readonly scopeId;
|
|
6
|
+
private credential?;
|
|
7
|
+
private pending?;
|
|
8
|
+
private readonly origin;
|
|
9
|
+
private readonly appId;
|
|
10
|
+
private readonly service;
|
|
11
|
+
constructor(grantId: string, scopeId: string, options?: ProviderServerOptions);
|
|
12
|
+
/** Coalesces issuance; each protected dispatch still validates the delegation online in Core. */
|
|
13
|
+
token(): Promise<string>;
|
|
14
|
+
/** Sends one operation. No automatic replay of a possibly completed mutation. */
|
|
15
|
+
core(path: string, init?: RequestInit): Promise<Response>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ProviderError } from "@hosty-sdk/app/providers";
|
|
2
|
+
/** An explicit Core delegation reference. Keep this client and its credentials on the server. */
|
|
3
|
+
export class BackgroundGrantClient {
|
|
4
|
+
grantId;
|
|
5
|
+
scopeId;
|
|
6
|
+
credential;
|
|
7
|
+
pending;
|
|
8
|
+
origin;
|
|
9
|
+
appId;
|
|
10
|
+
service;
|
|
11
|
+
constructor(grantId, scopeId, options = {}) {
|
|
12
|
+
this.grantId = grantId;
|
|
13
|
+
this.scopeId = scopeId;
|
|
14
|
+
this.origin = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
|
|
15
|
+
this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
|
|
16
|
+
this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
|
|
17
|
+
if (!this.appId || !this.service || !grantId || !scopeId)
|
|
18
|
+
throw new Error("An app service identity and explicit background grant/scope references are required.");
|
|
19
|
+
}
|
|
20
|
+
/** Coalesces issuance; each protected dispatch still validates the delegation online in Core. */
|
|
21
|
+
token() {
|
|
22
|
+
if (this.credential && Date.parse(this.credential.expiresAt) > Date.now() + 30_000)
|
|
23
|
+
return Promise.resolve(this.credential.accessToken);
|
|
24
|
+
if (this.pending)
|
|
25
|
+
return this.pending;
|
|
26
|
+
this.pending = (async () => {
|
|
27
|
+
const response = await fetch(new URL(`/api/internal/apps/${encodeURIComponent(this.appId)}/background/token`, this.origin), {
|
|
28
|
+
method: "POST", redirect: "error", cache: "no-store", signal: AbortSignal.timeout(10_000),
|
|
29
|
+
headers: { authorization: `Bearer ${this.service}`, "content-type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ grantId: this.grantId, scopeId: this.scopeId }),
|
|
31
|
+
});
|
|
32
|
+
const body = await response.json();
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new ProviderError(body.code ?? "background_denied", body.message ?? "Background access is unavailable.", response.status);
|
|
35
|
+
if (typeof body.accessToken !== "string" || !Number.isFinite(Date.parse(body.expiresAt)))
|
|
36
|
+
throw new Error("Invalid background credential response.");
|
|
37
|
+
this.credential = body;
|
|
38
|
+
return body.accessToken;
|
|
39
|
+
})().finally(() => { this.pending = undefined; });
|
|
40
|
+
return this.pending;
|
|
41
|
+
}
|
|
42
|
+
/** Sends one operation. No automatic replay of a possibly completed mutation. */
|
|
43
|
+
async core(path, init = {}) {
|
|
44
|
+
const url = new URL(path, this.origin);
|
|
45
|
+
if (url.origin !== this.origin.origin || !url.pathname.startsWith("/api/apps/") || url.username || url.password)
|
|
46
|
+
throw new Error("Use a Core app operation path.");
|
|
47
|
+
const headers = new Headers(init.headers);
|
|
48
|
+
headers.set("authorization", `Bearer ${this.service}`);
|
|
49
|
+
headers.set("X-Hosty-App-Identity", await this.token());
|
|
50
|
+
return fetch(url, { ...init, headers, redirect: "error", cache: "no-store" });
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/browser-auth.d.ts
CHANGED
package/dist/browser-auth.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ensureAppContinuity } from "@hosty-sdk/app/continuity/browser";
|
|
2
|
+
export { APP_CONTINUITY_UPDATED } from "@hosty-sdk/app/continuity/browser";
|
|
3
|
+
export { readAppContinuity } from "@hosty-sdk/app/continuity";
|
|
1
4
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
5
|
function isValidCodeVerifier(value) {
|
|
3
6
|
return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value);
|
|
@@ -270,6 +273,8 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
270
273
|
// SDK identity/protocol probes use recover=false so that validation itself can proceed.
|
|
271
274
|
if (recover && renewal && activityConfig?.validateSession && !await waitForActivity(init.signal))
|
|
272
275
|
return Response.json({ code: "reauth_required", message: "Hosty access has not been restored." }, { status: 401 });
|
|
276
|
+
if (recover && !await ensureAppContinuity())
|
|
277
|
+
return Response.json({ code: "session_unavailable", message: "Hosty access could not be verified." }, { status: 503 });
|
|
273
278
|
const current = grant?.origin === url.origin ? grant : null;
|
|
274
279
|
const headers = new Headers(init.headers);
|
|
275
280
|
if (current)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AppContinuity } from "./continuity.js";
|
|
2
|
+
export declare const APP_CONTINUITY_UPDATED = "hosty:app-continuity-updated";
|
|
3
|
+
type Owner = {
|
|
4
|
+
coreOrigin: string;
|
|
5
|
+
authority: AppContinuity;
|
|
6
|
+
probe: () => Promise<boolean>;
|
|
7
|
+
};
|
|
8
|
+
/** One coordinator per app document; probes bypass appFetch's maintenance to avoid recursion. */
|
|
9
|
+
export declare function ensureAppContinuity(force?: boolean): Promise<boolean>;
|
|
10
|
+
export declare function configureAppContinuity(config: Owner): () => void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const APP_CONTINUITY_UPDATED = "hosty:app-continuity-updated";
|
|
2
|
+
let owner = null;
|
|
3
|
+
let pending = null;
|
|
4
|
+
let checkedAt = 0;
|
|
5
|
+
let valid = true;
|
|
6
|
+
/** One coordinator per app document; probes bypass appFetch's maintenance to avoid recursion. */
|
|
7
|
+
export async function ensureAppContinuity(force = false) {
|
|
8
|
+
if (!owner)
|
|
9
|
+
return true;
|
|
10
|
+
if (pending)
|
|
11
|
+
return pending;
|
|
12
|
+
if (!force && Date.now() - checkedAt < Math.min(30_000, owner.authority.checkAfterSeconds * 1000))
|
|
13
|
+
return valid;
|
|
14
|
+
const current = owner;
|
|
15
|
+
pending = (async () => {
|
|
16
|
+
// Browser policy may refuse the frame. Only the app's authoritative probe decides access.
|
|
17
|
+
if (current.authority.mode === "browser")
|
|
18
|
+
await maintainParent(current);
|
|
19
|
+
const result = await current.probe().catch(() => false);
|
|
20
|
+
if (owner !== current)
|
|
21
|
+
return false;
|
|
22
|
+
valid = result;
|
|
23
|
+
checkedAt = Date.now();
|
|
24
|
+
if (result)
|
|
25
|
+
window.dispatchEvent(new Event(APP_CONTINUITY_UPDATED));
|
|
26
|
+
return result;
|
|
27
|
+
})().finally(() => { pending = null; });
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
export function configureAppContinuity(config) {
|
|
31
|
+
owner = config;
|
|
32
|
+
checkedAt = 0;
|
|
33
|
+
valid = true;
|
|
34
|
+
let disposed = false;
|
|
35
|
+
let failures = 0;
|
|
36
|
+
let scheduleVersion = 0;
|
|
37
|
+
let timer;
|
|
38
|
+
const schedule = async (force = false) => {
|
|
39
|
+
const version = ++scheduleVersion;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
const ok = await ensureAppContinuity(force);
|
|
42
|
+
// Events can share one pending probe; only the latest waiter installs its next timer.
|
|
43
|
+
if (disposed || version !== scheduleVersion)
|
|
44
|
+
return;
|
|
45
|
+
failures = ok ? 0 : Math.min(4, failures + 1);
|
|
46
|
+
const seconds = ok ? config.authority.checkAfterSeconds : Math.min(300, 15 * 2 ** failures);
|
|
47
|
+
timer = setTimeout(() => { void schedule(true); }, seconds * 1000 * (0.9 + Math.random() * 0.2));
|
|
48
|
+
};
|
|
49
|
+
const wake = () => { void schedule(); };
|
|
50
|
+
const online = () => { void schedule(true); };
|
|
51
|
+
window.addEventListener("focus", wake);
|
|
52
|
+
window.addEventListener("online", online);
|
|
53
|
+
document.addEventListener("visibilitychange", wake);
|
|
54
|
+
void schedule(true);
|
|
55
|
+
return () => {
|
|
56
|
+
disposed = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
window.removeEventListener("focus", wake);
|
|
59
|
+
window.removeEventListener("online", online);
|
|
60
|
+
document.removeEventListener("visibilitychange", wake);
|
|
61
|
+
if (owner === config) {
|
|
62
|
+
owner = null;
|
|
63
|
+
checkedAt = 0;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function maintainParent(config) {
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
|
|
70
|
+
const url = new URL("/api/auth/apps/continuity/frame", config.coreOrigin);
|
|
71
|
+
url.searchParams.set("grantId", config.authority.grantId);
|
|
72
|
+
url.searchParams.set("origin", window.location.origin);
|
|
73
|
+
url.searchParams.set("state", state);
|
|
74
|
+
const frame = document.createElement("iframe");
|
|
75
|
+
frame.hidden = true;
|
|
76
|
+
frame.title = "Hosty session maintenance";
|
|
77
|
+
frame.referrerPolicy = "no-referrer";
|
|
78
|
+
const done = () => { clearTimeout(timer); window.removeEventListener("message", received); frame.remove(); resolve(); };
|
|
79
|
+
const received = (event) => {
|
|
80
|
+
if (event.source === frame.contentWindow && event.origin === url.origin &&
|
|
81
|
+
event.data?.type === "hosty:continuity-result" && event.data.state === state &&
|
|
82
|
+
["maintained", "unavailable"].includes(event.data.status))
|
|
83
|
+
done();
|
|
84
|
+
};
|
|
85
|
+
const timer = setTimeout(done, 4_000);
|
|
86
|
+
window.addEventListener("message", received);
|
|
87
|
+
frame.onload = () => frame.contentWindow?.postMessage({ type: "hosty:continuity-check", state }, url.origin);
|
|
88
|
+
frame.onerror = done;
|
|
89
|
+
frame.src = url.href;
|
|
90
|
+
document.body.append(frame);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Server-advertised authority, never inferred from embedding or a missing activity deadline. */
|
|
2
|
+
export type AppContinuity = {
|
|
3
|
+
version: 1;
|
|
4
|
+
mode: "browser" | "native";
|
|
5
|
+
grantId: string;
|
|
6
|
+
expiresAt: string;
|
|
7
|
+
checkAfterSeconds: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function readAppContinuity(value: unknown): AppContinuity | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function readAppContinuity(value) {
|
|
2
|
+
const v = value;
|
|
3
|
+
if (v?.version !== 1 || !["browser", "native"].includes(v.mode ?? "") ||
|
|
4
|
+
typeof v.grantId !== "string" || !v.grantId || v.grantId.length > 128 ||
|
|
5
|
+
typeof v.expiresAt !== "string" || !Number.isFinite(Date.parse(v.expiresAt)) ||
|
|
6
|
+
typeof v.checkAfterSeconds !== "number" || !Number.isFinite(v.checkAfterSeconds) || v.checkAfterSeconds <= 0)
|
|
7
|
+
return;
|
|
8
|
+
return { ...v, checkAfterSeconds: Math.min(300, v.checkAfterSeconds) };
|
|
9
|
+
}
|
package/dist/overlay-react.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
/** Mount once around protected content. Presentation and recovery belong to Hosty. */
|
|
3
|
-
export declare function HostyOverlay({ children, probePath, appCodePath }: {
|
|
3
|
+
export declare function HostyOverlay({ children, probePath, appCodePath, uiPath, panelLayout, localContent }: {
|
|
4
|
+
/** Explicitly public/local content available during an outage; never put protected actor data here. */
|
|
5
|
+
localContent?: ReactNode;
|
|
4
6
|
children: ReactNode;
|
|
5
7
|
probePath?: string;
|
|
6
8
|
appCodePath?: string;
|
|
9
|
+
uiPath?: string;
|
|
10
|
+
panelLayout?: "auto" | "slot" | "none";
|
|
7
11
|
}): import("react").JSX.Element;
|
package/dist/overlay-react.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { useAppIdentity } from "./react.js";
|
|
@@ -7,6 +7,7 @@ import { useAppActivity } from "./activity-react.js";
|
|
|
7
7
|
import { APP_SESSION_ENDED, APP_SESSION_RESTORED, APP_ACTIVITY_RENEWED, acceptAppAuthProtocol } from "./browser-auth.js";
|
|
8
8
|
import { buildCoreOpenUrl, readRecoveryParams } from "./index.js";
|
|
9
9
|
import { permissionReviewUrl, requestPermissionReview } from "./permissions.js";
|
|
10
|
+
import { HostyUiRoot } from "./ui-react.js";
|
|
10
11
|
function snapshot(body) {
|
|
11
12
|
const value = body;
|
|
12
13
|
if (value?.status !== "active" || typeof value.userId !== "string" || !value.userId)
|
|
@@ -29,7 +30,7 @@ function sessionReady(session) {
|
|
|
29
30
|
return session?.setup === "ready" && (!session.activityRequired || session.recoveryReady) && !activityExpired(session);
|
|
30
31
|
}
|
|
31
32
|
/** 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
|
+
export function HostyOverlay({ children, probePath = "/api/hosty/session", appCodePath = "/api/auth/app-code", uiPath, panelLayout = "auto", localContent }) {
|
|
33
34
|
const actor = useRef(null);
|
|
34
35
|
const changingActor = useRef(false);
|
|
35
36
|
const [session, setSession] = useState(null);
|
|
@@ -79,7 +80,7 @@ export function HostyOverlay({ children, probePath = "/api/hosty/session", appCo
|
|
|
79
80
|
setReviewing(false);
|
|
80
81
|
}, [session?.setup]);
|
|
81
82
|
useEffect(() => {
|
|
82
|
-
//
|
|
83
|
+
// Continuous sessions own their maintenance; this also observes pending permission reviews.
|
|
83
84
|
let loading = false;
|
|
84
85
|
const refresh = () => {
|
|
85
86
|
if (loading || !actor.current || activity.pending || (activity.needed && !renewedAwaitingReadiness.current))
|
|
@@ -165,7 +166,9 @@ export function HostyOverlay({ children, probePath = "/api/hosty/session", appCo
|
|
|
165
166
|
action = activity.renew;
|
|
166
167
|
actionLabel = "Renew access";
|
|
167
168
|
}
|
|
168
|
-
|
|
169
|
+
const localOffline = (identity.ui.kind === "unavailable" || (identity.ui.kind === "active" && session?.setup === "unavailable"))
|
|
170
|
+
&& localContent !== undefined;
|
|
171
|
+
return _jsxs(_Fragment, { children: [localOffline && _jsxs("div", { "data-hosty-local-content": true, children: [_jsx("p", { role: "status", children: "Hosty is unavailable. Local content remains available." }), localContent] }), _jsx("div", { "data-hosty-content": true, hidden: !ready, inert: !ready, style: ready ? { display: "contents" } : { display: "none" }, children: _jsx(HostyUiRoot, { uiPath: uiPath, panelLayout: panelLayout, active: ready, children: mounted.current ? children : null }) }), !ready && !localOffline && _jsx(OverlaySurface, { pending: pending, title: title, message: message, action: action, actionLabel: actionLabel, reviewUrl: reviewUrl })] });
|
|
169
172
|
}
|
|
170
173
|
function OverlaySurface({ pending, title, message, action, actionLabel, reviewUrl }) {
|
|
171
174
|
const [portal, setPortal] = useState(null);
|
|
@@ -24,7 +24,9 @@ export declare class ProviderClient {
|
|
|
24
24
|
signal?: AbortSignal;
|
|
25
25
|
}): Promise<SpeechResult>;
|
|
26
26
|
/** User-attributed requests preserve the assistant's existing handoff and dispatch policy. */
|
|
27
|
-
assistant(provider: Pick<ProviderDescriptor, "appId" | "key"
|
|
27
|
+
assistant(provider: Pick<ProviderDescriptor, "appId" | "key"> & {
|
|
28
|
+
installationId?: string | null;
|
|
29
|
+
}, userToken: () => Promise<string>): Promise<AssistantClient>;
|
|
28
30
|
/** Provider-side validation checks live grants on every request; never cache this result. */
|
|
29
31
|
validate(token: string, kind: ProviderKind, key?: string, signal?: AbortSignal): Promise<ProviderInvocation>;
|
|
30
32
|
}
|
package/dist/providers-server.js
CHANGED
|
@@ -31,6 +31,8 @@ export class ProviderClient {
|
|
|
31
31
|
const issued = await this.coreRequest(`/providers/${provider.kind}/token`, { providerAppId: provider.appId, key: provider.key }, signal, userToken);
|
|
32
32
|
if (issued.provider.version !== 1 || !issued.provider.url)
|
|
33
33
|
throw new ProviderError("provider_incompatible", "This provider does not support interface version 1.", 409);
|
|
34
|
+
if (provider.installationId && issued.provider.installationId !== provider.installationId)
|
|
35
|
+
throw new ProviderError("provider_changed", "The selected assistant installation changed. Start a new discussion explicitly.", 409);
|
|
34
36
|
const url = new URL(issued.provider.url);
|
|
35
37
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
36
38
|
throw new ProviderError("provider_url_invalid", "Invalid provider endpoint.", 409);
|
|
@@ -58,8 +60,8 @@ export class ProviderClient {
|
|
|
58
60
|
const binding = await this.connect(target, undefined, await userToken());
|
|
59
61
|
return new AssistantClient(binding.url, { version: binding.provider.version, capabilities: binding.provider.capabilities }, async () => {
|
|
60
62
|
const fresh = await this.connect(target, undefined, await userToken());
|
|
61
|
-
if (fresh.url !== binding.url)
|
|
62
|
-
throw new ProviderError("provider_changed", "The assistant endpoint changed. Reconnect explicitly.", 409);
|
|
63
|
+
if (fresh.url !== binding.url || fresh.provider.installationId !== binding.provider.installationId)
|
|
64
|
+
throw new ProviderError("provider_changed", "The assistant endpoint or installation changed. Reconnect explicitly.", 409);
|
|
63
65
|
return { token: fresh.token };
|
|
64
66
|
});
|
|
65
67
|
}
|
package/dist/providers.d.ts
CHANGED
|
@@ -14,7 +14,11 @@ export type ProviderDescriptor = {
|
|
|
14
14
|
endpoint: string | null;
|
|
15
15
|
path: string;
|
|
16
16
|
url: string | null;
|
|
17
|
+
kind?: "panel" | "navigation" | "entrypoint";
|
|
18
|
+
panelId?: string | null;
|
|
17
19
|
}> | null;
|
|
20
|
+
/** Opaque Core installation identity; compare for equality, never parse. */
|
|
21
|
+
installationId?: string | null;
|
|
18
22
|
};
|
|
19
23
|
export type AppPermissionState = {
|
|
20
24
|
required: string[];
|
package/dist/react.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AppContinuity } from "./continuity.js";
|
|
1
2
|
import type { AppAuthProtocol } from "./app-code.js";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
import { type AppLaunchMode, type AppSessionStatus } from "./index.js";
|
|
@@ -71,6 +72,7 @@ export declare function useAppIdentity({ probePath, appCodePath, onSession, }?:
|
|
|
71
72
|
appAuthProtocol: AppAuthProtocol;
|
|
72
73
|
activeUntil?: string | null;
|
|
73
74
|
activityRequired?: boolean;
|
|
75
|
+
continuity?: AppContinuity;
|
|
74
76
|
} | null;
|
|
75
77
|
hasBeenActive: boolean;
|
|
76
78
|
refresh: () => Promise<unknown>;
|
package/dist/react.js
CHANGED
|
@@ -3,6 +3,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
// React slice: the client identity bridge — probe, silent recovery, and the fallback
|
|
4
4
|
// cards. Prop-less by design: the app id and browser-reachable Core origin arrive in the
|
|
5
5
|
// probe response's `recovery` field (request-time values, never build-time props).
|
|
6
|
+
import { readAppContinuity } from "./continuity.js";
|
|
7
|
+
import { configureAppContinuity } from "./continuity-browser.js";
|
|
6
8
|
import { AppActivityBridge } from "./activity-react.js";
|
|
7
9
|
import { AuthNotice, AuthNoticeButton, authNoticeActionStyle } from "./auth-notice.js";
|
|
8
10
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -203,6 +205,18 @@ export function useAppIdentity({ probePath = "/api/auth/identity", appCodePath =
|
|
|
203
205
|
const exchangeRef = useRef(null);
|
|
204
206
|
const onSessionRef = useRef(onSession);
|
|
205
207
|
useEffect(() => { onSessionRef.current = onSession; }, [onSession]);
|
|
208
|
+
const continuity = activity?.continuity;
|
|
209
|
+
const continuityActor = session?.userId;
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!continuity || !activity?.openUrl || !continuityActor)
|
|
212
|
+
return;
|
|
213
|
+
return configureAppContinuity({ coreOrigin: new URL(activity.openUrl).origin, authority: continuity,
|
|
214
|
+
probe: async () => {
|
|
215
|
+
const next = await refreshRef.current();
|
|
216
|
+
return next?.status === "active" && next.userId === continuityActor
|
|
217
|
+
&& (!next.hosty || (next.hosty.version === 1 && next.hosty.setup === "ready"));
|
|
218
|
+
} });
|
|
219
|
+
}, [continuity?.grantId, activity?.openUrl, continuityActor]);
|
|
206
220
|
useEffect(() => {
|
|
207
221
|
let cancelled = false;
|
|
208
222
|
let probeSequence = 0;
|
|
@@ -263,7 +277,7 @@ export function useAppIdentity({ probePath = "/api/auth/identity", appCodePath =
|
|
|
263
277
|
forgetRejectedAppGrant(rejectionCode, revision);
|
|
264
278
|
if (openUrl && protocol)
|
|
265
279
|
setActivity({ openUrl, appAuthProtocol: protocol,
|
|
266
|
-
activeUntil: activityMetadata?.activeUntil, activityRequired: activityMetadata?.activityRequired });
|
|
280
|
+
activeUntil: activityMetadata?.activeUntil, activityRequired: activityMetadata?.activityRequired, continuity: readAppContinuity(activityMetadata?.continuity) });
|
|
267
281
|
const initialLoad = firstProbe.current;
|
|
268
282
|
firstProbe.current = false;
|
|
269
283
|
if (silentError.current === "access_denied") {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
+
import { type AppContinuity } from "./continuity.js";
|
|
2
3
|
import { type AppAuthProtocol } from "./app-code.js";
|
|
3
4
|
export { isValidCodeVerifier } from "./app-code.js";
|
|
4
5
|
import { type AppSessionFailureStatus, type AppSessionStatus, type SessionRecoveryParams } from "./index.js";
|
|
@@ -25,6 +26,7 @@ export interface HostIdentity {
|
|
|
25
26
|
expiresAt: string | null;
|
|
26
27
|
activeUntil?: string | null;
|
|
27
28
|
activityRequired?: boolean;
|
|
29
|
+
continuity?: AppContinuity;
|
|
28
30
|
}
|
|
29
31
|
/** Structured detail for a failed resolution: the Core HTTP status (null when Core was
|
|
30
32
|
* never reached) plus a machine-readable code and human-readable message. Codes pass
|
|
@@ -47,6 +49,11 @@ export declare function getAppId(config: HostyAppConfig): string;
|
|
|
47
49
|
export declare function createHostySessionRouteHandler(config: HostyAppConfig, options?: {
|
|
48
50
|
administratorOnly?: boolean;
|
|
49
51
|
}): (request: Request) => Promise<Response>;
|
|
52
|
+
/** Mount GET/POST at /api/hosty/ui/[...action] for the shared panel protocol. */
|
|
53
|
+
export declare function createHostyUiRouteHandler(config: HostyAppConfig, options?: {
|
|
54
|
+
administratorOnly?: boolean;
|
|
55
|
+
uiPath?: string;
|
|
56
|
+
}): (request: Request) => Promise<Response>;
|
|
50
57
|
export declare function getCoreOrigin(): string | null;
|
|
51
58
|
/** Browser-reachable Core origin. Core always injects it (falls back to its loopback
|
|
52
59
|
* listen URL on localhost-only installs), so a missing value is a broken environment. */
|