@hosty-sdk/app 0.6.0 → 0.7.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 +22 -3
- package/dist/embedder.d.ts +25 -1
- package/dist/embedder.js +48 -12
- package/dist/index.d.ts +32 -0
- package/dist/index.js +19 -1
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,10 +10,10 @@ npm install @hosty-sdk/app
|
|
|
10
10
|
|
|
11
11
|
| Entry | Runtime | Contents |
|
|
12
12
|
| --- | --- | --- |
|
|
13
|
-
| `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required`
|
|
13
|
+
| `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` and `hosty:request-delegated-token` schemas, URL/env helpers |
|
|
14
14
|
| `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory, the app secrets client |
|
|
15
15
|
| `@hosty-sdk/app/react` | client | `<AppIdentityBridge />` — probe, silent recovery, fallback cards |
|
|
16
|
-
| `@hosty-sdk/app/embedder` | client | verified
|
|
16
|
+
| `@hosty-sdk/app/embedder` | client | verified responders — launch-code recovery and delegated tokens — for anything that embeds Hosty apps |
|
|
17
17
|
|
|
18
18
|
Minimal Next.js wiring:
|
|
19
19
|
|
|
@@ -58,7 +58,26 @@ const claims = validateDelegatedToken(bearerToken);
|
|
|
58
58
|
if (claims?.role !== "host.admin") { /* 401/403 */ }
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
An embedded page cannot mint a delegated token itself — that needs the user's Core session in a
|
|
62
|
+
first-party context — so it asks whoever embeds it:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { DELEGATED_TOKEN_REQUEST_TYPE, DELEGATED_TOKEN_TYPE } from "@hosty-sdk/app";
|
|
66
|
+
import { parseActiveFrameDelegatedTokenRequest } from "@hosty-sdk/app/embedder";
|
|
67
|
+
|
|
68
|
+
// In the embedder, per app frame. A verified request says who asked, never whether to answer: the
|
|
69
|
+
// token is user-scoped, so grant it only to apps you decided to grant it to, and post it to that
|
|
70
|
+
// frame's own origin — never "*". Attach the listener before the frame can run (apps ask as soon as
|
|
71
|
+
// their document does, and they re-ask until answered), and honour `refresh`: it means the token the
|
|
72
|
+
// app holds was refused, so a cached mint must not be handed back.
|
|
73
|
+
const intent = parseActiveFrameDelegatedTokenRequest(event, frame.contentWindow, frame.src);
|
|
74
|
+
if (intent) {
|
|
75
|
+
const { token, expiresAt } = await mintDelegatedTokenFromCore(appId, { force: intent.refresh });
|
|
76
|
+
frame.contentWindow.postMessage({ type: DELEGATED_TOKEN_TYPE, token, expiresAt }, frameOrigin);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
61
80
|
The design contract lives in the Hosty repository:
|
|
62
|
-
[`docs/
|
|
81
|
+
[`docs/features/hosty-app-sdk/feature.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/features/hosty-app-sdk/feature.md).
|
|
63
82
|
|
|
64
83
|
License: AGPL-3.0-only.
|
package/dist/embedder.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/** The three fields of a `message` event these parsers read. */
|
|
2
|
+
export interface EmbedderMessage {
|
|
2
3
|
data: unknown;
|
|
3
4
|
origin: string;
|
|
4
5
|
source: unknown;
|
|
5
6
|
}
|
|
7
|
+
export type AuthRequiredMessage = EmbedderMessage;
|
|
6
8
|
/**
|
|
7
9
|
* Verifies that a `message` event is a genuine auth-required intent from the active app
|
|
8
10
|
* frame. Every check is about facts only the embedder can know:
|
|
@@ -13,6 +15,28 @@ export interface AuthRequiredMessage {
|
|
|
13
15
|
* and the embedder answers by reissuing a code through its own authenticated Core call.
|
|
14
16
|
*/
|
|
15
17
|
export declare function parseActiveFrameAuthRequired(event: AuthRequiredMessage, activeFrameWindow: unknown, activeFrameUrl: string, expectedAppId: string): boolean;
|
|
18
|
+
/** A verified request, and the one thing its payload carries. */
|
|
19
|
+
export interface DelegatedTokenRequestIntent {
|
|
20
|
+
/** The app says the token it holds was refused, so a cached mint must not be replayed. */
|
|
21
|
+
refresh: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Verifies that a `message` event is a delegated-token request from the active app frame, using
|
|
25
|
+
* the same sender checks as `parseActiveFrameAuthRequired`. There is nothing app-specific in the
|
|
26
|
+
* payload to check — and nothing to gain from one, since a frame's claim about which app it is
|
|
27
|
+
* would be the weakest fact in the room next to the embedder's own DOM.
|
|
28
|
+
*
|
|
29
|
+
* A non-null result says only *who asked*, never *whether to answer*. Answering hands the frame a
|
|
30
|
+
* user-scoped credential, so the embedder decides per app which ones it grants — Hosty Shell
|
|
31
|
+
* answers for the assistant gateway alone, the app it already mints delegated tokens for, so the
|
|
32
|
+
* handshake gives that app nothing it did not already hold. Wiring this responder to every frame
|
|
33
|
+
* would silently widen the delegated-token trust story to whatever an operator installed.
|
|
34
|
+
*
|
|
35
|
+
* An app may repeat the request (the first one can land before a listener is attached, and nothing
|
|
36
|
+
* makes an embedder attach one early), so answering must be idempotent — which minting from Core
|
|
37
|
+
* is.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseActiveFrameDelegatedTokenRequest(event: EmbedderMessage, activeFrameWindow: unknown, activeFrameUrl: string): DelegatedTokenRequestIntent | null;
|
|
16
40
|
/**
|
|
17
41
|
* Per-app reissue throttle: a frame that keeps re-posting (e.g. it never accepts the new
|
|
18
42
|
* code) must not drive an unbounded reissue storm. One reissue per app per interval is
|
package/dist/embedder.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
// Embedder slice: the shell half of the
|
|
2
|
-
// apps in iframes (Hosty Shell or a third-party UI client). Pure
|
|
3
|
-
// rate limiter — the embedder wires them to its own DOM
|
|
1
|
+
// Embedder slice: the shell half of the contracts an embedded app cannot serve itself, for
|
|
2
|
+
// anything that embeds Hosty apps in iframes (Hosty Shell or a third-party UI client). Pure
|
|
3
|
+
// functions plus a small rate limiter — the embedder wires them to its own DOM, its own "open
|
|
4
|
+
// this app" flow, and its own Core session.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
+
// Recovery, in one sentence: on a VERIFIED `hosty:auth-required` from an app you embed,
|
|
6
7
|
// re-run your normal open flow for that app (which mints a fresh launch code), rate-limited.
|
|
7
8
|
// The re-open must take the full launch-code path — an "already open → reuse the URL
|
|
8
9
|
// without a code" optimization must not short-circuit recovery.
|
|
9
|
-
|
|
10
|
+
//
|
|
11
|
+
// Delegated tokens, in one sentence: on a VERIFIED `hosty:request-delegated-token` from an app
|
|
12
|
+
// you have decided to grant one, mint a token from Core with your own session and post it back
|
|
13
|
+
// to that frame's origin. Both requests are unauthenticated by design; what makes them safe is
|
|
14
|
+
// that the embedder answers from facts about its own DOM rather than from what the frame claims.
|
|
15
|
+
import { AUTH_REQUIRED_INTENT_TYPE, DELEGATED_TOKEN_REQUEST_TYPE } from "./index.js";
|
|
10
16
|
/**
|
|
11
17
|
* Verifies that a `message` event is a genuine auth-required intent from the active app
|
|
12
18
|
* frame. Every check is about facts only the embedder can know:
|
|
@@ -17,6 +23,42 @@ import { AUTH_REQUIRED_INTENT_TYPE } from "./index.js";
|
|
|
17
23
|
* and the embedder answers by reissuing a code through its own authenticated Core call.
|
|
18
24
|
*/
|
|
19
25
|
export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFrameUrl, expectedAppId) {
|
|
26
|
+
if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const candidate = event.data;
|
|
30
|
+
return (candidate.type === AUTH_REQUIRED_INTENT_TYPE &&
|
|
31
|
+
typeof candidate.appId === "string" &&
|
|
32
|
+
candidate.appId === expectedAppId);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Verifies that a `message` event is a delegated-token request from the active app frame, using
|
|
36
|
+
* the same sender checks as `parseActiveFrameAuthRequired`. There is nothing app-specific in the
|
|
37
|
+
* payload to check — and nothing to gain from one, since a frame's claim about which app it is
|
|
38
|
+
* would be the weakest fact in the room next to the embedder's own DOM.
|
|
39
|
+
*
|
|
40
|
+
* A non-null result says only *who asked*, never *whether to answer*. Answering hands the frame a
|
|
41
|
+
* user-scoped credential, so the embedder decides per app which ones it grants — Hosty Shell
|
|
42
|
+
* answers for the assistant gateway alone, the app it already mints delegated tokens for, so the
|
|
43
|
+
* handshake gives that app nothing it did not already hold. Wiring this responder to every frame
|
|
44
|
+
* would silently widen the delegated-token trust story to whatever an operator installed.
|
|
45
|
+
*
|
|
46
|
+
* An app may repeat the request (the first one can land before a listener is attached, and nothing
|
|
47
|
+
* makes an embedder attach one early), so answering must be idempotent — which minting from Core
|
|
48
|
+
* is.
|
|
49
|
+
*/
|
|
50
|
+
export function parseActiveFrameDelegatedTokenRequest(event, activeFrameWindow, activeFrameUrl) {
|
|
51
|
+
if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const candidate = event.data;
|
|
55
|
+
if (candidate.type !== DELEGATED_TOKEN_REQUEST_TYPE) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { refresh: candidate.refresh === true };
|
|
59
|
+
}
|
|
60
|
+
/** The sender half both parsers share: right frame, right origin, object payload. */
|
|
61
|
+
function isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl) {
|
|
20
62
|
if (!activeFrameWindow || event.source !== activeFrameWindow) {
|
|
21
63
|
return false;
|
|
22
64
|
}
|
|
@@ -28,13 +70,7 @@ export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFra
|
|
|
28
70
|
catch {
|
|
29
71
|
return false;
|
|
30
72
|
}
|
|
31
|
-
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
const candidate = event.data;
|
|
35
|
-
return (candidate.type === AUTH_REQUIRED_INTENT_TYPE &&
|
|
36
|
-
typeof candidate.appId === "string" &&
|
|
37
|
-
candidate.appId === expectedAppId);
|
|
73
|
+
return Boolean(event.data) && typeof event.data === "object" && !Array.isArray(event.data);
|
|
38
74
|
}
|
|
39
75
|
/**
|
|
40
76
|
* Per-app reissue throttle: a frame that keeps re-posting (e.g. it never accepts the new
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,38 @@ export interface AuthRequiredIntent {
|
|
|
10
10
|
appId: string;
|
|
11
11
|
}
|
|
12
12
|
export declare function createAuthRequiredIntent(appId: string): AuthRequiredIntent;
|
|
13
|
+
/**
|
|
14
|
+
* The postMessage types of the delegated-token handshake: an embedded app page asks its embedder
|
|
15
|
+
* for a short-TTL token scoped to itself, and the embedder answers with one it minted from Core.
|
|
16
|
+
* Same shape of contract as the launch-code recovery above — the app cannot self-serve, because
|
|
17
|
+
* minting needs the user's Core session in a first-party context, which only the embedder has.
|
|
18
|
+
*
|
|
19
|
+
* The request carries no secret, so it is safe to broadcast; the answer does, so an embedder must
|
|
20
|
+
* post it to the frame's own origin and never to `*`. Answering is a decision, not a reflex: the
|
|
21
|
+
* token is a user-scoped credential, so an embedder answers only for apps it deliberately grants
|
|
22
|
+
* one (Hosty Shell: the assistant gateway, which it already mints tokens for).
|
|
23
|
+
*/
|
|
24
|
+
export declare const DELEGATED_TOKEN_REQUEST_TYPE = "hosty:request-delegated-token";
|
|
25
|
+
export declare const DELEGATED_TOKEN_TYPE = "hosty:delegated-token";
|
|
26
|
+
export interface DelegatedTokenRequest {
|
|
27
|
+
type: typeof DELEGATED_TOKEN_REQUEST_TYPE;
|
|
28
|
+
/**
|
|
29
|
+
* Set when the app is asking because the token it holds was refused. Only the app learns that —
|
|
30
|
+
* it is the one calling the API — so without this flag an embedder that caches its mints would
|
|
31
|
+
* keep answering with the very token that just came back 401, and the app could not recover until
|
|
32
|
+
* the embedder's own copy aged out. Absent rather than false on an ordinary request.
|
|
33
|
+
*/
|
|
34
|
+
refresh?: true;
|
|
35
|
+
}
|
|
36
|
+
export interface DelegatedTokenGrant {
|
|
37
|
+
type: typeof DELEGATED_TOKEN_TYPE;
|
|
38
|
+
token: string;
|
|
39
|
+
/** ISO-8601 expiry, so the page can reuse the token instead of asking once per request. */
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function createDelegatedTokenRequest(options?: {
|
|
43
|
+
refresh?: boolean;
|
|
44
|
+
}): DelegatedTokenRequest;
|
|
13
45
|
/**
|
|
14
46
|
* Recovery classification of an app session, per the platform identity error contract:
|
|
15
47
|
* - `not-present`: no token at all (recoverable — same handling as `expired`).
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// server.mjs). No React, no Next, no environment reads — everything is passed in.
|
|
3
3
|
//
|
|
4
4
|
// This is the single source of truth for the Hosty app-session contract described in
|
|
5
|
-
// docker-host docs/
|
|
5
|
+
// docker-host docs/features/hosty-app-sdk/feature.md and docs/features/auth-session-lifecycle/feature.md.
|
|
6
6
|
/**
|
|
7
7
|
* The postMessage type an embedded app sends its parent to request a fresh launch code.
|
|
8
8
|
* A frozen protocol constant: it deliberately does not track product branding (precedent:
|
|
@@ -13,6 +13,24 @@ export const AUTH_REQUIRED_INTENT_TYPE = "hosty:auth-required";
|
|
|
13
13
|
export function createAuthRequiredIntent(appId) {
|
|
14
14
|
return { type: AUTH_REQUIRED_INTENT_TYPE, appId };
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The postMessage types of the delegated-token handshake: an embedded app page asks its embedder
|
|
18
|
+
* for a short-TTL token scoped to itself, and the embedder answers with one it minted from Core.
|
|
19
|
+
* Same shape of contract as the launch-code recovery above — the app cannot self-serve, because
|
|
20
|
+
* minting needs the user's Core session in a first-party context, which only the embedder has.
|
|
21
|
+
*
|
|
22
|
+
* The request carries no secret, so it is safe to broadcast; the answer does, so an embedder must
|
|
23
|
+
* post it to the frame's own origin and never to `*`. Answering is a decision, not a reflex: the
|
|
24
|
+
* token is a user-scoped credential, so an embedder answers only for apps it deliberately grants
|
|
25
|
+
* one (Hosty Shell: the assistant gateway, which it already mints tokens for).
|
|
26
|
+
*/
|
|
27
|
+
export const DELEGATED_TOKEN_REQUEST_TYPE = "hosty:request-delegated-token";
|
|
28
|
+
export const DELEGATED_TOKEN_TYPE = "hosty:delegated-token";
|
|
29
|
+
export function createDelegatedTokenRequest(options = {}) {
|
|
30
|
+
return options.refresh
|
|
31
|
+
? { type: DELEGATED_TOKEN_REQUEST_TYPE, refresh: true }
|
|
32
|
+
: { type: DELEGATED_TOKEN_REQUEST_TYPE };
|
|
33
|
+
}
|
|
16
34
|
/**
|
|
17
35
|
* Maps a Core revalidation HTTP status onto the session classification. Classification is
|
|
18
36
|
* by status code only — never by error-code strings — so new Core codes cannot break an
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { classifyRevalidationHttpStatus, } from "./index.js";
|
|
|
5
5
|
// Core runs on the same host as the app services, so a short budget is plenty; a stalled
|
|
6
6
|
// call must not hold a route open indefinitely.
|
|
7
7
|
const CORE_AUTH_TIMEOUT_MS = 1_500;
|
|
8
|
-
//
|
|
8
|
+
// Per docs/features/hosty-app-sdk/feature.md: positive revalidations may be cached for 30s, clamped to
|
|
9
9
|
// the grant's expiry; negative results are never cached, so a stuck-unauthenticated state
|
|
10
10
|
// is impossible. Bounded so an attacker spraying tokens cannot grow the map unboundedly.
|
|
11
11
|
const REVALIDATION_CACHE_TTL_MS = 30_000;
|
package/package.json
CHANGED