@horizonmedia/blu-sdk 0.0.0-stage → 1.8.4
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 +552 -2
- package/bin/blu.js +343 -0
- package/chunks/chunk-CSQHQLB7.js +163 -0
- package/chunks/chunk-M7GVSOGQ.js +179 -0
- package/dev.js +374 -0
- package/frame.js +134 -0
- package/index.js +34 -0
- package/package.json +54 -4
- package/server.js +466 -0
- package/types/dev.d.ts +48 -0
- package/types/frame.d.ts +139 -0
- package/types/index.d.ts +176 -0
- package/types/server.d.ts +448 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/signin.ts
|
|
2
|
+
var STORAGE_KEY = "blu.signin";
|
|
3
|
+
var MAX_AGE_MS = 10 * 6e4;
|
|
4
|
+
var b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5
|
+
var random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
|
|
6
|
+
async function beginBluSignIn({ hostApi, clientId, redirectUri }) {
|
|
7
|
+
const verifier = random(32);
|
|
8
|
+
const state = random(16);
|
|
9
|
+
const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
10
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ state, verifier, hostApi, clientId, redirectUri, at: Date.now() }));
|
|
11
|
+
const url = new URL("/oauth/authorize", hostApi);
|
|
12
|
+
url.search = new URLSearchParams({
|
|
13
|
+
response_type: "code",
|
|
14
|
+
client_id: clientId,
|
|
15
|
+
redirect_uri: redirectUri,
|
|
16
|
+
state,
|
|
17
|
+
code_challenge: challenge,
|
|
18
|
+
code_challenge_method: "S256"
|
|
19
|
+
}).toString();
|
|
20
|
+
window.location.assign(url.toString());
|
|
21
|
+
}
|
|
22
|
+
var completion = null;
|
|
23
|
+
function completeBluSignIn() {
|
|
24
|
+
completion ??= finish();
|
|
25
|
+
return completion;
|
|
26
|
+
}
|
|
27
|
+
async function finish() {
|
|
28
|
+
const params = new URLSearchParams(window.location.search);
|
|
29
|
+
if (!params.has("code") && !params.has("error")) return { kind: "none" };
|
|
30
|
+
window.history.replaceState(null, "", window.location.pathname);
|
|
31
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
32
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
33
|
+
const saved = raw ? JSON.parse(raw) : null;
|
|
34
|
+
const fail = (error, description) => ({ kind: "error", error, description });
|
|
35
|
+
if (!saved) return fail("no_pending_sign_in", "This tab didn't start a Blu sign-in. Start again.");
|
|
36
|
+
if (params.get("state") !== saved.state) return fail("state_mismatch", "The sign-in response doesn't belong to this tab's request (possible login CSRF). Start again.");
|
|
37
|
+
if (params.get("iss") !== saved.hostApi) return fail("issuer_mismatch", `Response issuer ${params.get("iss")} \u2260 ${saved.hostApi}.`);
|
|
38
|
+
if (params.has("error")) return fail(params.get("error"), params.get("error_description") ?? "Blu refused the sign-in.");
|
|
39
|
+
if (Date.now() - saved.at > MAX_AGE_MS) return fail("expired", "The sign-in took too long. Start again.");
|
|
40
|
+
const res = await fetch(`${saved.hostApi}/oauth/token`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
43
|
+
body: new URLSearchParams({
|
|
44
|
+
grant_type: "authorization_code",
|
|
45
|
+
code: params.get("code"),
|
|
46
|
+
client_id: saved.clientId,
|
|
47
|
+
redirect_uri: saved.redirectUri,
|
|
48
|
+
code_verifier: saved.verifier
|
|
49
|
+
}),
|
|
50
|
+
credentials: "omit"
|
|
51
|
+
});
|
|
52
|
+
const body = await res.json().catch(() => ({}));
|
|
53
|
+
if (!res.ok) return fail(body.error ?? "token_error", body.detail ?? `Blu token endpoint returned ${res.status}.`);
|
|
54
|
+
return { kind: "signed-in", tokens: body, hostApi: saved.hostApi };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/core.tsx
|
|
58
|
+
import { createContext, useContext, useMemo } from "react";
|
|
59
|
+
import { jsx } from "react/jsx-runtime";
|
|
60
|
+
var CONTRACT_VERSION = "1.3.0";
|
|
61
|
+
function assertHostContext(value) {
|
|
62
|
+
const v = value;
|
|
63
|
+
const problems = [];
|
|
64
|
+
if (!v || typeof v !== "object") throw new Error("[@blu/sdk] host context is missing");
|
|
65
|
+
const [major] = String(v.contractVersion ?? "").split(".");
|
|
66
|
+
if (major !== CONTRACT_VERSION.split(".")[0]) {
|
|
67
|
+
problems.push(`contract major mismatch: host=${v.contractVersion} sdk=${CONTRACT_VERSION}`);
|
|
68
|
+
}
|
|
69
|
+
if (!v.user?.id) problems.push("user.id");
|
|
70
|
+
if (!v.client?.id) problems.push("client.id");
|
|
71
|
+
if (!v.environment?.apiBaseUrl) problems.push("environment.apiBaseUrl");
|
|
72
|
+
if (typeof v.getToken !== "function") problems.push("getToken()");
|
|
73
|
+
if (!v.events || typeof v.events.emit !== "function") problems.push("events");
|
|
74
|
+
if (problems.length) {
|
|
75
|
+
throw new Error(`[@blu/sdk] invalid host context: ${problems.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
var HostContext = createContext(null);
|
|
79
|
+
function BluHostProvider({ value, children }) {
|
|
80
|
+
assertHostContext(value);
|
|
81
|
+
return /* @__PURE__ */ jsx(HostContext.Provider, { value, children });
|
|
82
|
+
}
|
|
83
|
+
function useHost() {
|
|
84
|
+
const ctx = useContext(HostContext);
|
|
85
|
+
if (!ctx) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"[@blu/sdk] useHost() called outside <BluHostProvider>. If this is a remote, check that @blu/sdk is shared as a singleton (two copies = two contexts)."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return ctx;
|
|
91
|
+
}
|
|
92
|
+
function useOptionalHost() {
|
|
93
|
+
return useContext(HostContext);
|
|
94
|
+
}
|
|
95
|
+
var useUser = () => useHost().user;
|
|
96
|
+
var useClient = () => useHost().client;
|
|
97
|
+
var useRoles = () => useHost().roles;
|
|
98
|
+
var useEnvironment = () => useHost().environment;
|
|
99
|
+
var useTheme = () => useHost().theme;
|
|
100
|
+
var useEventBus = () => useHost().events;
|
|
101
|
+
var useShell = () => useHost().shell;
|
|
102
|
+
var useAuthToken = () => useHost().getToken;
|
|
103
|
+
var useRoute = () => useHost().route ?? NO_ROUTE;
|
|
104
|
+
var NO_ROUTE = { path: "/", search: "", navigate: () => {
|
|
105
|
+
} };
|
|
106
|
+
var ApiError = class extends Error {
|
|
107
|
+
constructor(status, body, url) {
|
|
108
|
+
super(`${status} from ${url}`);
|
|
109
|
+
this.status = status;
|
|
110
|
+
this.body = body;
|
|
111
|
+
this.url = url;
|
|
112
|
+
}
|
|
113
|
+
status;
|
|
114
|
+
body;
|
|
115
|
+
url;
|
|
116
|
+
};
|
|
117
|
+
function createApiClient(env, getToken) {
|
|
118
|
+
const base = new URL(env.apiBaseUrl);
|
|
119
|
+
async function request(method, path, body, init = {}) {
|
|
120
|
+
const target = new URL(path, base);
|
|
121
|
+
if (target.origin !== base.origin) {
|
|
122
|
+
throw new Error(`[@blu/sdk] refused to send this module's token to ${target.origin}: the API client only calls the module's own API (${base.origin}).`);
|
|
123
|
+
}
|
|
124
|
+
const url = target.toString();
|
|
125
|
+
const headers = new Headers(init.headers);
|
|
126
|
+
headers.set("Authorization", `Bearer ${await getToken()}`);
|
|
127
|
+
if (body !== void 0) headers.set("Content-Type", "application/json");
|
|
128
|
+
const res = await fetch(url, {
|
|
129
|
+
...init,
|
|
130
|
+
method,
|
|
131
|
+
headers,
|
|
132
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
133
|
+
credentials: "omit",
|
|
134
|
+
redirect: "error"
|
|
135
|
+
// a token-bearing call has no business being sent on elsewhere
|
|
136
|
+
});
|
|
137
|
+
const text = await res.text();
|
|
138
|
+
const parsed = text ? safeJson(text) : null;
|
|
139
|
+
if (!res.ok) throw new ApiError(res.status, parsed, url);
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
baseUrl: env.apiBaseUrl,
|
|
144
|
+
get: (path, init) => request("GET", path, void 0, init),
|
|
145
|
+
post: (path, body, init) => request("POST", path, body, init)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function useApiClient() {
|
|
149
|
+
const { environment, getToken } = useHost();
|
|
150
|
+
return useMemo(() => createApiClient(environment, getToken), [environment, getToken]);
|
|
151
|
+
}
|
|
152
|
+
function safeJson(text) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(text);
|
|
155
|
+
} catch {
|
|
156
|
+
return text;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export {
|
|
161
|
+
beginBluSignIn,
|
|
162
|
+
completeBluSignIn,
|
|
163
|
+
CONTRACT_VERSION,
|
|
164
|
+
assertHostContext,
|
|
165
|
+
BluHostProvider,
|
|
166
|
+
useHost,
|
|
167
|
+
useOptionalHost,
|
|
168
|
+
useUser,
|
|
169
|
+
useClient,
|
|
170
|
+
useRoles,
|
|
171
|
+
useEnvironment,
|
|
172
|
+
useTheme,
|
|
173
|
+
useEventBus,
|
|
174
|
+
useShell,
|
|
175
|
+
useAuthToken,
|
|
176
|
+
useRoute,
|
|
177
|
+
ApiError,
|
|
178
|
+
useApiClient
|
|
179
|
+
};
|
package/dev.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BluHostProvider,
|
|
3
|
+
CONTRACT_VERSION,
|
|
4
|
+
beginBluSignIn,
|
|
5
|
+
completeBluSignIn
|
|
6
|
+
} from "./chunks/chunk-M7GVSOGQ.js";
|
|
7
|
+
|
|
8
|
+
// src/dev/index.tsx
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { createRoot } from "react-dom/client";
|
|
11
|
+
|
|
12
|
+
// src/dev/auth.ts
|
|
13
|
+
async function postJson(url, body, token) {
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json", ...token ? { Authorization: `Bearer ${token}` } : {} },
|
|
17
|
+
body: JSON.stringify(body)
|
|
18
|
+
});
|
|
19
|
+
const json = await res.json().catch(() => ({}));
|
|
20
|
+
if (!res.ok) throw new Error(json.detail ?? json.error ?? `HTTP ${res.status}`);
|
|
21
|
+
return json;
|
|
22
|
+
}
|
|
23
|
+
function teamProvider(label, baseUrl) {
|
|
24
|
+
return {
|
|
25
|
+
id: "team",
|
|
26
|
+
label,
|
|
27
|
+
issuer: baseUrl,
|
|
28
|
+
flow: "personas",
|
|
29
|
+
async personas() {
|
|
30
|
+
const res = await fetch(`${baseUrl}/personas`);
|
|
31
|
+
if (!res.ok) throw new Error(`team IdP unreachable (${res.status})`);
|
|
32
|
+
const { personas } = await res.json();
|
|
33
|
+
return personas.map((p) => ({ id: p.username, label: p.label }));
|
|
34
|
+
},
|
|
35
|
+
login: (username) => postJson(`${baseUrl}/login`, { username }),
|
|
36
|
+
// The simulated team IdP has no refresh grant: the user signs in again.
|
|
37
|
+
refresh: async () => null,
|
|
38
|
+
switchClient: (current, clientId) => postJson(`${baseUrl}/switch-client`, { clientId }, current.accessToken),
|
|
39
|
+
async clientNames() {
|
|
40
|
+
const res = await fetch(`${baseUrl}/clients`);
|
|
41
|
+
return res.ok ? res.json() : {};
|
|
42
|
+
},
|
|
43
|
+
describe: (c) => ({
|
|
44
|
+
name: String(c.name ?? c.sub),
|
|
45
|
+
email: String(c.email ?? ""),
|
|
46
|
+
clientId: String(c.cid),
|
|
47
|
+
clients: c.clients ?? [],
|
|
48
|
+
roles: c.role ? [String(c.role)] : []
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function fromBluTokens(t) {
|
|
53
|
+
return { accessToken: t.access_token, expiresAt: Date.now() + t.expires_in * 1e3, refreshToken: t.refresh_token, clients: t.blu_clients };
|
|
54
|
+
}
|
|
55
|
+
function bluProvider(label, hostApi, moduleId) {
|
|
56
|
+
const token = async (params) => {
|
|
57
|
+
const res = await fetch(`${hostApi}/oauth/token`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
60
|
+
body: new URLSearchParams({ client_id: moduleId, ...params }),
|
|
61
|
+
credentials: "omit"
|
|
62
|
+
});
|
|
63
|
+
const json = await res.json().catch(() => ({}));
|
|
64
|
+
if (!res.ok) throw new Error(json.detail ?? json.error ?? `HTTP ${res.status}`);
|
|
65
|
+
return fromBluTokens(json);
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
id: "blu",
|
|
69
|
+
label,
|
|
70
|
+
issuer: hostApi,
|
|
71
|
+
flow: "redirect",
|
|
72
|
+
personas: async () => [],
|
|
73
|
+
login: async () => {
|
|
74
|
+
throw new Error("Sign in with Blu happens on Blu's sign-in page");
|
|
75
|
+
},
|
|
76
|
+
beginRedirect: () => beginBluSignIn({ hostApi, clientId: moduleId, redirectUri: `${window.location.origin}/` }),
|
|
77
|
+
refresh: async (current) => current.refreshToken ? token({ grant_type: "refresh_token", refresh_token: current.refreshToken }) : null,
|
|
78
|
+
switchClient: (current, clientId) => token({ grant_type: "refresh_token", refresh_token: current.refreshToken ?? "", blu_client_id: clientId }),
|
|
79
|
+
clientNames: async (current) => Object.fromEntries((current.clients ?? []).map((c) => [c.id, c.name])),
|
|
80
|
+
describe: (c) => ({
|
|
81
|
+
name: String(c.name ?? c.sub),
|
|
82
|
+
email: String(c.email ?? ""),
|
|
83
|
+
clientId: String(c.cid),
|
|
84
|
+
clients: c.tenant_scope ?? [],
|
|
85
|
+
roles: c.roles ?? []
|
|
86
|
+
})
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function decodeClaims(token) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
92
|
+
} catch {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/dev/index.tsx
|
|
98
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
99
|
+
var DEFAULT_THEME = {
|
|
100
|
+
primary: "#3b5bdb",
|
|
101
|
+
surface: "#ffffff",
|
|
102
|
+
surfaceAlt: "#f4f6fb",
|
|
103
|
+
text: "#1b1f2a",
|
|
104
|
+
textMuted: "#5c6478",
|
|
105
|
+
border: "#dde2ee",
|
|
106
|
+
danger: "#c92a2a",
|
|
107
|
+
success: "#2b8a3e",
|
|
108
|
+
radius: "10px",
|
|
109
|
+
font: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
|
110
|
+
};
|
|
111
|
+
async function mountStandalone({ Page, rootId = "root" }) {
|
|
112
|
+
const [config, deployment] = await Promise.all(
|
|
113
|
+
["./standalone.config.json", "./deployment.json"].map(async (u) => (await fetch(u, { cache: "no-store" })).json())
|
|
114
|
+
);
|
|
115
|
+
document.title = `${config.title} \u2014 standalone (${deployment.stage})`;
|
|
116
|
+
createRoot(document.getElementById(rootId)).render(/* @__PURE__ */ jsx(Standalone, { config, deployment, Page }));
|
|
117
|
+
}
|
|
118
|
+
function Standalone({ config, deployment, Page }) {
|
|
119
|
+
const providers = useMemo(() => {
|
|
120
|
+
const list = [];
|
|
121
|
+
if (config.auth.team) list.push(teamProvider(config.auth.team.label, deployment.teamAuthUrl));
|
|
122
|
+
if (config.auth.blu) list.push(bluProvider(config.auth.blu.label, deployment.bluHostApi, config.moduleId));
|
|
123
|
+
return list;
|
|
124
|
+
}, [config, deployment]);
|
|
125
|
+
const [session, setSession] = useState(null);
|
|
126
|
+
const [notice, setNotice] = useState(null);
|
|
127
|
+
const [returning, setReturning] = useState(() => new URLSearchParams(window.location.search).has("code"));
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const blu = providers.find((p) => p.id === "blu");
|
|
130
|
+
if (!blu) return;
|
|
131
|
+
completeBluSignIn().then((r) => {
|
|
132
|
+
setReturning(false);
|
|
133
|
+
if (r.kind === "signed-in") setSession({ provider: blu, tokens: fromBluTokens(r.tokens) });
|
|
134
|
+
else if (r.kind === "error") setNotice(`Sign in with Blu didn't complete: ${r.description}`);
|
|
135
|
+
});
|
|
136
|
+
}, [providers]);
|
|
137
|
+
if (returning && !session) return /* @__PURE__ */ jsx("div", { style: styles.page, children: "Finishing sign-in with Blu\u2026" });
|
|
138
|
+
if (!session) {
|
|
139
|
+
return /* @__PURE__ */ jsx(
|
|
140
|
+
Login,
|
|
141
|
+
{
|
|
142
|
+
config,
|
|
143
|
+
deployment,
|
|
144
|
+
providers,
|
|
145
|
+
notice,
|
|
146
|
+
onSession: (s) => {
|
|
147
|
+
setNotice(null);
|
|
148
|
+
setSession(s);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return /* @__PURE__ */ jsx(
|
|
154
|
+
Signed,
|
|
155
|
+
{
|
|
156
|
+
config,
|
|
157
|
+
deployment,
|
|
158
|
+
session,
|
|
159
|
+
setSession,
|
|
160
|
+
onSignOut: (why) => {
|
|
161
|
+
setNotice(why ?? null);
|
|
162
|
+
setSession(null);
|
|
163
|
+
},
|
|
164
|
+
Page
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
function Login({
|
|
169
|
+
config,
|
|
170
|
+
deployment,
|
|
171
|
+
providers,
|
|
172
|
+
notice,
|
|
173
|
+
onSession
|
|
174
|
+
}) {
|
|
175
|
+
const [personas, setPersonas] = useState({});
|
|
176
|
+
const [error, setError] = useState(null);
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
providers.filter((p) => p.flow === "personas").forEach(
|
|
179
|
+
(p) => p.personas().then((list) => setPersonas((s) => ({ ...s, [p.id]: list }))).catch((e) => setPersonas((s) => ({ ...s, [p.id]: String(e.message ?? e) })))
|
|
180
|
+
);
|
|
181
|
+
}, [providers]);
|
|
182
|
+
return /* @__PURE__ */ jsx("div", { style: styles.page, children: /* @__PURE__ */ jsxs("div", { style: styles.card, children: [
|
|
183
|
+
/* @__PURE__ */ jsxs("div", { style: styles.kicker, children: [
|
|
184
|
+
config.moduleId,
|
|
185
|
+
" \xB7 standalone \xB7 ",
|
|
186
|
+
deployment.stage
|
|
187
|
+
] }),
|
|
188
|
+
/* @__PURE__ */ jsx("h1", { style: { fontSize: 22, margin: "4px 0 8px" }, children: config.title }),
|
|
189
|
+
/* @__PURE__ */ jsxs("p", { style: styles.muted, children: [
|
|
190
|
+
"Running on its own origin (",
|
|
191
|
+
/* @__PURE__ */ jsx("code", { children: window.location.origin }),
|
|
192
|
+
") without the Blu host. Sign in with",
|
|
193
|
+
" ",
|
|
194
|
+
providers.map((p) => p.label).join(" or "),
|
|
195
|
+
"; either way you get a token this module's API accepts."
|
|
196
|
+
] }),
|
|
197
|
+
notice && /* @__PURE__ */ jsx("p", { style: styles.warn, children: notice }),
|
|
198
|
+
error && /* @__PURE__ */ jsx("p", { style: styles.warn, children: error }),
|
|
199
|
+
providers.length === 0 && /* @__PURE__ */ jsx("p", { style: styles.warn, children: "No sign-in providers are enabled in standalone.config.json." }),
|
|
200
|
+
providers.map((p) => {
|
|
201
|
+
const list = personas[p.id];
|
|
202
|
+
return /* @__PURE__ */ jsxs("section", { style: styles.section, "data-provider": p.id, children: [
|
|
203
|
+
/* @__PURE__ */ jsx("h2", { style: { fontSize: 15, margin: "0 0 4px" }, children: p.label }),
|
|
204
|
+
/* @__PURE__ */ jsxs("div", { style: { ...styles.muted, fontSize: 12, marginBottom: 8 }, children: [
|
|
205
|
+
"issuer ",
|
|
206
|
+
/* @__PURE__ */ jsx("code", { children: p.issuer })
|
|
207
|
+
] }),
|
|
208
|
+
p.flow === "redirect" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
209
|
+
/* @__PURE__ */ jsxs("p", { style: { ...styles.muted, fontSize: 13, margin: "0 0 6px" }, children: [
|
|
210
|
+
"You'll sign in on Blu's own page and come back here. This app gets a token for",
|
|
211
|
+
" ",
|
|
212
|
+
/* @__PURE__ */ jsx("strong", { children: config.title }),
|
|
213
|
+
" only \u2014 never your Blu session \u2014 with the role Blu gives you. No",
|
|
214
|
+
" ",
|
|
215
|
+
config.title,
|
|
216
|
+
" account needed."
|
|
217
|
+
] }),
|
|
218
|
+
/* @__PURE__ */ jsx(
|
|
219
|
+
"button",
|
|
220
|
+
{
|
|
221
|
+
style: { ...styles.persona, ...styles.badgeBlu, fontWeight: 600 },
|
|
222
|
+
onClick: () => p.beginRedirect?.().catch((e) => setError(`${p.label}: ${e.message}`)),
|
|
223
|
+
children: "Continue with Blu \u2192"
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
] }) : typeof list === "string" ? /* @__PURE__ */ jsx("p", { style: styles.warn, children: list }) : !list ? /* @__PURE__ */ jsx("p", { style: styles.muted, children: "Loading\u2026" }) : list.map((persona) => /* @__PURE__ */ jsx(
|
|
227
|
+
"button",
|
|
228
|
+
{
|
|
229
|
+
style: styles.persona,
|
|
230
|
+
onClick: async () => {
|
|
231
|
+
try {
|
|
232
|
+
onSession({ provider: p, tokens: await p.login(persona.id) });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
setError(`${p.label}: ${e.message}`);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
children: persona.label
|
|
238
|
+
},
|
|
239
|
+
persona.id
|
|
240
|
+
))
|
|
241
|
+
] }, p.id);
|
|
242
|
+
})
|
|
243
|
+
] }) });
|
|
244
|
+
}
|
|
245
|
+
function Signed({
|
|
246
|
+
config,
|
|
247
|
+
deployment,
|
|
248
|
+
session,
|
|
249
|
+
setSession,
|
|
250
|
+
onSignOut,
|
|
251
|
+
Page
|
|
252
|
+
}) {
|
|
253
|
+
const latest = useRef({ session, setSession, onSignOut });
|
|
254
|
+
latest.current = { session, setSession, onSignOut };
|
|
255
|
+
const [events, setEvents] = useState([]);
|
|
256
|
+
const [routePath, setRoutePath] = useState("/");
|
|
257
|
+
const claims = decodeClaims(session.tokens.accessToken);
|
|
258
|
+
const who = session.provider.describe(claims);
|
|
259
|
+
const [names, setNames] = useState({});
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
session.provider.clientNames(session.tokens).then(setNames, () => setNames({}));
|
|
262
|
+
}, [session.provider]);
|
|
263
|
+
const clientName = (id) => names[id] ?? id;
|
|
264
|
+
const getToken = useCallback(async () => {
|
|
265
|
+
const { session: s, setSession: set, onSignOut: signOut } = latest.current;
|
|
266
|
+
if (s.tokens.expiresAt - Date.now() > 3e4) return s.tokens.accessToken;
|
|
267
|
+
const next = await s.provider.refresh(s.tokens).catch(() => null);
|
|
268
|
+
if (!next) {
|
|
269
|
+
signOut(`Your ${s.provider.label} session expired \u2014 sign in again.`);
|
|
270
|
+
throw new Error("session expired");
|
|
271
|
+
}
|
|
272
|
+
set({ ...s, tokens: next });
|
|
273
|
+
return next.accessToken;
|
|
274
|
+
}, []);
|
|
275
|
+
const bus = useMemo(() => {
|
|
276
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
277
|
+
return {
|
|
278
|
+
emit(type, payload) {
|
|
279
|
+
const e = { type, source: config.moduleId, payload, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
280
|
+
setEvents((list) => [e, ...list].slice(0, 5));
|
|
281
|
+
for (const key of [type, "*"]) handlers.get(key)?.forEach((h) => h(e));
|
|
282
|
+
},
|
|
283
|
+
on(type, h) {
|
|
284
|
+
if (!handlers.has(type)) handlers.set(type, /* @__PURE__ */ new Set());
|
|
285
|
+
handlers.get(type).add(h);
|
|
286
|
+
return () => void handlers.get(type)?.delete(h);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}, [config.moduleId]);
|
|
290
|
+
const value = useMemo(
|
|
291
|
+
() => ({
|
|
292
|
+
contractVersion: CONTRACT_VERSION,
|
|
293
|
+
user: { id: String(claims.sub), email: who.email, displayName: who.name, provider: session.provider.id },
|
|
294
|
+
client: { id: who.clientId, name: clientName(who.clientId) },
|
|
295
|
+
roles: who.roles,
|
|
296
|
+
// Same descriptor shape the Blu shell provides; here the stage + API come from deployment.json.
|
|
297
|
+
environment: { stage: deployment.stage, apiBaseUrl: deployment.apiBaseUrl, moduleId: config.moduleId },
|
|
298
|
+
theme: DEFAULT_THEME,
|
|
299
|
+
embedded: { inIframe: false, parentOrigin: null },
|
|
300
|
+
shell: { kind: "standalone", label: `${config.title} standalone app (${deployment.stage})`, clientChosenBy: "standalone", mount: "standalone" },
|
|
301
|
+
route: { path: routePath, navigate: (p) => setRoutePath(p.startsWith("/") ? p : `/${p}`) },
|
|
302
|
+
getToken,
|
|
303
|
+
navigate: (path) => console.info(`[standalone] navigate(${path}) \u2014 no host nav outside Blu`),
|
|
304
|
+
events: bus
|
|
305
|
+
}),
|
|
306
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
307
|
+
[claims.sub, who.clientId, who.roles.join(), session.provider.id, config, deployment, names, getToken, bus, routePath]
|
|
308
|
+
);
|
|
309
|
+
return /* @__PURE__ */ jsxs("div", { style: { minHeight: "100%", background: DEFAULT_THEME.surfaceAlt, fontFamily: DEFAULT_THEME.font }, children: [
|
|
310
|
+
/* @__PURE__ */ jsxs("header", { style: styles.bar, children: [
|
|
311
|
+
/* @__PURE__ */ jsx("strong", { children: config.title }),
|
|
312
|
+
/* @__PURE__ */ jsxs("span", { style: styles.badge, children: [
|
|
313
|
+
"standalone \xB7 ",
|
|
314
|
+
window.location.host
|
|
315
|
+
] }),
|
|
316
|
+
/* @__PURE__ */ jsx("span", { style: { ...styles.badge, fontWeight: 700 }, "data-stage": deployment.stage, children: deployment.stage }),
|
|
317
|
+
/* @__PURE__ */ jsxs("span", { style: { ...styles.badge, ...session.provider.id === "team" ? styles.badgeTeam : styles.badgeBlu }, "data-auth-mode": session.provider.id, children: [
|
|
318
|
+
"signed in via ",
|
|
319
|
+
session.provider.label
|
|
320
|
+
] }),
|
|
321
|
+
/* @__PURE__ */ jsx("span", { style: { flex: 1 } }),
|
|
322
|
+
/* @__PURE__ */ jsxs("label", { "data-standalone-client": who.clientId, style: { fontSize: 12, color: DEFAULT_THEME.textMuted, display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
323
|
+
"Client",
|
|
324
|
+
/* @__PURE__ */ jsx(
|
|
325
|
+
"select",
|
|
326
|
+
{
|
|
327
|
+
value: who.clientId,
|
|
328
|
+
onChange: async (e) => {
|
|
329
|
+
try {
|
|
330
|
+
setSession({ ...session, tokens: await session.provider.switchClient(session.tokens, e.target.value) });
|
|
331
|
+
} catch (err) {
|
|
332
|
+
alert(err.message);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
children: who.clients.map((c) => /* @__PURE__ */ jsx("option", { value: c, children: clientName(c) }, c))
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
] }),
|
|
339
|
+
/* @__PURE__ */ jsxs("span", { style: { fontSize: 13 }, children: [
|
|
340
|
+
who.name,
|
|
341
|
+
" ",
|
|
342
|
+
/* @__PURE__ */ jsxs("span", { style: styles.muted, children: [
|
|
343
|
+
"(",
|
|
344
|
+
who.roles.join(", "),
|
|
345
|
+
")"
|
|
346
|
+
] })
|
|
347
|
+
] }),
|
|
348
|
+
/* @__PURE__ */ jsx("button", { style: styles.button, onClick: () => onSignOut(), children: "Sign out" })
|
|
349
|
+
] }),
|
|
350
|
+
/* @__PURE__ */ jsx(BluHostProvider, { value, children: /* @__PURE__ */ jsx(Page, {}, `${session.provider.id}:${who.clientId}`) }),
|
|
351
|
+
events.length > 0 && /* @__PURE__ */ jsxs("footer", { style: { padding: "8px 24px", fontSize: 12, color: DEFAULT_THEME.textMuted }, children: [
|
|
352
|
+
"Local event bus (no host to receive them):",
|
|
353
|
+
" ",
|
|
354
|
+
events.map((e, i) => /* @__PURE__ */ jsx("code", { style: { marginRight: 8 }, children: e.type }, i))
|
|
355
|
+
] })
|
|
356
|
+
] });
|
|
357
|
+
}
|
|
358
|
+
var styles = {
|
|
359
|
+
page: { minHeight: "100vh", display: "grid", placeItems: "center", padding: 16, background: DEFAULT_THEME.surfaceAlt, fontFamily: DEFAULT_THEME.font, color: DEFAULT_THEME.text },
|
|
360
|
+
card: { background: "#fff", border: `1px solid ${DEFAULT_THEME.border}`, borderRadius: 14, padding: 24, width: "100%", maxWidth: 560 },
|
|
361
|
+
kicker: { fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: DEFAULT_THEME.textMuted },
|
|
362
|
+
muted: { color: DEFAULT_THEME.textMuted, fontSize: 14 },
|
|
363
|
+
warn: { color: "#b35c00", background: "#fff4e6", padding: "8px 12px", borderRadius: 8, fontSize: 13 },
|
|
364
|
+
section: { borderTop: `1px solid ${DEFAULT_THEME.border}`, paddingTop: 12, marginTop: 12 },
|
|
365
|
+
persona: { display: "block", width: "100%", textAlign: "left", font: "inherit", fontSize: 14, padding: "10px 12px", margin: "6px 0", border: `1px solid ${DEFAULT_THEME.border}`, borderRadius: 10, background: "#fff", cursor: "pointer" },
|
|
366
|
+
bar: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "10px 24px", background: "#fff", borderBottom: `1px solid ${DEFAULT_THEME.border}`, fontSize: 14 },
|
|
367
|
+
badge: { fontSize: 11, padding: "3px 8px", borderRadius: 99, background: "#eef1f8", color: DEFAULT_THEME.textMuted },
|
|
368
|
+
badgeTeam: { background: "#f3f0ff", color: "#6741d9" },
|
|
369
|
+
badgeBlu: { background: "#e7f5ff", color: "#1c7ed6" },
|
|
370
|
+
button: { font: "inherit", fontSize: 13, border: `1px solid ${DEFAULT_THEME.border}`, background: "#fff", borderRadius: 8, padding: "5px 10px", cursor: "pointer" }
|
|
371
|
+
};
|
|
372
|
+
export {
|
|
373
|
+
mountStandalone
|
|
374
|
+
};
|
package/frame.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BluHostProvider,
|
|
3
|
+
CONTRACT_VERSION,
|
|
4
|
+
assertHostContext
|
|
5
|
+
} from "./chunks/chunk-M7GVSOGQ.js";
|
|
6
|
+
|
|
7
|
+
// src/frame.tsx
|
|
8
|
+
import { createRoot } from "react-dom/client";
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
var V = 1;
|
|
12
|
+
async function mountIsolated({ Page, rootId = "root" }) {
|
|
13
|
+
const root = createRoot(document.getElementById(rootId));
|
|
14
|
+
if (window.parent === window) {
|
|
15
|
+
root.render(/* @__PURE__ */ jsx(Notice, { text: "This page is a Blu module's isolated frame. Open it from the Blu shell (or use the module's standalone app)." }));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { hostOrigins } = await (await fetch("./deployment.json", { cache: "no-store" })).json();
|
|
19
|
+
const allowed = Array.isArray(hostOrigins) ? hostOrigins : String(hostOrigins ?? "").split(",").filter(Boolean);
|
|
20
|
+
root.render(/* @__PURE__ */ jsx(Isolated, { Page, hostOrigins: new Set(allowed) }));
|
|
21
|
+
}
|
|
22
|
+
function Isolated({ Page, hostOrigins }) {
|
|
23
|
+
const [bridge] = useState(() => connectToBluShell(hostOrigins));
|
|
24
|
+
const [context, setContext] = useState(null);
|
|
25
|
+
useEffect(() => bridge.onContext(setContext), [bridge]);
|
|
26
|
+
if (!context) return /* @__PURE__ */ jsx(Notice, { text: "Waiting for the Blu shell\u2026" });
|
|
27
|
+
return /* @__PURE__ */ jsx(BluHostProvider, { value: context, children: /* @__PURE__ */ jsx(Page, {}, context.client.id) });
|
|
28
|
+
}
|
|
29
|
+
function connectToBluShell(hostOrigins) {
|
|
30
|
+
return createBridge(new Set(hostOrigins));
|
|
31
|
+
}
|
|
32
|
+
function createBridge(hostOrigins) {
|
|
33
|
+
let shellOrigin = null;
|
|
34
|
+
let token = null;
|
|
35
|
+
let seq = 0;
|
|
36
|
+
const pending = /* @__PURE__ */ new Map();
|
|
37
|
+
const contextListeners = /* @__PURE__ */ new Set();
|
|
38
|
+
const localHandlers = /* @__PURE__ */ new Map();
|
|
39
|
+
let latest = null;
|
|
40
|
+
const post = (msg) => {
|
|
41
|
+
window.parent.postMessage({ ...msg, v: V }, shellOrigin ?? "*");
|
|
42
|
+
};
|
|
43
|
+
let inflight = null;
|
|
44
|
+
const getToken = () => {
|
|
45
|
+
if (token && token.expiresAt - Date.now() > 1e4) return Promise.resolve(token.value);
|
|
46
|
+
inflight ??= new Promise((resolve, reject) => {
|
|
47
|
+
const id = ++seq;
|
|
48
|
+
pending.set(id, { resolve, reject });
|
|
49
|
+
post({ type: "blu.frame.token", id });
|
|
50
|
+
}).finally(() => {
|
|
51
|
+
inflight = null;
|
|
52
|
+
});
|
|
53
|
+
return inflight;
|
|
54
|
+
};
|
|
55
|
+
const events = {
|
|
56
|
+
emit(type, payload) {
|
|
57
|
+
post({ type: "blu.frame.event", event: { type, payload } });
|
|
58
|
+
const e = { type, source: latest?.environment.moduleId ?? "module", payload, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
59
|
+
for (const key of [type, "*"]) localHandlers.get(key)?.forEach((h) => h(e));
|
|
60
|
+
},
|
|
61
|
+
on(type, h) {
|
|
62
|
+
if (!localHandlers.has(type)) localHandlers.set(type, /* @__PURE__ */ new Set());
|
|
63
|
+
localHandlers.get(type).add(h);
|
|
64
|
+
return () => void localHandlers.get(type)?.delete(h);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
window.addEventListener("message", (event) => {
|
|
68
|
+
if (event.source !== window.parent || !hostOrigins.has(event.origin)) return;
|
|
69
|
+
if (shellOrigin && event.origin !== shellOrigin) return;
|
|
70
|
+
const msg = event.data;
|
|
71
|
+
if (!msg || msg.v !== V) return;
|
|
72
|
+
if (msg.type === "blu.frame.context") {
|
|
73
|
+
shellOrigin = event.origin;
|
|
74
|
+
token = null;
|
|
75
|
+
const c = msg.context;
|
|
76
|
+
const value = {
|
|
77
|
+
...c,
|
|
78
|
+
route: { path: c.route?.path ?? "/", navigate: (path) => post({ type: "blu.frame.route", path }) },
|
|
79
|
+
getToken,
|
|
80
|
+
navigate: (path) => post({ type: "blu.frame.navigate", path }),
|
|
81
|
+
events
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
assertHostContext(value);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error(e);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
latest = value;
|
|
90
|
+
contextListeners.forEach((l) => l(value));
|
|
91
|
+
} else if (msg.type === "blu.frame.token.result") {
|
|
92
|
+
const p = pending.get(msg.id);
|
|
93
|
+
if (!p) return;
|
|
94
|
+
pending.delete(msg.id);
|
|
95
|
+
if (typeof msg.token === "string") {
|
|
96
|
+
token = { value: msg.token, expiresAt: Date.now() + Number(msg.expiresIn ?? 60) * 1e3 };
|
|
97
|
+
p.resolve(msg.token);
|
|
98
|
+
} else p.reject(new Error(String(msg.error ?? "the shell refused a token")));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
let last = 0;
|
|
102
|
+
const report = () => {
|
|
103
|
+
const height = Math.ceil(document.body.getBoundingClientRect().height);
|
|
104
|
+
if (height === last || !shellOrigin) return;
|
|
105
|
+
post({ type: "blu.frame.resize", height });
|
|
106
|
+
last = height;
|
|
107
|
+
};
|
|
108
|
+
new ResizeObserver(() => requestAnimationFrame(report)).observe(document.body);
|
|
109
|
+
post({ type: "blu.frame.ready", contract: CONTRACT_VERSION });
|
|
110
|
+
return {
|
|
111
|
+
/**
|
|
112
|
+
* The app moved (its own router): Blu updates its URL. Pass `replace: true`
|
|
113
|
+
* when your router already added a browser-history entry (it did, if it uses
|
|
114
|
+
* pushState) — then Blu replaces its URL instead of adding a second entry, and
|
|
115
|
+
* the back button steps once.
|
|
116
|
+
*/
|
|
117
|
+
reportRoute(path, opts = {}) {
|
|
118
|
+
if (shellOrigin) post({ type: "blu.frame.route", path, replace: Boolean(opts.replace) });
|
|
119
|
+
},
|
|
120
|
+
onContext(l) {
|
|
121
|
+
contextListeners.add(l);
|
|
122
|
+
if (latest) l(latest);
|
|
123
|
+
requestAnimationFrame(report);
|
|
124
|
+
return () => void contextListeners.delete(l);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function Notice({ text }) {
|
|
129
|
+
return /* @__PURE__ */ jsx("p", { style: { font: "14px ui-sans-serif, system-ui, sans-serif", color: "#5c6478", padding: 24, margin: 0 }, children: text });
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
connectToBluShell,
|
|
133
|
+
mountIsolated
|
|
134
|
+
};
|