@hosty-sdk/app 0.1.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 ADDED
@@ -0,0 +1,38 @@
1
+ # @hosty-sdk/app
2
+
3
+ Auth and host integration for [Hosty](https://github.com/alex-de-haas/docker-host) runtime
4
+ apps: the app-session state machine, silent session recovery, Core revalidation, and the
5
+ embedder responder for shells.
6
+
7
+ ```
8
+ npm install @hosty-sdk/app
9
+ ```
10
+
11
+ | Entry | Runtime | Contents |
12
+ | --- | --- | --- |
13
+ | `@hosty-sdk/app` | anywhere | status taxonomy, recovery decision, `hosty:auth-required` schema, URL/env helpers |
14
+ | `@hosty-sdk/app/server` | server only | Core revalidation with caching, cookie helpers, the app-code route factory |
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 |
17
+
18
+ Minimal Next.js wiring:
19
+
20
+ ```tsx
21
+ // app/layout.tsx
22
+ import { AppIdentityBridge } from "@hosty-sdk/app/react";
23
+ // mount <AppIdentityBridge /> at the top of <body>
24
+
25
+ // app/api/auth/app-code/route.ts
26
+ import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
27
+ export const dynamic = "force-dynamic";
28
+ export const runtime = "nodejs";
29
+ export const POST = createAppCodeRouteHandler({
30
+ appIdFallback: "com.example.my-app",
31
+ identityCookieName: "my_app_hosty_identity",
32
+ });
33
+ ```
34
+
35
+ The design contract lives in the Hosty repository:
36
+ [`docs/ideas/hosty-app-sdk.md`](https://github.com/alex-de-haas/docker-host/blob/main/docs/ideas/hosty-app-sdk.md).
37
+
38
+ License: AGPL-3.0-only.
@@ -0,0 +1,24 @@
1
+ export interface AuthRequiredMessage {
2
+ data: unknown;
3
+ origin: string;
4
+ source: unknown;
5
+ }
6
+ /**
7
+ * Verifies that a `message` event is a genuine auth-required intent from the active app
8
+ * frame. Every check is about facts only the embedder can know:
9
+ * - `event.source` must be the active iframe's `contentWindow` (not some other frame);
10
+ * - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
11
+ * - the payload's `appId` must match the app the embedder actually mounted there.
12
+ * No credential crosses the boundary in either direction: the payload is only the app id,
13
+ * and the embedder answers by reissuing a code through its own authenticated Core call.
14
+ */
15
+ export declare function parseActiveFrameAuthRequired(event: AuthRequiredMessage, activeFrameWindow: unknown, activeFrameUrl: string, expectedAppId: string): boolean;
16
+ /**
17
+ * Per-app reissue throttle: a frame that keeps re-posting (e.g. it never accepts the new
18
+ * code) must not drive an unbounded reissue storm. One reissue per app per interval is
19
+ * plenty for recovery — Hosty Shell ships 3000ms.
20
+ */
21
+ export declare function createReissueRateLimiter(minIntervalMs: number, now?: () => number): {
22
+ /** True when a reissue for this app is allowed now; records the attempt when allowed. */
23
+ tryAcquire(appId: string): boolean;
24
+ };
@@ -0,0 +1,58 @@
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.
4
+ //
5
+ // The contract in one sentence: on a VERIFIED `hosty:auth-required` from an app you embed,
6
+ // re-run your normal open flow for that app (which mints a fresh launch code), rate-limited.
7
+ // The re-open must take the full launch-code path — an "already open → reuse the URL
8
+ // without a code" optimization must not short-circuit recovery.
9
+ import { AUTH_REQUIRED_INTENT_TYPE } from "./index";
10
+ /**
11
+ * Verifies that a `message` event is a genuine auth-required intent from the active app
12
+ * frame. Every check is about facts only the embedder can know:
13
+ * - `event.source` must be the active iframe's `contentWindow` (not some other frame);
14
+ * - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
15
+ * - the payload's `appId` must match the app the embedder actually mounted there.
16
+ * No credential crosses the boundary in either direction: the payload is only the app id,
17
+ * and the embedder answers by reissuing a code through its own authenticated Core call.
18
+ */
19
+ export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFrameUrl, expectedAppId) {
20
+ if (!activeFrameWindow || event.source !== activeFrameWindow) {
21
+ return false;
22
+ }
23
+ try {
24
+ if (event.origin !== new URL(activeFrameUrl).origin) {
25
+ return false;
26
+ }
27
+ }
28
+ catch {
29
+ return false;
30
+ }
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);
38
+ }
39
+ /**
40
+ * Per-app reissue throttle: a frame that keeps re-posting (e.g. it never accepts the new
41
+ * code) must not drive an unbounded reissue storm. One reissue per app per interval is
42
+ * plenty for recovery — Hosty Shell ships 3000ms.
43
+ */
44
+ export function createReissueRateLimiter(minIntervalMs, now = Date.now) {
45
+ const lastReissueAt = new Map();
46
+ return {
47
+ /** True when a reissue for this app is allowed now; records the attempt when allowed. */
48
+ tryAcquire(appId) {
49
+ const at = now();
50
+ const last = lastReissueAt.get(appId);
51
+ if (last !== undefined && at - last < minIntervalMs) {
52
+ return false;
53
+ }
54
+ lastReissueAt.set(appId, at);
55
+ return true;
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The postMessage type an embedded app sends its parent to request a fresh launch code.
3
+ * A frozen protocol constant: it deliberately does not track product branding (precedent:
4
+ * the `x-docker-host-identity` header survived the docker-host→hosty rename untouched).
5
+ * The payload carries no secret — the embedder verifies the sender before acting.
6
+ */
7
+ export declare const AUTH_REQUIRED_INTENT_TYPE = "hosty:auth-required";
8
+ export interface AuthRequiredIntent {
9
+ type: typeof AUTH_REQUIRED_INTENT_TYPE;
10
+ appId: string;
11
+ }
12
+ export declare function createAuthRequiredIntent(appId: string): AuthRequiredIntent;
13
+ /**
14
+ * Recovery classification of an app session, per the platform identity error contract:
15
+ * - `not-present`: no token at all (recoverable — same handling as `expired`).
16
+ * - `active`: token revalidated OK.
17
+ * - `expired`: Core 401 — recoverable; re-authorize via the Shell (embedded) or Core `/open`
18
+ * (standalone).
19
+ * - `forbidden`: Core 403 or a token minted for a different app — terminal; never
20
+ * auto-redirect (it would loop).
21
+ * - `unavailable`: Core unreachable, slow, or answering garbage — transient; keep the
22
+ * cookie and offer a retry.
23
+ * - `misconfigured`: the app itself is broken (no service token / no Core origin) — an
24
+ * operator problem; signing in cannot fix it, so never offer a login.
25
+ */
26
+ export type AppSessionStatus = "not-present" | "active" | "expired" | "forbidden" | "unavailable" | "misconfigured";
27
+ /** Failure statuses (everything but `active`). */
28
+ export type AppSessionFailureStatus = Exclude<AppSessionStatus, "active">;
29
+ /**
30
+ * Maps a Core revalidation HTTP status onto the session classification. Classification is
31
+ * by status code only — never by error-code strings — so new Core codes cannot break an
32
+ * app; `MapIdentityErrorStatus` in Core is the normative table behind these numbers.
33
+ */
34
+ export declare function classifyRevalidationHttpStatus(status: number): AppSessionFailureStatus;
35
+ /**
36
+ * Recovery parameters an app's force-dynamic identity/session endpoint hands the browser.
37
+ * They ride in a response — never in a server-component prop — because app pages are
38
+ * prerendered at image build time, where the HOSTY_* environment does not exist yet
39
+ * (docker-host PR #233 / media-server PR #63). Neither value is secret.
40
+ */
41
+ export interface SessionRecoveryParams {
42
+ appId: string | null;
43
+ corePublicOrigin: string | null;
44
+ }
45
+ export declare function readRecoveryParams(body: unknown): SessionRecoveryParams;
46
+ /** True for hosts that only resolve on the machine itself. */
47
+ export declare function isLoopbackHost(hostname: string): boolean;
48
+ export interface PageLocation {
49
+ origin: string;
50
+ pathname: string;
51
+ search: string;
52
+ hostname: string;
53
+ }
54
+ /**
55
+ * Builds the Core standalone re-auth URL (`/api/apps/{id}/open?redirectUri=…`) for the
56
+ * current page, or null when the redirect is known to be impossible:
57
+ * - no Core public origin at all, or an unparsable one;
58
+ * - Core's origin is loopback while this page is not — an unset public origin falls back
59
+ * to Core's loopback listen URL, which only a same-machine browser can follow, and Core
60
+ * would reject the foreign redirect URI anyway (`redirect_uri_denied`).
61
+ * The fragment is deliberately dropped: Core rejects redirect URIs carrying one
62
+ * (`redirect_uri_invalid`), and it would not survive the server redirect anyway.
63
+ */
64
+ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId: string, location: PageLocation): string | null;
65
+ /** Launch mode drives the logout affordance: embedded apps hide logout entirely (the
66
+ * session belongs to the Shell), standalone apps may show one. `window.self !== window.top`
67
+ * is the baseline signal; a Core-reported launch channel can replace it later without
68
+ * changing callers. */
69
+ export type AppLaunchMode = "embedded" | "standalone";
70
+ export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): AppLaunchMode;
71
+ /**
72
+ * The recovery decision: state × launch mode → what the app should do, per the UX
73
+ * contract (Core `/login` is the only auth UI in the system; apps render no login UI and
74
+ * no errors while recovery runs).
75
+ */
76
+ export type RecoveryAction = {
77
+ kind: "none";
78
+ } | {
79
+ kind: "post-auth-required";
80
+ intent: AuthRequiredIntent;
81
+ } | {
82
+ kind: "redirect";
83
+ openUrl: string;
84
+ } | {
85
+ kind: "card";
86
+ card: "signin" | "denied" | "unavailable" | "misconfigured";
87
+ };
88
+ export declare function decideRecoveryAction(input: {
89
+ status: AppSessionStatus;
90
+ mode: AppLaunchMode;
91
+ appId: string;
92
+ openUrl: string | null;
93
+ redirectAlreadyAttempted: boolean;
94
+ }): RecoveryAction;
package/dist/index.js ADDED
@@ -0,0 +1,107 @@
1
+ // Core slice: pure TypeScript, safe in any runtime (browser, Node, edge, solitaire's
2
+ // server.mjs). No React, no Next, no environment reads — everything is passed in.
3
+ //
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.
6
+ /**
7
+ * The postMessage type an embedded app sends its parent to request a fresh launch code.
8
+ * A frozen protocol constant: it deliberately does not track product branding (precedent:
9
+ * the `x-docker-host-identity` header survived the docker-host→hosty rename untouched).
10
+ * The payload carries no secret — the embedder verifies the sender before acting.
11
+ */
12
+ export const AUTH_REQUIRED_INTENT_TYPE = "hosty:auth-required";
13
+ export function createAuthRequiredIntent(appId) {
14
+ return { type: AUTH_REQUIRED_INTENT_TYPE, appId };
15
+ }
16
+ /**
17
+ * Maps a Core revalidation HTTP status onto the session classification. Classification is
18
+ * by status code only — never by error-code strings — so new Core codes cannot break an
19
+ * app; `MapIdentityErrorStatus` in Core is the normative table behind these numbers.
20
+ */
21
+ export function classifyRevalidationHttpStatus(status) {
22
+ if (status === 401) {
23
+ return "expired";
24
+ }
25
+ if (status === 403) {
26
+ return "forbidden";
27
+ }
28
+ return "unavailable";
29
+ }
30
+ export function readRecoveryParams(body) {
31
+ const recovery = body && typeof body === "object" ? body.recovery : null;
32
+ if (!recovery || typeof recovery !== "object") {
33
+ return { appId: null, corePublicOrigin: null };
34
+ }
35
+ const { appId, corePublicOrigin } = recovery;
36
+ return {
37
+ appId: typeof appId === "string" && appId.length > 0 ? appId : null,
38
+ corePublicOrigin: typeof corePublicOrigin === "string" && corePublicOrigin.length > 0
39
+ ? corePublicOrigin
40
+ : null,
41
+ };
42
+ }
43
+ /** True for hosts that only resolve on the machine itself. */
44
+ export function isLoopbackHost(hostname) {
45
+ const host = hostname.toLowerCase();
46
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
47
+ }
48
+ /**
49
+ * Builds the Core standalone re-auth URL (`/api/apps/{id}/open?redirectUri=…`) for the
50
+ * current page, or null when the redirect is known to be impossible:
51
+ * - no Core public origin at all, or an unparsable one;
52
+ * - Core's origin is loopback while this page is not — an unset public origin falls back
53
+ * to Core's loopback listen URL, which only a same-machine browser can follow, and Core
54
+ * would reject the foreign redirect URI anyway (`redirect_uri_denied`).
55
+ * The fragment is deliberately dropped: Core rejects redirect URIs carrying one
56
+ * (`redirect_uri_invalid`), and it would not survive the server redirect anyway.
57
+ */
58
+ export function buildCoreOpenUrl(corePublicOrigin, appId, location) {
59
+ if (!corePublicOrigin) {
60
+ return null;
61
+ }
62
+ let target;
63
+ try {
64
+ target = new URL(`/api/apps/${encodeURIComponent(appId)}/open`, corePublicOrigin);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ if (isLoopbackHost(target.hostname) && !isLoopbackHost(location.hostname)) {
70
+ return null;
71
+ }
72
+ target.searchParams.set("redirectUri", `${location.origin}${location.pathname}${location.search}`);
73
+ return target.toString();
74
+ }
75
+ export function detectLaunchMode(win) {
76
+ try {
77
+ return win.self !== win.top ? "embedded" : "standalone";
78
+ }
79
+ catch {
80
+ // A cross-origin `top` access can throw in exotic embeddings; being framed is the only
81
+ // way that happens, so classify it as embedded.
82
+ return "embedded";
83
+ }
84
+ }
85
+ export function decideRecoveryAction(input) {
86
+ const { status, mode, appId, openUrl, redirectAlreadyAttempted } = input;
87
+ if (status === "active") {
88
+ return { kind: "none" };
89
+ }
90
+ if (status === "forbidden") {
91
+ return { kind: "card", card: "denied" };
92
+ }
93
+ if (status === "unavailable") {
94
+ return { kind: "card", card: "unavailable" };
95
+ }
96
+ if (status === "misconfigured") {
97
+ return { kind: "card", card: "misconfigured" };
98
+ }
99
+ // expired / not-present — the recoverable pair.
100
+ if (mode === "embedded") {
101
+ return { kind: "post-auth-required", intent: createAuthRequiredIntent(appId) };
102
+ }
103
+ if (openUrl && !redirectAlreadyAttempted) {
104
+ return { kind: "redirect", openUrl };
105
+ }
106
+ return { kind: "card", card: "signin" };
107
+ }
@@ -0,0 +1,21 @@
1
+ import { type AppSessionStatus } from "./index";
2
+ /**
3
+ * Tolerant probe reader: accepts both identity-route shapes in the fleet — a top-level
4
+ * `status` (marketplace/telemetry) and a nested `appSession.status` (demo-app,
5
+ * project-manager). The legacy `error` status maps to `unavailable` (transient).
6
+ */
7
+ export declare function readProbedSessionStatus(body: unknown): AppSessionStatus | null;
8
+ export interface AppIdentityBridgeProps {
9
+ /** Probe endpoint returning a session status + recovery params. */
10
+ probePath?: string;
11
+ /** Code-exchange endpoint. */
12
+ appCodePath?: string;
13
+ }
14
+ /**
15
+ * Mount once at the top of the root layout body. On first load it consumes a `?code` from
16
+ * the URL (exchanging it for the identity cookie), then probes the session and runs the
17
+ * recovery decision: silent `hosty:auth-required` to the Shell when embedded, a once-per-tab
18
+ * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
19
+ * — never a login UI while recovery is still running.
20
+ */
21
+ export declare function AppIdentityBridge({ probePath, appCodePath, }?: AppIdentityBridgeProps): import("react").JSX.Element | null;
package/dist/react.js ADDED
@@ -0,0 +1,226 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // React slice: the client identity bridge — probe, silent recovery, and the fallback
4
+ // cards. Prop-less by design: the app id and browser-reachable Core origin arrive in the
5
+ // probe response's `recovery` field (request-time values, never build-time props).
6
+ import { useEffect, useState } from "react";
7
+ import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, readRecoveryParams, } from "./index";
8
+ // Once-per-tab guard so a standalone app that returns from Core still unauthorized does
9
+ // not bounce through /open forever. Cleared on a successful code exchange.
10
+ const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
11
+ // How long an embedded frame waits for the Shell to reissue a launch code before showing
12
+ // the manual sign-in fallback (i.e. it is embedded by something other than Hosty Shell).
13
+ const EMBEDDED_RECOVERY_TIMEOUT_MS = 4_000;
14
+ // Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
15
+ // timeout it classifies as unavailable and the user gets a Retry affordance.
16
+ const IDENTITY_PROBE_TIMEOUT_MS = 4_000;
17
+ const KNOWN_STATUSES = [
18
+ "not-present",
19
+ "active",
20
+ "expired",
21
+ "forbidden",
22
+ "unavailable",
23
+ "misconfigured",
24
+ ];
25
+ /**
26
+ * Tolerant probe reader: accepts both identity-route shapes in the fleet — a top-level
27
+ * `status` (marketplace/telemetry) and a nested `appSession.status` (demo-app,
28
+ * project-manager). The legacy `error` status maps to `unavailable` (transient).
29
+ */
30
+ export function readProbedSessionStatus(body) {
31
+ if (!body || typeof body !== "object") {
32
+ return null;
33
+ }
34
+ const record = body;
35
+ const nested = record.appSession && typeof record.appSession === "object"
36
+ ? record.appSession.status
37
+ : undefined;
38
+ const raw = typeof record.status === "string" ? record.status : nested;
39
+ if (typeof raw !== "string") {
40
+ return null;
41
+ }
42
+ if (raw === "error") {
43
+ return "unavailable";
44
+ }
45
+ return KNOWN_STATUSES.includes(raw) ? raw : null;
46
+ }
47
+ function readGuard() {
48
+ try {
49
+ return window.sessionStorage.getItem(RECOVERY_GUARD_KEY) === "1";
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ function writeGuard(value) {
56
+ try {
57
+ if (value) {
58
+ window.sessionStorage.setItem(RECOVERY_GUARD_KEY, "1");
59
+ }
60
+ else {
61
+ window.sessionStorage.removeItem(RECOVERY_GUARD_KEY);
62
+ }
63
+ }
64
+ catch {
65
+ // sessionStorage may be blocked; recovery still works, only the guard is lost.
66
+ }
67
+ }
68
+ /**
69
+ * Mount once at the top of the root layout body. On first load it consumes a `?code` from
70
+ * the URL (exchanging it for the identity cookie), then probes the session and runs the
71
+ * recovery decision: silent `hosty:auth-required` to the Shell when embedded, a once-per-tab
72
+ * redirect through Core `/open` when standalone, and cards only in fallback/terminal states
73
+ * — never a login UI while recovery is still running.
74
+ */
75
+ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", } = {}) {
76
+ const [ui, setUi] = useState({ kind: "hidden" });
77
+ useEffect(() => {
78
+ let cancelled = false;
79
+ const controller = new AbortController();
80
+ async function probeAndRecover() {
81
+ // Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
82
+ // available in every browser (a synchronous throw here would stop the bridge from
83
+ // ever rendering). Aborts on effect teardown or when the probe times out.
84
+ const probeController = new AbortController();
85
+ const abortProbe = () => probeController.abort();
86
+ controller.signal.addEventListener("abort", abortProbe, { once: true });
87
+ const probeTimeout = window.setTimeout(abortProbe, IDENTITY_PROBE_TIMEOUT_MS);
88
+ let status = null;
89
+ let openUrl = null;
90
+ let appId = null;
91
+ try {
92
+ const response = await fetch(probePath, {
93
+ headers: { Accept: "application/json" },
94
+ cache: "no-store",
95
+ signal: probeController.signal,
96
+ });
97
+ const body = await response.json().catch(() => null);
98
+ status = readProbedSessionStatus(body);
99
+ const recovery = readRecoveryParams(body);
100
+ appId = recovery.appId;
101
+ openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
102
+ }
103
+ catch {
104
+ // A failed or timed-out probe (Core unreachable) classifies as unavailable below.
105
+ status = null;
106
+ }
107
+ finally {
108
+ window.clearTimeout(probeTimeout);
109
+ controller.signal.removeEventListener("abort", abortProbe);
110
+ }
111
+ if (cancelled) {
112
+ return;
113
+ }
114
+ const action = !status || !appId
115
+ ? { kind: "card", card: "unavailable" }
116
+ : decideRecoveryAction({
117
+ status,
118
+ mode: detectLaunchMode(window),
119
+ appId,
120
+ openUrl,
121
+ redirectAlreadyAttempted: readGuard(),
122
+ });
123
+ switch (action.kind) {
124
+ case "none":
125
+ setUi({ kind: "hidden" });
126
+ return;
127
+ case "post-auth-required": {
128
+ // The payload carries no secret, so targetOrigin "*" is safe — the Shell verifies
129
+ // the sender before acting, and answers by swapping the iframe src.
130
+ try {
131
+ window.parent.postMessage(action.intent, "*");
132
+ }
133
+ catch {
134
+ // Ignore; the timeout below still falls back to the manual sign-in card.
135
+ }
136
+ const timeoutId = window.setTimeout(() => {
137
+ if (!cancelled) {
138
+ setUi({ kind: "signin", openUrl, embedded: true });
139
+ }
140
+ }, EMBEDDED_RECOVERY_TIMEOUT_MS);
141
+ controller.signal.addEventListener("abort", () => window.clearTimeout(timeoutId), {
142
+ once: true,
143
+ });
144
+ return;
145
+ }
146
+ case "redirect":
147
+ writeGuard(true);
148
+ window.location.assign(action.openUrl);
149
+ return;
150
+ case "card":
151
+ setUi(action.card === "signin"
152
+ ? { kind: "signin", openUrl, embedded: false }
153
+ : { kind: action.card });
154
+ return;
155
+ }
156
+ }
157
+ const url = new URL(window.location.href);
158
+ const code = url.searchParams.get("code")?.trim();
159
+ if (code) {
160
+ url.searchParams.delete("code");
161
+ window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
162
+ void fetch(appCodePath, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({ code }),
166
+ signal: controller.signal,
167
+ })
168
+ .then((response) => {
169
+ if (cancelled) {
170
+ return;
171
+ }
172
+ if (response.ok) {
173
+ writeGuard(false);
174
+ window.location.reload();
175
+ }
176
+ else {
177
+ void probeAndRecover();
178
+ }
179
+ })
180
+ .catch(() => {
181
+ if (!cancelled) {
182
+ void probeAndRecover();
183
+ }
184
+ });
185
+ }
186
+ else {
187
+ void probeAndRecover();
188
+ }
189
+ return () => {
190
+ cancelled = true;
191
+ controller.abort();
192
+ };
193
+ }, [probePath, appCodePath]);
194
+ if (ui.kind === "hidden") {
195
+ return null;
196
+ }
197
+ return (_jsx("div", { role: "status", style: barStyle, children: ui.kind === "signin" ? (_jsxs(_Fragment, { children: [_jsx("span", { children: "Your Hosty session ended." }), ui.openUrl ? (_jsx("a", { href: ui.openUrl, ...(ui.embedded ? { target: "_blank", rel: "noopener noreferrer" } : {}), style: actionStyle, children: "Sign in via Hosty" })) : (_jsx("span", { children: "Open this app from the machine running Hosty, or configure a public origin." }))] })) : ui.kind === "denied" ? (_jsx("span", { children: "You are signed in to Hosty but are not allowed to use this app." })) : ui.kind === "misconfigured" ? (_jsx("span", { children: "This app is not configured correctly on the host. Contact the administrator." })) : (_jsxs(_Fragment, { children: [_jsx("span", { children: "Can\u2019t reach Hosty right now." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), style: actionStyle, children: "Retry" })] })) }));
198
+ }
199
+ const barStyle = {
200
+ position: "fixed",
201
+ insetInline: 0,
202
+ bottom: 0,
203
+ zIndex: 2147483647,
204
+ display: "flex",
205
+ flexWrap: "wrap",
206
+ alignItems: "center",
207
+ justifyContent: "center",
208
+ gap: "0.75rem",
209
+ padding: "0.75rem 1rem",
210
+ background: "#111827",
211
+ color: "#f9fafb",
212
+ font: "500 0.875rem/1.4 system-ui, sans-serif",
213
+ boxShadow: "0 -1px 0 rgba(255,255,255,0.08)",
214
+ };
215
+ const actionStyle = {
216
+ display: "inline-block",
217
+ padding: "0.4rem 0.85rem",
218
+ borderRadius: "0.5rem",
219
+ background: "#f9fafb",
220
+ color: "#111827",
221
+ border: "none",
222
+ cursor: "pointer",
223
+ font: "inherit",
224
+ fontWeight: 650,
225
+ textDecoration: "none",
226
+ };
@@ -0,0 +1,98 @@
1
+ import "server-only";
2
+ import { type AppSessionFailureStatus, type AppSessionStatus, type SessionRecoveryParams } from "./index";
3
+ /** Per-app parameters: everything that legitimately diverges between apps stays a
4
+ * parameter — cookie namespaces and role models are per-app by decision, never unified. */
5
+ export interface HostyAppConfig {
6
+ /** Stable app id fallback for when HOSTY_APP_ID is not injected (mirrors the manifest). */
7
+ appIdFallback: string;
8
+ /** This app's identity cookie name (e.g. "hosty_marketplace_identity"). */
9
+ identityCookieName: string;
10
+ /** Optional host-role → app-role mapping hook; the raw role passes through when absent. */
11
+ mapHostRole?: (hostRole: string | null) => string | null;
12
+ }
13
+ /** The inbound trusted-identity header the platform has always used (a frozen protocol
14
+ * constant that survived the docker-host→hosty rename). */
15
+ export declare const HOSTY_APP_IDENTITY_HEADER = "x-docker-host-identity";
16
+ export interface HostIdentity {
17
+ userId: string;
18
+ email: string | null;
19
+ displayName: string | null;
20
+ hostRole: string | null;
21
+ /** `mapHostRole(hostRole)` when the config provides the hook, else the raw host role. */
22
+ appRole: string | null;
23
+ expiresAt: string | null;
24
+ }
25
+ export type AppSessionResolution = {
26
+ status: "active";
27
+ identity: HostIdentity;
28
+ } | {
29
+ status: AppSessionFailureStatus;
30
+ };
31
+ export declare function getAppId(config: HostyAppConfig): string;
32
+ export declare function getCoreOrigin(): string | null;
33
+ /** Browser-reachable Core origin. Core always injects it (falls back to its loopback
34
+ * listen URL on localhost-only installs), so a missing value is a broken environment. */
35
+ export declare function getCorePublicOrigin(): string | null;
36
+ export declare function getServiceToken(): string | null;
37
+ export declare function getRecoveryParams(config: HostyAppConfig): SessionRecoveryParams;
38
+ export type HeaderReader = {
39
+ get(name: string): string | null;
40
+ };
41
+ /**
42
+ * Reads the app identity token from a request: the app-origin cookie first (authoritative
43
+ * when present), then the Authorization bearer fallback the browser sends when the
44
+ * cross-site cookie is blocked, then the legacy inbound identity header.
45
+ */
46
+ export declare function readAppIdentityToken(headers: HeaderReader, config: HostyAppConfig): string | null;
47
+ /** True when the effective request protocol is https (honouring the forwarded proto). */
48
+ export declare function isSecureRequest(headers: HeaderReader, requestProtocol?: string): boolean;
49
+ export interface IdentityCookieAttributes {
50
+ httpOnly: true;
51
+ secure: boolean;
52
+ sameSite: "none" | "lax";
53
+ path: "/";
54
+ maxAge: number;
55
+ }
56
+ /**
57
+ * Cookie attributes for the app-origin identity cookie. Cross-site Shell iframes need
58
+ * `SameSite=None; Secure` over https; plain http (local dev) cannot use `Secure` and
59
+ * falls back to `SameSite=Lax`. `maxAge` follows the grant's absolute lifetime.
60
+ */
61
+ export declare function identityCookieAttributes(secure: boolean, maxAgeSeconds: number): IdentityCookieAttributes;
62
+ /** Test hook: the cache is process-global by design (route handlers are stateless). */
63
+ export declare function clearRevalidationCache(): void;
64
+ /**
65
+ * Revalidates an app identity token against Core with the app service token and
66
+ * classifies the outcome. Core identity tokens are opaque (`hostyg_` grants), so this
67
+ * round-trip is the only trustworthy validation; policy (disabled / unassigned / role
68
+ * downgrade) is re-checked online by Core on every call, which is why caching stays short.
69
+ */
70
+ export declare function resolveAppSession(token: string | null, config: HostyAppConfig): Promise<AppSessionResolution>;
71
+ /**
72
+ * Cookie-only probe classification for the client recovery bridge. Deliberately ignores
73
+ * bearer/header tokens: probe routes are public, and honoring caller-supplied tokens would
74
+ * turn them into an unauthenticated oracle that revalidates attacker-chosen tokens against
75
+ * Core. The recovery contract only ever concerns *this browser's* session, which lives in
76
+ * the HttpOnly app cookie.
77
+ */
78
+ export declare function classifyAppSessionFromCookie(headers: HeaderReader, config: HostyAppConfig): Promise<AppSessionStatus>;
79
+ export type AppCodeExchange = {
80
+ ok: true;
81
+ accessToken: string;
82
+ expiresInSeconds: number | null;
83
+ } | {
84
+ ok: false;
85
+ status: number;
86
+ code: string;
87
+ message: string;
88
+ };
89
+ /** Exchanges a one-time Shell/Core launch code for an identity token at Core. The code is
90
+ * the only credential — the endpoint is deliberately unauthenticated on Core's side. */
91
+ export declare function exchangeAppCode(code: string): Promise<AppCodeExchange>;
92
+ /**
93
+ * Route-handler factory for `POST /api/auth/app-code`: exchanges the one-time `code` for
94
+ * an identity token, stores it in the app-origin HttpOnly cookie, and returns the token so
95
+ * the browser can keep a bearer fallback for when the cross-site cookie is blocked.
96
+ * Framework-agnostic (Web `Request`/`Response`), usable directly as a Next route handler.
97
+ */
98
+ export declare function createAppCodeRouteHandler(config: HostyAppConfig): (request: Request) => Promise<Response>;
package/dist/server.js ADDED
@@ -0,0 +1,334 @@
1
+ // Server slice: Next route-handler factories and Core revalidation. Never reaches a client
2
+ // bundle — the service token lives here.
3
+ import "server-only";
4
+ import { classifyRevalidationHttpStatus, } from "./index";
5
+ // Core runs on the same host as the app services, so a short budget is plenty; a stalled
6
+ // call must not hold a route open indefinitely.
7
+ const CORE_AUTH_TIMEOUT_MS = 1_500;
8
+ // Decision 9 (hosty-app-sdk.md): positive revalidations may be cached for 30s, clamped to
9
+ // the grant's expiry; negative results are never cached, so a stuck-unauthenticated state
10
+ // is impossible. Bounded so an attacker spraying tokens cannot grow the map unboundedly.
11
+ const REVALIDATION_CACHE_TTL_MS = 30_000;
12
+ const MAX_REVALIDATION_CACHE_ENTRIES = 256;
13
+ /** The inbound trusted-identity header the platform has always used (a frozen protocol
14
+ * constant that survived the docker-host→hosty rename). */
15
+ export const HOSTY_APP_IDENTITY_HEADER = "x-docker-host-identity";
16
+ export function getAppId(config) {
17
+ return process.env.HOSTY_APP_ID?.trim() || config.appIdFallback;
18
+ }
19
+ export function getCoreOrigin() {
20
+ return process.env.HOSTY_CORE_ORIGIN?.trim() || null;
21
+ }
22
+ /** Browser-reachable Core origin. Core always injects it (falls back to its loopback
23
+ * listen URL on localhost-only installs), so a missing value is a broken environment. */
24
+ export function getCorePublicOrigin() {
25
+ return process.env.HOSTY_CORE_PUBLIC_ORIGIN?.trim() || null;
26
+ }
27
+ export function getServiceToken() {
28
+ return process.env.HOSTY_APP_SERVICE_TOKEN?.trim() || null;
29
+ }
30
+ export function getRecoveryParams(config) {
31
+ return { appId: getAppId(config), corePublicOrigin: getCorePublicOrigin() };
32
+ }
33
+ /**
34
+ * Reads the app identity token from a request: the app-origin cookie first (authoritative
35
+ * when present), then the Authorization bearer fallback the browser sends when the
36
+ * cross-site cookie is blocked, then the legacy inbound identity header.
37
+ */
38
+ export function readAppIdentityToken(headers, config) {
39
+ const cookieToken = readCookie(headers.get("cookie"), config.identityCookieName);
40
+ if (cookieToken) {
41
+ return cookieToken;
42
+ }
43
+ const authorization = headers.get("authorization")?.trim();
44
+ if (authorization?.toLowerCase().startsWith("bearer ")) {
45
+ const token = authorization.slice("bearer ".length).trim();
46
+ if (token) {
47
+ return token;
48
+ }
49
+ }
50
+ return headers.get(HOSTY_APP_IDENTITY_HEADER)?.trim() || null;
51
+ }
52
+ /** True when the effective request protocol is https (honouring the forwarded proto). */
53
+ export function isSecureRequest(headers, requestProtocol) {
54
+ const forwarded = headers.get("x-forwarded-proto");
55
+ if (forwarded) {
56
+ return forwarded.split(",")[0].trim().toLowerCase() === "https";
57
+ }
58
+ return requestProtocol === "https:";
59
+ }
60
+ /**
61
+ * Cookie attributes for the app-origin identity cookie. Cross-site Shell iframes need
62
+ * `SameSite=None; Secure` over https; plain http (local dev) cannot use `Secure` and
63
+ * falls back to `SameSite=Lax`. `maxAge` follows the grant's absolute lifetime.
64
+ */
65
+ export function identityCookieAttributes(secure, maxAgeSeconds) {
66
+ return {
67
+ httpOnly: true,
68
+ secure,
69
+ sameSite: secure ? "none" : "lax",
70
+ path: "/",
71
+ maxAge: Math.max(0, Math.floor(maxAgeSeconds)),
72
+ };
73
+ }
74
+ const revalidationCache = new Map();
75
+ const pendingRevalidations = new Map();
76
+ /** Test hook: the cache is process-global by design (route handlers are stateless). */
77
+ export function clearRevalidationCache() {
78
+ revalidationCache.clear();
79
+ pendingRevalidations.clear();
80
+ }
81
+ /**
82
+ * Revalidates an app identity token against Core with the app service token and
83
+ * classifies the outcome. Core identity tokens are opaque (`hostyg_` grants), so this
84
+ * round-trip is the only trustworthy validation; policy (disabled / unassigned / role
85
+ * downgrade) is re-checked online by Core on every call, which is why caching stays short.
86
+ */
87
+ export async function resolveAppSession(token, config) {
88
+ if (!token) {
89
+ return { status: "not-present" };
90
+ }
91
+ const serviceToken = getServiceToken();
92
+ const coreOrigin = getCoreOrigin();
93
+ if (!serviceToken || !coreOrigin) {
94
+ return { status: "misconfigured" };
95
+ }
96
+ const now = Date.now();
97
+ const cached = revalidationCache.get(token);
98
+ if (cached) {
99
+ if (now - cached.cachedAt < REVALIDATION_CACHE_TTL_MS && now < cached.expiresAtMs) {
100
+ return { status: "active", identity: cached.identity };
101
+ }
102
+ revalidationCache.delete(token);
103
+ }
104
+ const pending = pendingRevalidations.get(token);
105
+ if (pending) {
106
+ return pending;
107
+ }
108
+ const revalidation = revalidateWithCore(token, coreOrigin, serviceToken, config);
109
+ pendingRevalidations.set(token, revalidation);
110
+ try {
111
+ return await revalidation;
112
+ }
113
+ finally {
114
+ pendingRevalidations.delete(token);
115
+ }
116
+ }
117
+ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
118
+ let endpoint;
119
+ try {
120
+ endpoint = new URL("/api/auth/apps/revalidate", coreOrigin).toString();
121
+ }
122
+ catch {
123
+ return { status: "misconfigured" };
124
+ }
125
+ let response;
126
+ try {
127
+ response = await fetch(endpoint, {
128
+ method: "POST",
129
+ headers: {
130
+ "content-type": "application/json",
131
+ authorization: `Bearer ${serviceToken}`,
132
+ },
133
+ body: JSON.stringify({ accessToken: token }),
134
+ cache: "no-store",
135
+ signal: AbortSignal.timeout(CORE_AUTH_TIMEOUT_MS),
136
+ });
137
+ }
138
+ catch {
139
+ // Network failure or timeout — transient either way; never cached.
140
+ return { status: "unavailable" };
141
+ }
142
+ if (!response.ok) {
143
+ return { status: classifyRevalidationHttpStatus(response.status) };
144
+ }
145
+ let payload;
146
+ try {
147
+ payload = (await response.json());
148
+ }
149
+ catch {
150
+ // A non-JSON / truncated body means the session is unverifiable, not proof it is invalid.
151
+ return { status: "unavailable" };
152
+ }
153
+ const appId = readString(payload?.appId);
154
+ if (appId && appId !== getAppId(config)) {
155
+ // A token minted for a different app is Core's token_app_mismatch — terminal, like 403.
156
+ return { status: "forbidden" };
157
+ }
158
+ const userId = readString(payload?.userId);
159
+ // An "active" grant without a subject is unusable; classify it as recoverable so a probe
160
+ // and the real auth path can never disagree about the same token.
161
+ if (!payload || payload.active !== true || !userId) {
162
+ return { status: "expired" };
163
+ }
164
+ const hostRole = readString(payload.hostRole);
165
+ const expiresAt = readString(payload.expiresAt);
166
+ const identity = {
167
+ userId,
168
+ email: readString(payload.email),
169
+ displayName: readString(payload.displayName),
170
+ hostRole,
171
+ appRole: config.mapHostRole ? config.mapHostRole(hostRole) : hostRole,
172
+ expiresAt,
173
+ };
174
+ const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
175
+ if (Number.isFinite(expiresAtMs) && expiresAtMs > Date.now()) {
176
+ pruneRevalidationCache();
177
+ revalidationCache.set(token, { identity, expiresAtMs, cachedAt: Date.now() });
178
+ }
179
+ return { status: "active", identity };
180
+ }
181
+ function pruneRevalidationCache() {
182
+ if (revalidationCache.size < MAX_REVALIDATION_CACHE_ENTRIES) {
183
+ return;
184
+ }
185
+ const now = Date.now();
186
+ for (const [token, entry] of revalidationCache) {
187
+ if (now - entry.cachedAt >= REVALIDATION_CACHE_TTL_MS || now >= entry.expiresAtMs) {
188
+ revalidationCache.delete(token);
189
+ }
190
+ }
191
+ while (revalidationCache.size >= MAX_REVALIDATION_CACHE_ENTRIES) {
192
+ const oldest = revalidationCache.keys().next().value;
193
+ if (oldest === undefined) {
194
+ return;
195
+ }
196
+ revalidationCache.delete(oldest);
197
+ }
198
+ }
199
+ /**
200
+ * Cookie-only probe classification for the client recovery bridge. Deliberately ignores
201
+ * bearer/header tokens: probe routes are public, and honoring caller-supplied tokens would
202
+ * turn them into an unauthenticated oracle that revalidates attacker-chosen tokens against
203
+ * Core. The recovery contract only ever concerns *this browser's* session, which lives in
204
+ * the HttpOnly app cookie.
205
+ */
206
+ export async function classifyAppSessionFromCookie(headers, config) {
207
+ const token = readCookie(headers.get("cookie"), config.identityCookieName);
208
+ const resolution = await resolveAppSession(token, config);
209
+ return resolution.status;
210
+ }
211
+ // Follow the grant's absolute lifetime (expiresInSeconds). The bound is only a sanity
212
+ // ceiling against a malformed response: 400 days is the maximum cookie Max-Age browsers
213
+ // honor (RFC 6265bis), so it never truncates a configured absolute lifetime.
214
+ const MAX_COOKIE_AGE_SECONDS = 400 * 24 * 60 * 60;
215
+ /** Exchanges a one-time Shell/Core launch code for an identity token at Core. The code is
216
+ * the only credential — the endpoint is deliberately unauthenticated on Core's side. */
217
+ export async function exchangeAppCode(code) {
218
+ const coreOrigin = getCoreOrigin();
219
+ let endpoint;
220
+ try {
221
+ endpoint = new URL("/api/auth/apps/token", coreOrigin ?? "").toString();
222
+ }
223
+ catch {
224
+ return { ok: false, status: 503, code: "core_origin_invalid", message: "HOSTY_CORE_ORIGIN is not a valid URL." };
225
+ }
226
+ let response;
227
+ try {
228
+ response = await fetch(endpoint, {
229
+ method: "POST",
230
+ headers: { "content-type": "application/json" },
231
+ body: JSON.stringify({ code }),
232
+ cache: "no-store",
233
+ signal: AbortSignal.timeout(CORE_AUTH_TIMEOUT_MS),
234
+ });
235
+ }
236
+ catch (error) {
237
+ const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
238
+ return {
239
+ ok: false,
240
+ status: 503,
241
+ code: timeout ? "core_token_exchange_timeout" : "core_token_exchange_unavailable",
242
+ message: "Hosty Core could not be reached to exchange the authorization code.",
243
+ };
244
+ }
245
+ if (!response.ok) {
246
+ return {
247
+ ok: false,
248
+ status: 401,
249
+ code: "app_auth_code_rejected",
250
+ message: `Core rejected the authorization code (HTTP ${response.status}).`,
251
+ };
252
+ }
253
+ const payload = (await response.json().catch(() => null));
254
+ const accessToken = readString(payload?.accessToken);
255
+ if (!accessToken) {
256
+ return { ok: false, status: 502, code: "app_identity_token_missing", message: "Core returned no usable identity token." };
257
+ }
258
+ // A missing/malformed lifetime is tolerated (the cookie falls back to the browser
259
+ // Max-Age ceiling) — the server-side grant still expires on schedule regardless, and
260
+ // recovery handles the eventual 401.
261
+ const expiresInSeconds = typeof payload?.expiresInSeconds === "number" && payload.expiresInSeconds > 0
262
+ ? payload.expiresInSeconds
263
+ : null;
264
+ return { ok: true, accessToken, expiresInSeconds };
265
+ }
266
+ /**
267
+ * Route-handler factory for `POST /api/auth/app-code`: exchanges the one-time `code` for
268
+ * an identity token, stores it in the app-origin HttpOnly cookie, and returns the token so
269
+ * the browser can keep a bearer fallback for when the cross-site cookie is blocked.
270
+ * Framework-agnostic (Web `Request`/`Response`), usable directly as a Next route handler.
271
+ */
272
+ export function createAppCodeRouteHandler(config) {
273
+ return async function POST(request) {
274
+ let body = null;
275
+ try {
276
+ body = await request.json();
277
+ }
278
+ catch {
279
+ // Fall through to the missing-code response.
280
+ }
281
+ const code = body && typeof body === "object" ? readString(body.code) : null;
282
+ if (!code) {
283
+ return jsonResponse({ code: "app_auth_code_required", message: "A Hosty app authorization code is required." }, 422);
284
+ }
285
+ const exchange = await exchangeAppCode(code);
286
+ if (!exchange.ok) {
287
+ return jsonResponse({ code: exchange.code, message: exchange.message }, exchange.status);
288
+ }
289
+ const secure = isSecureRequest(request.headers, safeProtocol(request.url));
290
+ const attributes = identityCookieAttributes(secure, Math.min(exchange.expiresInSeconds ?? MAX_COOKIE_AGE_SECONDS, MAX_COOKIE_AGE_SECONDS));
291
+ const cookie = [
292
+ `${config.identityCookieName}=${encodeURIComponent(exchange.accessToken)}`,
293
+ `Path=${attributes.path}`,
294
+ `Max-Age=${attributes.maxAge}`,
295
+ `SameSite=${attributes.sameSite === "none" ? "None" : "Lax"}`,
296
+ "HttpOnly",
297
+ ...(attributes.secure ? ["Secure"] : []),
298
+ ].join("; ");
299
+ return jsonResponse({ accessToken: exchange.accessToken, expiresInSeconds: exchange.expiresInSeconds }, 200, { "set-cookie": cookie });
300
+ };
301
+ }
302
+ function safeProtocol(url) {
303
+ try {
304
+ return new URL(url).protocol;
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ }
310
+ function jsonResponse(body, status, headers) {
311
+ return new Response(JSON.stringify(body), {
312
+ status,
313
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...headers },
314
+ });
315
+ }
316
+ function readCookie(cookieHeader, name) {
317
+ for (const entry of cookieHeader?.split(";") ?? []) {
318
+ const separator = entry.indexOf("=");
319
+ if (separator < 0 || entry.slice(0, separator).trim() !== name) {
320
+ continue;
321
+ }
322
+ try {
323
+ const value = decodeURIComponent(entry.slice(separator + 1).trim());
324
+ return value || null;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ function readString(value) {
333
+ return typeof value === "string" && value.trim() ? value.trim() : null;
334
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@hosty-sdk/app",
3
+ "version": "0.1.0",
4
+ "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./server": {
13
+ "types": "./dist/server.d.ts",
14
+ "default": "./dist/server.js"
15
+ },
16
+ "./react": {
17
+ "types": "./dist/react.d.ts",
18
+ "default": "./dist/react.js"
19
+ },
20
+ "./embedder": {
21
+ "types": "./dist/embedder.d.ts",
22
+ "default": "./dist/embedder.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "build": "tsc -p tsconfig.build.json"
28
+ },
29
+ "dependencies": {
30
+ "server-only": "^0.0.1"
31
+ },
32
+ "peerDependencies": {
33
+ "next": ">=16",
34
+ "react": ">=19"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "next": {
38
+ "optional": true
39
+ },
40
+ "react": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5",
46
+ "vitest": "^4",
47
+ "@types/node": "^24",
48
+ "@types/react": "^19"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/alex-de-haas/docker-host.git",
53
+ "directory": "packages/app-sdk"
54
+ },
55
+ "files": [
56
+ "dist",
57
+ "README.md"
58
+ ],
59
+ "sideEffects": false,
60
+ "publishConfig": {
61
+ "access": "public"
62
+ },
63
+ "types": "./dist/index.d.ts"
64
+ }