@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 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` schema, URL/env helpers |
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 `hosty:auth-required` responder for anything that embeds Hosty apps |
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/ideas/hosty-app-sdk.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/ideas/hosty-app-sdk.md).
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.
@@ -1,8 +1,10 @@
1
- export interface AuthRequiredMessage {
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 recovery contract, for anything that embeds Hosty
2
- // apps in iframes (Hosty Shell or a third-party UI client). Pure functions plus a small
3
- // rate limiter — the embedder wires them to its own DOM and its own "open this app" flow.
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
- // The contract in one sentence: on a VERIFIED `hosty:auth-required` from an app you embed,
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
- import { AUTH_REQUIRED_INTENT_TYPE } from "./index.js";
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
- if (!event.data || typeof event.data !== "object" || Array.isArray(event.data)) {
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/ideas/hosty-app-sdk.md and docs/ideas/auth-session-lifecycle.md.
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
- // Decision 9 (hosty-app-sdk.md): positive revalidations may be cached for 30s, clamped to
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",