@hosty-sdk/app 0.25.12 → 0.27.22
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 +181 -19
- 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/index.d.ts +9 -6
- package/dist/index.js +10 -1
- package/dist/next.d.ts +4 -0
- package/dist/next.js +29 -0
- package/dist/overlay-react.d.ts +9 -1
- package/dist/overlay-react.js +6 -4
- package/dist/react.d.ts +3 -0
- package/dist/react.js +18 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +2 -0
- package/dist/session-server.d.ts +2 -0
- package/dist/ui-composition.d.ts +71 -0
- package/dist/ui-composition.js +232 -0
- package/dist/ui-context.d.ts +2 -0
- package/dist/ui-context.js +1 -0
- package/dist/ui-embedded.d.ts +18 -1
- package/dist/ui-embedded.js +123 -17
- package/dist/ui-frame-layer.d.ts +36 -0
- package/dist/ui-frame-layer.js +88 -0
- package/dist/ui-frame.d.ts +2 -0
- package/dist/ui-frame.js +43 -7
- package/dist/ui-host.d.ts +62 -15
- package/dist/ui-host.js +666 -76
- package/dist/ui-navigation-react.d.ts +17 -0
- package/dist/ui-navigation-react.js +89 -0
- package/dist/ui-react.d.ts +13 -3
- package/dist/ui-react.js +127 -50
- package/dist/ui-server.d.ts +3 -1
- package/dist/ui-server.js +14 -2
- package/dist/ui-transport.d.ts +2 -2
- package/dist/ui-transport.js +1 -1
- package/dist/ui-types.d.ts +88 -0
- package/dist/ui-types.js +1 -0
- package/dist/ui-workspaces.d.ts +54 -0
- package/dist/ui-workspaces.js +3 -0
- package/dist/ui.d.ts +7 -1
- package/dist/ui.js +22 -0
- package/package.json +17 -1
package/README.md
CHANGED
|
@@ -11,6 +11,9 @@ npm install @hosty-sdk/app
|
|
|
11
11
|
| --- | --- | --- |
|
|
12
12
|
| `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` and `hosty:request-delegated-token` schemas, URL/env helpers |
|
|
13
13
|
| `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
|
|
14
|
+
| `@hosty-sdk/app/next` | Next client | `HostyNextOverlay`, `HostyNextNavigationBridge` for App Router navigation |
|
|
15
|
+
| `@hosty-sdk/app/ui` | client | policy/navigation snapshots, replayable navigation, chooser and presentation operations |
|
|
16
|
+
| `@hosty-sdk/app/ui/react` | React client | navigation boundaries/hooks, app frames and retained panel workspaces |
|
|
14
17
|
| `@hosty-sdk/app/react` | client | `<HostyOverlay />` — shared loading, identity, required setup and recovery UI; legacy bridges remain available |
|
|
15
18
|
| `@hosty-sdk/app/session/server` | server | Framework-neutral session response adapter for authenticated Node servers |
|
|
16
19
|
| `@hosty-sdk/app/embedder` | client | theme sender and legacy embedder message parsers; Shell no longer mints app credentials |
|
|
@@ -23,8 +26,8 @@ Minimal Next.js wiring (the configuration object stays server-side):
|
|
|
23
26
|
|
|
24
27
|
```tsx
|
|
25
28
|
// app/layout.tsx — inside the body and the existing theme/launch providers
|
|
26
|
-
import {
|
|
27
|
-
// <
|
|
29
|
+
import { HostyNextOverlay } from "@hosty-sdk/app/next";
|
|
30
|
+
// <HostyNextOverlay>{children}</HostyNextOverlay>
|
|
28
31
|
|
|
29
32
|
// lib/hosty.ts
|
|
30
33
|
export const hostyConfig = {
|
|
@@ -48,8 +51,9 @@ export const POST = createAppCodeRouteHandler(hostyConfig);
|
|
|
48
51
|
|
|
49
52
|
Replace the protected scope's `AppIdentityBridge`, `AppActivityBridge`, custom sign-in rendering
|
|
50
53
|
and `MissingPermissionsNotice` with one `HostyOverlay`. Keep public routes outside that scope.
|
|
51
|
-
Existing launch/theme bridges
|
|
52
|
-
|
|
54
|
+
Existing launch/theme bridges stay in place for transport compatibility; add the composition
|
|
55
|
+
bootstrap below. `probePath` and `appCodePath` support existing route names. `chrome`, `navigation`
|
|
56
|
+
and `panelLayout` configure UI composition without replacing authentication/recovery rendering.
|
|
53
57
|
API routes and server-rendered data still enforce their own authorization. The overlay delays
|
|
54
58
|
protected client mounting; it is not a server data-access boundary.
|
|
55
59
|
|
|
@@ -69,8 +73,8 @@ After first mount, blocked content and app portals become hidden and inert while
|
|
|
69
73
|
remains mounted. Same-user renewal restores it. A different stable user ID reloads the app document
|
|
70
74
|
and refuses to replay old pending requests. `APP_SESSION_RESTORED` from `/browser-auth` is an optional
|
|
71
75
|
DOM notification with `detail.userId`; it contains no credential. There is no generic draft persistence
|
|
72
|
-
across reloads or cache-clearing framework.
|
|
73
|
-
|
|
76
|
+
across reloads or cache-clearing framework. Current user/app permissions and the authorizing principal's idle/absolute lifetime still apply.
|
|
77
|
+
Core 0.130.0 advertises continuous app sessions; older servers retain their actual activity policy.
|
|
74
78
|
|
|
75
79
|
Compatible SDK updates change standard presentation and recovery after a dependency update and
|
|
76
80
|
rebuild, without editing this wiring. New mandatory Core capabilities can require upgrading Core.
|
|
@@ -289,24 +293,48 @@ reads or `mcp:invoke` for assistant mutations, then apply your application's use
|
|
|
289
293
|
Do not cache validation. Keep `introspectScopedToken` on ordinary APIs: it does not opt into accepting
|
|
290
294
|
MCP-only credentials. Neither token permits skipping the application's own authorization.
|
|
291
295
|
|
|
292
|
-
##
|
|
296
|
+
## Continuous app access (Core 0.130.0 / SDK 0.26.0)
|
|
293
297
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
or reload to renew access. `appActivityNeedsRenewal()` supports proactive click renewal near expiry.
|
|
300
|
-
See [the activity contract](../../docs/features/app-activity-window/feature.md), including the
|
|
301
|
-
separate Core-approved assistant session leases.
|
|
298
|
+
Pass `continuity`, `activeUntil` and `activityRequired` from Core revalidation through custom identity
|
|
299
|
+
probes. Standard SDK adapters do this automatically. `HostyOverlay` / `useAppIdentity` maintain a
|
|
300
|
+
live browser parent through a Core-owned frame and refresh the app-local probe. Checks coalesce,
|
|
301
|
+
run on mount/return/network recovery, and repeat at a jittered five-minute interval. App backends
|
|
302
|
+
cannot keep the browser parent alive with app credentials alone.
|
|
302
303
|
|
|
303
|
-
|
|
304
|
+
Keep protected client requests on `appFetch`. An outage returns 503 without clearing identity or
|
|
305
|
+
replaying a mutation. Real expiry uses normal recovery, preserving same-user state. Optional
|
|
306
|
+
`<HostyOverlay localContent={<LocalNotes />}>…</HostyOverlay>` places explicitly local/public
|
|
307
|
+
content outside the protected root during an outage; do not place private actor-bound data there.
|
|
308
|
+
|
|
309
|
+
For another framework, use `configureAppContinuity` / `ensureAppContinuity` from
|
|
310
|
+
`@hosty-sdk/app/continuity/browser`, with validated metadata from `/continuity` and a probe that
|
|
311
|
+
checks the same actor. The probe must bypass maintenance (`appFetch(path, init, false)`) to avoid
|
|
312
|
+
recursion. Clean up the coordinator on unmount. `APP_CONTINUITY_UPDATED` reports silent success;
|
|
313
|
+
`APP_ACTIVITY_RENEWED` continues to mean explicit recovery. Do not replay handoffs/messages on either.
|
|
314
|
+
Missing continuity metadata keeps older Core's `configureAppActivity`/click renewal behavior.
|
|
315
|
+
|
|
316
|
+
Server-only unattended work requires an explicit Core grant:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { BackgroundGrantClient } from "@hosty-sdk/app/background/server";
|
|
320
|
+
const background = new BackgroundGrantClient(grantId, "health"); // HOSTY_* service configuration
|
|
321
|
+
const response = await background.core("/api/apps/example.target/health");
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The client coalesces credential issuance and refreshes before expiry. It sends each operation once;
|
|
325
|
+
Core enforces the immutable selected scope online. `token()` can supply the existing provider/MCP
|
|
326
|
+
client's user-token input. Keep tokens server-side. See
|
|
327
|
+
[Continuous App Access](../../docs/features/continuous-app-access/feature.md) for creation, logout,
|
|
328
|
+
revocation, exact MCP tools and installation boundaries. Background grants do not create a scheduler.
|
|
329
|
+
|
|
330
|
+
## Shared panels (Core 0.131.0 / SDK 0.27.0)
|
|
304
331
|
|
|
305
332
|
`HostyOverlay` composes one UI owner per top-level browser document. Its default
|
|
306
333
|
`panelLayout="auto"` adds an opened-tool rail, an **Add tool** chooser and a resizable
|
|
307
334
|
workspace. In a directly embedded app it delegates presentation to the verified parent
|
|
308
|
-
and renders no local rail. Use `panelLayout="slot"` with
|
|
309
|
-
|
|
335
|
+
and renders no local rail. Use `panelLayout="slot"` with a `HostyPanelWorkspace` per workspace
|
|
336
|
+
ID inside your chrome, or `"none"` with an explicitly registered custom renderer. Without a renderer,
|
|
337
|
+
disable local tools; an opening otherwise fails with `presentation_unavailable`.
|
|
310
338
|
Panel hosting requires reviewed optional `apps.panels.read` (or existing `apps.read`);
|
|
311
339
|
participating as a child does not require catalog access.
|
|
312
340
|
|
|
@@ -320,7 +348,7 @@ export const POST = handler;
|
|
|
320
348
|
|
|
321
349
|
Non-Next servers use `createHostyUiResponse` from `@hosty-sdk/app/ui/server` with their
|
|
322
350
|
revalidated own app-user session/token. `UiServerClient` provides only `bootstrap`,
|
|
323
|
-
`listPanels` and `
|
|
351
|
+
`listPanels`, `resolvePanel`, `navigation` and `resolveNavigation`; never expose a generic Core proxy or caller-supplied credentials.
|
|
324
352
|
Both server adapters return `no-store` metadata. The React root's `uiPath` defaults to
|
|
325
353
|
`/api/hosty/ui` and must be a same-origin path.
|
|
326
354
|
|
|
@@ -371,3 +399,137 @@ peer metadata. Matching opaque Core/user contexts prevents accidental account mi
|
|
|
371
399
|
between cooperating apps; it is not attestation of app code or delegated API authority.
|
|
372
400
|
Every target API still authorizes its own session. Older surfaces remain `unreported`.
|
|
373
401
|
See [the panel contract and verification](../../docs/features/sdk-panel-system/feature.md).
|
|
402
|
+
|
|
403
|
+
## Navigation and UI composition
|
|
404
|
+
|
|
405
|
+
Supply identical root defaults to the before-paint bootstrap and overlay. Omitted defaults show
|
|
406
|
+
navigation and enable the standalone tool rail/chooser. Hints and verified host policy can override
|
|
407
|
+
each leaf independently; neither framing nor copied launch-mode storage hides the new navigation.
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
// app/layout.tsx
|
|
411
|
+
import { chromeBootstrapScript } from "@hosty-sdk/app";
|
|
412
|
+
import { HostyNextOverlay } from "@hosty-sdk/app/next";
|
|
413
|
+
|
|
414
|
+
const chrome = { navigation: "show" as const, tools: { enabled: true, launcher: "show" as const } };
|
|
415
|
+
// In <head>, before hydration and the legacy launch bridge:
|
|
416
|
+
// <script dangerouslySetInnerHTML={{ __html: chromeBootstrapScript(chrome) }} />
|
|
417
|
+
// In <body>, around protected app content:
|
|
418
|
+
// <HostyNextOverlay chrome={chrome}>{children}</HostyNextOverlay>
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
For React without Next, use `HostyOverlay` and optionally pass
|
|
422
|
+
`navigation={{ renderer: "standard", router }}`. A router implements `getRoute(): string`,
|
|
423
|
+
`subscribe(listener): () => void` and `navigate(relativeRoute): void | Promise<void>`; optional
|
|
424
|
+
`endpoint` disambiguates several endpoint names on the same origin. Without an adapter the SDK
|
|
425
|
+
uses document navigation. The Next entry is optional and does not add Next to plain SDK consumers.
|
|
426
|
+
|
|
427
|
+
The standard renderer uses only manifest `ui.navigation`. Own navigation needs ordinary app access,
|
|
428
|
+
not `apps.panels.read`. Unavailable metadata has a retry state; an empty declaration adds no menu.
|
|
429
|
+
Same-endpoint links use cached validated metadata and the app router; endpoint changes revalidate
|
|
430
|
+
through Core and replace the same host view. Modified clicks use pinned standalone links.
|
|
431
|
+
|
|
432
|
+
For custom navigation, suppress the automatic menu and wrap only page navigation/branding:
|
|
433
|
+
|
|
434
|
+
```tsx
|
|
435
|
+
import { HostyNavigationBoundary, useHostyChrome, useHostyNavigation } from "@hosty-sdk/app/ui/react";
|
|
436
|
+
|
|
437
|
+
// <HostyNextOverlay navigation={{ renderer: "custom" }}>
|
|
438
|
+
// <HostyNavigationBoundary><MyPageNavigation /></HostyNavigationBoundary>
|
|
439
|
+
// <ProjectSelector /> // Contextual controls stay reachable when navigation is hidden.
|
|
440
|
+
// {children}
|
|
441
|
+
// </HostyNextOverlay>
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
The boundary retains component state while hidden and moves hidden focus to the content landmark.
|
|
445
|
+
Alternatively place `HostyNavigation` at a custom location. Framework-neutral renderers subscribe
|
|
446
|
+
to `ui.getChrome/subscribeChrome` and `ui.getNavigation/subscribeNavigation`, then apply equivalent
|
|
447
|
+
hidden/inert/focus behavior. Snapshots are immutable. `tools.owner` is derived by the SDK; it is not
|
|
448
|
+
a configurable default or URL field.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
const result = await ui.navigate(entry.key, { requestId: crypto.randomUUID() });
|
|
452
|
+
const picker = await ui.openToolPicker({ requestId: crypto.randomUUID() });
|
|
453
|
+
// accepted means navigation was accepted / the chooser became visible.
|
|
454
|
+
// Retain request IDs for uncertain retries; an accepted replay does not repeat the action.
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Document hints are `hosty_navigation=show|hide`, `hosty_tools=enabled|disabled` and
|
|
458
|
+
`hosty_tool_launcher=show|hide`. The SDK removes them from the visible URL, preserving history
|
|
459
|
+
state. They are not persisted. Verified host overrides take precedence, then hints, app defaults,
|
|
460
|
+
the current document's legacy launch fallback for navigation, and standalone defaults. Hints grant
|
|
461
|
+
no access or hosting authority. Hiding a launcher permits programmatic presentation; disabling
|
|
462
|
+
tools rejects new requests. A root disable applies to every source and collapses retained tools.
|
|
463
|
+
|
|
464
|
+
### Host views and retained tools
|
|
465
|
+
|
|
466
|
+
`HostyAppFrame` accepts a view descriptor. A desktop host can choose different policy from Shell:
|
|
467
|
+
|
|
468
|
+
```tsx
|
|
469
|
+
<HostyAppFrame surface={validatedSurface} title="Plans"
|
|
470
|
+
view={{ viewId: "plans-window-1", workspaceId: "default",
|
|
471
|
+
chrome: { navigation: "show", tools: { enabled: true, launcher: "hide" } },
|
|
472
|
+
navigate: surface => replaceWindowSurface(surface) }} />
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Omit `navigate` for the SDK's plain iframe replacement, or use it to update the host's route/window
|
|
476
|
+
model. Key a replaced React frame by its immutable surface; live `chrome` updates keep that key.
|
|
477
|
+
One top-level coordinator handles direct app/tool children. It never delegates credentials.
|
|
478
|
+
|
|
479
|
+
Panel declarations and presentations have different identities. `PanelRef` identifies a manifest
|
|
480
|
+
tool. `openSurface` returns an SDK `presentationId`; its `requestId` identifies only the operation.
|
|
481
|
+
Default `reuse: "exact"` focuses the matching target in the destination workspace. A distinct route
|
|
482
|
+
retains another frame; `reuse: "new"` creates another even for the same route. Rails have one item
|
|
483
|
+
per declaration and an instance switcher. Replaying a closed presentation's operation does not
|
|
484
|
+
resurrect it; use a new request to reopen. Domain sessions remain owned by the app.
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
const opened = await ui.openSurface(target, {
|
|
488
|
+
placement: "side", requestId: crypto.randomUUID(), reuse: "exact",
|
|
489
|
+
});
|
|
490
|
+
// A host may satisfy "side" with a dock or tool window.
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Hosts use `useHostyUiHost()` or `createUiHost()`:
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
host.setViewChrome("plans-window-1", { navigation: "show", tools: { launcher: "hide" } });
|
|
497
|
+
host.setPlacementResolver(({ source, target, presentations }) => ({ workspaceId: "shared" }));
|
|
498
|
+
host.focusPresentation(presentationId);
|
|
499
|
+
host.collapseWorkspace("shared");
|
|
500
|
+
await host.movePresentation(presentationId, "floating");
|
|
501
|
+
host.closePresentation(presentationId); // Does not delete the conversation.
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Register destinations before their views. React's `<HostyPanelWorkspace workspaceId="shared">`
|
|
505
|
+
implements the renderer; `panelLayout="slot"` lets the host place multiple named workspaces.
|
|
506
|
+
`layout="dock"` keeps dock geometry inside an independent tool window. The default `responsive`
|
|
507
|
+
layout switches to a drawer in a narrow region. Keep populated workspaces mounted until their
|
|
508
|
+
presentations are moved or explicitly closed.
|
|
509
|
+
|
|
510
|
+
Non-React hosts call `registerWorkspace({ workspaceId, renderer })`. A renderer implements async
|
|
511
|
+
`present(instance, signal)` and `showToolPicker(signal)` returning `accepted | unavailable`, plus
|
|
512
|
+
`focus(id)`, `collapse()` and `release(id)`. Acceptance requires an attached visible destination,
|
|
513
|
+
not a promise of later UI. Matching `frameLayer` object identities allow movement; the renderer
|
|
514
|
+
must preserve iframe DOM parent and browsing context, changing geometry instead of reparenting.
|
|
515
|
+
The supplied React renderer does this. A failed move leaves the original presentation in place.
|
|
516
|
+
|
|
517
|
+
A placement resolver can nominate `presentationId` together with its actual workspace, but only
|
|
518
|
+
an exact resolved target is accepted. Requests are ordered per destination before asynchronous
|
|
519
|
+
resolution; a newer selection or collapse supersedes older work. Closing an app view cancels its
|
|
520
|
+
unaccepted requests and preserves accepted shared presentations. All browser IDs and ledgers are
|
|
521
|
+
local to the document; Core stores no browser workspace/session registry.
|
|
522
|
+
|
|
523
|
+
### Compatibility and migration
|
|
524
|
+
|
|
525
|
+
Both peers negotiate `ui.composition.v1` inside the existing verified wire-v1 handshake. New/old
|
|
526
|
+
peers keep legacy openings without new options. Explicit reuse, remote navigation or picker calls
|
|
527
|
+
to old hosts return `unsupported_composition`; the SDK does not silently discard those options.
|
|
528
|
+
Host loss preserves stale policy and exposes reconnect/separate recovery, without taking local
|
|
529
|
+
ownership. Silent session renewal preserves same-actor frames and drafts.
|
|
530
|
+
|
|
531
|
+
Remove `hosty-shell-chrome`/`data-hosty-launch` visibility CSS when adopting navigation boundaries.
|
|
532
|
+
Keep launch/auth exports for older hosts and native transport. Native clients send explicit hidden
|
|
533
|
+
navigation/launcher and disabled tools until they provide a panel host. `panelLayout="none"` is a
|
|
534
|
+
rendering choice, not a disable policy: explicitly disable local tools or register a renderer.
|
|
535
|
+
See [UI composition and browser acceptance](../../docs/features/sdk-ui-composition/feature.md).
|
|
@@ -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/index.d.ts
CHANGED
|
@@ -106,7 +106,7 @@ export interface PageLocation {
|
|
|
106
106
|
*/
|
|
107
107
|
export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId: string, location: PageLocation): string | null;
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
109
|
+
* Legacy launch/transport context. Navigation visibility uses the independent composition policy.
|
|
110
110
|
*
|
|
111
111
|
* - `embedded` — framed by the browser Shell. The shell renders navigation for this app. The
|
|
112
112
|
* app restores its per-tab grant, tries a silent Core redirect on first load, then uses its
|
|
@@ -117,19 +117,19 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
|
|
|
117
117
|
* - `standalone` — a plain browser tab on the app's own origin. Nothing else renders navigation
|
|
118
118
|
* for this app, so it keeps its own.
|
|
119
119
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* view keeps standalone recovery even though its app chrome is hidden.
|
|
120
|
+
* Legacy consumers can still use `hidesAppChrome`. New consumers use `useHostyChrome` and
|
|
121
|
+
* `HostyNavigationBoundary`; a desktop host can keep navigation visible in embedded apps.
|
|
122
|
+
* Authentication continues to use the actual transport environment.
|
|
124
123
|
*/
|
|
125
124
|
export type AppLaunchMode = "embedded" | "native" | "standalone";
|
|
126
125
|
/** The query parameter a shell appends to the launch URL to declare the mode. */
|
|
127
126
|
export declare const LAUNCH_MODE_PARAM = "hosty_launch";
|
|
128
127
|
/** Where a resolved mode is kept for the life of the tab or web view. */
|
|
129
128
|
export declare const LAUNCH_MODE_STORAGE_KEY = "hosty.launch.mode";
|
|
130
|
-
/**
|
|
129
|
+
/** Legacy launch attribute. Use data-hosty-navigation for composition visibility. */
|
|
131
130
|
export declare const LAUNCH_MODE_ATTRIBUTE = "data-hosty-launch";
|
|
132
131
|
/**
|
|
132
|
+
* @deprecated Use HostyNavigationBoundary or the effective navigation policy.
|
|
133
133
|
* Marks an element that duplicates chrome a shell already renders — the app's own name and the
|
|
134
134
|
* navigation between its manifest pages. Contextual controls and information a shell does not
|
|
135
135
|
* render (a project picker, a refresh action, an identity badge) are not chrome in this sense
|
|
@@ -164,6 +164,7 @@ export declare function resolveLaunchMode(input: {
|
|
|
164
164
|
heuristic: AppLaunchMode;
|
|
165
165
|
}): LaunchModeResolution;
|
|
166
166
|
/**
|
|
167
|
+
* @deprecated Use the effective composition navigation policy instead.
|
|
167
168
|
* True when a shell around this app already renders its name and page navigation, so the app's
|
|
168
169
|
* own copies are duplication. False for `standalone`, where nothing else renders them.
|
|
169
170
|
*/
|
|
@@ -203,3 +204,5 @@ export declare function decideRecoveryAction(input: {
|
|
|
203
204
|
openUrl: string | null;
|
|
204
205
|
redirectAlreadyAttempted: boolean;
|
|
205
206
|
}): RecoveryAction;
|
|
207
|
+
/** Paint-time navigation policy. Kept in the pure entry point for Node and edge consumers. */
|
|
208
|
+
export declare function chromeBootstrapScript(defaults?: import("./ui-types").UiChromeOverrides): string;
|
package/dist/index.js
CHANGED
|
@@ -142,9 +142,10 @@ export function buildCoreOpenUrl(corePublicOrigin, appId, location) {
|
|
|
142
142
|
export const LAUNCH_MODE_PARAM = "hosty_launch";
|
|
143
143
|
/** Where a resolved mode is kept for the life of the tab or web view. */
|
|
144
144
|
export const LAUNCH_MODE_STORAGE_KEY = "hosty.launch.mode";
|
|
145
|
-
/**
|
|
145
|
+
/** Legacy launch attribute. Use data-hosty-navigation for composition visibility. */
|
|
146
146
|
export const LAUNCH_MODE_ATTRIBUTE = "data-hosty-launch";
|
|
147
147
|
/**
|
|
148
|
+
* @deprecated Use HostyNavigationBoundary or the effective navigation policy.
|
|
148
149
|
* Marks an element that duplicates chrome a shell already renders — the app's own name and the
|
|
149
150
|
* navigation between its manifest pages. Contextual controls and information a shell does not
|
|
150
151
|
* render (a project picker, a refresh action, an identity badge) are not chrome in this sense
|
|
@@ -188,6 +189,7 @@ export function resolveLaunchMode(input) {
|
|
|
188
189
|
return { mode: stored ?? input.heuristic, fromParam: false };
|
|
189
190
|
}
|
|
190
191
|
/**
|
|
192
|
+
* @deprecated Use the effective composition navigation policy instead.
|
|
191
193
|
* True when a shell around this app already renders its name and page navigation, so the app's
|
|
192
194
|
* own copies are duplication. False for `standalone`, where nothing else renders them.
|
|
193
195
|
*/
|
|
@@ -260,3 +262,10 @@ export function decideRecoveryAction(input) {
|
|
|
260
262
|
}
|
|
261
263
|
return { kind: "card", card: "signin" };
|
|
262
264
|
}
|
|
265
|
+
/** Paint-time navigation policy. Kept in the pure entry point for Node and edge consumers. */
|
|
266
|
+
export function chromeBootstrapScript(defaults = {}) {
|
|
267
|
+
const navigation = defaults.navigation;
|
|
268
|
+
if (navigation !== undefined && navigation !== "show" && navigation !== "hide")
|
|
269
|
+
throw new Error("Invalid navigation default.");
|
|
270
|
+
return `(()=>{try{const d=${JSON.stringify({ navigation })},p=new URLSearchParams(location.search),h=new URLSearchParams();for(const k of ["hosty_navigation","hosty_tools","hosty_tool_launcher","hosty_launch"])for(const v of p.getAll(k))h.append(k,v);document.documentElement.setAttribute('data-hosty-chrome-hints',h.toString());const one=k=>p.getAll(k).length===1?p.get(k):null,n=one('hosty_navigation'),l=one('hosty_launch');document.documentElement.setAttribute('data-hosty-navigation',n==='show'||n==='hide'?n:d.navigation??(l==='native'||l==='embedded'?'hide':'show'));}catch{}})();`;
|
|
271
|
+
}
|
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { HostyOverlay } from "./overlay-react.js";
|
|
2
|
+
/** Next routing stays an optional adapter; the composition model does not patch browser history. */
|
|
3
|
+
export declare function HostyNextNavigationBridge(): null;
|
|
4
|
+
export declare function HostyNextOverlay({ children, ...props }: Parameters<typeof HostyOverlay>[0]): import("react").JSX.Element;
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Suspense, useContext, useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import { HostyOverlay } from "./overlay-react.js";
|
|
6
|
+
import { UiCompositionContext } from "./ui-context.js";
|
|
7
|
+
/** Next routing stays an optional adapter; the composition model does not patch browser history. */
|
|
8
|
+
export function HostyNextNavigationBridge() {
|
|
9
|
+
const model = useContext(UiCompositionContext), router = useRouter(), pathname = usePathname(), search = useSearchParams();
|
|
10
|
+
const current = useRef(router);
|
|
11
|
+
current.current = router;
|
|
12
|
+
const listeners = useRef(new Set());
|
|
13
|
+
const adapter = useMemo(() => ({
|
|
14
|
+
getRoute: () => `${window.location.pathname}${window.location.search}${window.location.hash}`,
|
|
15
|
+
subscribe(listener) {
|
|
16
|
+
listeners.current.add(listener);
|
|
17
|
+
window.addEventListener("hashchange", listener);
|
|
18
|
+
return () => { listeners.current.delete(listener); window.removeEventListener("hashchange", listener); };
|
|
19
|
+
},
|
|
20
|
+
navigate(route) { current.current.push(route); },
|
|
21
|
+
}), []);
|
|
22
|
+
useEffect(() => { model?.attachRouter(adapter); return () => model?.attachRouter(); }, [model, adapter]);
|
|
23
|
+
useEffect(() => { for (const listener of listeners.current)
|
|
24
|
+
listener(); }, [pathname, search]);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function HostyNextOverlay({ children, ...props }) {
|
|
28
|
+
return _jsxs(HostyOverlay, { ...props, children: [_jsx(Suspense, { fallback: null, children: _jsx(HostyNextNavigationBridge, {}) }), children] });
|
|
29
|
+
}
|
package/dist/overlay-react.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import type { UiChromeOverrides, UiRouterAdapter } from "./ui-types.js";
|
|
2
3
|
/** Mount once around protected content. Presentation and recovery belong to Hosty. */
|
|
3
|
-
export declare function HostyOverlay({ children, probePath, appCodePath, uiPath, panelLayout }: {
|
|
4
|
+
export declare function HostyOverlay({ children, probePath, appCodePath, uiPath, panelLayout, localContent, chrome, navigation }: {
|
|
5
|
+
/** Explicitly public/local content available during an outage; never put protected actor data here. */
|
|
6
|
+
localContent?: ReactNode;
|
|
7
|
+
chrome?: UiChromeOverrides;
|
|
8
|
+
navigation?: {
|
|
9
|
+
renderer: "standard" | "custom";
|
|
10
|
+
router?: UiRouterAdapter;
|
|
11
|
+
};
|
|
4
12
|
children: ReactNode;
|
|
5
13
|
probePath?: string;
|
|
6
14
|
appCodePath?: string;
|