@unoverse-platform/studio 0.1.57 → 0.1.59
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/local/plugin.ts +5 -66
- package/package.json +1 -1
- package/screens/DesignSystemView.tsx +4 -1
- package/screens/NativeAppHost.tsx +2 -2
- package/screens/TemplatesView.tsx +10 -1
- package/src/App.tsx +14 -70
- package/src/OfflineSession.tsx +50 -0
- package/src/host.ts +11 -13
- package/vendor/sdk/appEngine.tsx +15 -2
- package/vendor/sdk/lib/core/connection.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +17 -0
- package/src/ConnectUniverse.tsx +0 -232
- package/src/PublishDialog.tsx +0 -309
- package/src/UniverseSession.tsx +0 -121
- package/src/universes.ts +0 -210
package/local/plugin.ts
CHANGED
|
@@ -222,7 +222,7 @@ export function localStudioHost(): Plugin {
|
|
|
222
222
|
// 200. That has now cost three separate bugs (the /api allowlist, the dev proxy for
|
|
223
223
|
// /.well-known and /publish), each of which looked like a missing route rather than
|
|
224
224
|
// a filtered one.
|
|
225
|
-
if (!/^\/(dev|local|mcp|skills|prompt-blocks|registry|nodes|keys|execute
|
|
225
|
+
if (!/^\/(dev|local|mcp|skills|prompt-blocks|registry|nodes|keys|execute)\b/.test(path)) return next();
|
|
226
226
|
|
|
227
227
|
/** Read a JSON request body. */
|
|
228
228
|
const body = async (): Promise<Record<string, unknown>> => {
|
|
@@ -248,71 +248,10 @@ export function localStudioHost(): Plugin {
|
|
|
248
248
|
envFiles: [resolve(projectRoot, "..", ".env"), resolve(projectRoot, ".env")],
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
* developer's disk: their definitions, and the design system they built on.
|
|
256
|
-
* A browser cannot, and a server that could would be a server this tool
|
|
257
|
-
* deliberately does not have.
|
|
258
|
-
*
|
|
259
|
-
* The token is passed per request and never stored. Studio holds it in the
|
|
260
|
-
* OIDC session for the tab (UniverseSession) and hands it over for the one
|
|
261
|
-
* call; local Studio writes no secret to disk (local/keys.ts), and connecting
|
|
262
|
-
* to a universe must not be the thing that breaks that.
|
|
263
|
-
*
|
|
264
|
-
* Same functions the CLI uses (`unoverse publish assets`), so the two cannot
|
|
265
|
-
* disagree about what a publish is.
|
|
266
|
-
*/
|
|
267
|
-
case path === "/publish/plan" || path === "/publish/send": {
|
|
268
|
-
const b = await body();
|
|
269
|
-
const project = String(b.project ?? "");
|
|
270
|
-
const universe = String(b.universe ?? "");
|
|
271
|
-
const token = String(b.token ?? "");
|
|
272
|
-
if (!project || !universe || !token)
|
|
273
|
-
return json(res, { error: "project, universe and token are required" }, 400);
|
|
274
|
-
|
|
275
|
-
// Loaded through Vite's SSR runner, like the catalog and node loaders above: a bare
|
|
276
|
-
// dynamic import of a .js path does not resolve to the .ts source. The type below
|
|
277
|
-
// mirrors the module's real exports — tsc can't follow an ssrLoadModule path.
|
|
278
|
-
type PublishItem = { kind: string; name: string; why?: string; mode?: string };
|
|
279
|
-
type PublishModule = {
|
|
280
|
-
lintForPublish(designRoot: string, project: string): Promise<{ problems: unknown[]; errors: unknown[] }>;
|
|
281
|
-
collectProject(designRoot: string, project: string): unknown;
|
|
282
|
-
planPublish(collected: unknown, universe: string, token: string): Promise<Record<string, PublishItem[]>>;
|
|
283
|
-
sendPublish(plan: unknown, universe: string, token: string): Promise<{ sent: PublishItem[]; failed?: PublishItem[] }>;
|
|
284
|
-
};
|
|
285
|
-
const items = (await ssr(`${baseSrc}/items/publish.ts`)) as PublishModule;
|
|
286
|
-
const designRoot = designHome(projectRoot);
|
|
287
|
-
|
|
288
|
-
// LINT FIRST, ALWAYS. Nothing leaves the machine until the rules pass: a
|
|
289
|
-
// developer should learn about a raw hex value here, not from an HTTP error.
|
|
290
|
-
const { problems, errors } = await items.lintForPublish(designRoot, project);
|
|
291
|
-
if (errors.length) return json(res, { blocked: true, problems, errors });
|
|
292
|
-
|
|
293
|
-
const collected = items.collectProject(designRoot, project);
|
|
294
|
-
const plan = await items.planPublish(collected, universe, token);
|
|
295
|
-
|
|
296
|
-
// The screen renders kinds and names. The plan carries every file's full
|
|
297
|
-
// contents, so sending it whole would push the entire project through the
|
|
298
|
-
// browser for a list, and again on send.
|
|
299
|
-
type Lean = { kind: string; name: string; why?: string; mode?: string };
|
|
300
|
-
const lean = (list: Lean[] = []) =>
|
|
301
|
-
list.map(({ kind, name, why, mode }) => ({ kind, name, why, mode }));
|
|
302
|
-
const leanPlan = Object.fromEntries(
|
|
303
|
-
Object.entries(plan as Record<string, Lean[]>).map(([k, v]) => [k, lean(v)]),
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
if (path === "/publish/plan") return json(res, { problems, plan: leanPlan });
|
|
307
|
-
|
|
308
|
-
const result = await items.sendPublish(plan, universe, token);
|
|
309
|
-
return json(res, {
|
|
310
|
-
problems,
|
|
311
|
-
plan: leanPlan,
|
|
312
|
-
sent: lean(result.sent),
|
|
313
|
-
failed: lean(result.failed),
|
|
314
|
-
});
|
|
315
|
-
}
|
|
251
|
+
// NO PUBLISH ROUTES. Studio had /publish/plan and /publish/send; they moved
|
|
252
|
+
// to the terminal whole (LOCAL_STUDIO.md §Publish is terminal-only): the CLI
|
|
253
|
+
// runs the same base items/publish functions directly, and no token ever
|
|
254
|
+
// nears the browser because the browser no longer takes part.
|
|
316
255
|
|
|
317
256
|
case path === "/dev/orgs":
|
|
318
257
|
return json(res, { orgs: c.themeOrgs() });
|
package/package.json
CHANGED
|
@@ -167,7 +167,10 @@ export function DesignSystemView({
|
|
|
167
167
|
{isOverride("text", name) && <OverrideMark />}
|
|
168
168
|
</div>
|
|
169
169
|
<div className="min-w-0 flex-1">
|
|
170
|
-
|
|
170
|
+
{/* A typography token carries family/size/weight/line-height only — never a
|
|
171
|
+
colour — so the specimen would otherwise inherit Studio's ambient text
|
|
172
|
+
colour and wash out. Paint it in the THEME's ink, like the family name below. */}
|
|
173
|
+
<div style={{ ...(style as React.CSSProperties), color: ink }}>The quick brown fox</div>
|
|
171
174
|
<div className="mt-0.5 text-[10px]" style={{ color: sub }}>
|
|
172
175
|
{fams.length ? (
|
|
173
176
|
<>
|
|
@@ -33,7 +33,7 @@ export function NativeAppHost({
|
|
|
33
33
|
* (appEngine) and this relays it, so the host's badge can tell the truth.
|
|
34
34
|
* `refused` carries the server's terminal refusal text (register_session refused
|
|
35
35
|
* the session — public-entry gate, requires.role, stale token); undefined otherwise. */
|
|
36
|
-
onStatus?: (s: { ready: boolean; connected: boolean; refused?: string }) => void;
|
|
36
|
+
onStatus?: (s: { ready: boolean; connected: boolean; refused?: string; failure?: { nodeIds: string[]; errors: Record<string, string> } }) => void;
|
|
37
37
|
}) {
|
|
38
38
|
const [html, setHtml] = useState<string | null>(null);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -47,7 +47,7 @@ export function NativeAppHost({
|
|
|
47
47
|
if (e.source !== frame.current?.contentWindow) return;
|
|
48
48
|
if (e.data?.type === "unoverse:size" && e.data.width) setAppWidth(String(e.data.width));
|
|
49
49
|
if (e.data?.type === "unoverse:status")
|
|
50
|
-
onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined });
|
|
50
|
+
onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined, failure: e.data.failure ?? undefined });
|
|
51
51
|
};
|
|
52
52
|
window.addEventListener("message", onSize);
|
|
53
53
|
return () => window.removeEventListener("message", onSize);
|
|
@@ -217,7 +217,7 @@ export function TemplatesView({
|
|
|
217
217
|
// connection and posts `unoverse:status` up; the in-page `conn` below is never
|
|
218
218
|
// opened in native mode, so a badge reading it said "Offline" while the app
|
|
219
219
|
// streamed (observed live). Native status wins whenever the iframe has reported.
|
|
220
|
-
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string } | null>(null);
|
|
220
|
+
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string; failure?: { nodeIds: string[]; errors: Record<string, string> } } | null>(null);
|
|
221
221
|
useEffect(() => setNativeStatus(null), [selected, live]);
|
|
222
222
|
// LIVE output inspector — the last submitted app output, projected through the
|
|
223
223
|
// component's declared `outputs` (the same object the held tool call returns to
|
|
@@ -831,6 +831,15 @@ export function TemplatesView({
|
|
|
831
831
|
{nativeStatus.refused}
|
|
832
832
|
</div>
|
|
833
833
|
)}
|
|
834
|
+
{nativeStatus?.failure && (
|
|
835
|
+
/* The last run's WORKFLOW_ERROR, verbatim per node — developer surface
|
|
836
|
+
only; a production app never renders this. */
|
|
837
|
+
<div className="mb-2 rounded border px-2 py-1.5 text-[11px] leading-snug" style={{ borderColor: "#ef4444", color: "#ef4444" }}>
|
|
838
|
+
{Object.entries(nativeStatus.failure.errors).map(([nodeId, err]) => (
|
|
839
|
+
<div key={nodeId}><span className="font-medium">{nodeId}</span>: {err}</div>
|
|
840
|
+
))}
|
|
841
|
+
</div>
|
|
842
|
+
)}
|
|
834
843
|
{/* Read-only: the app decides. Server is environment; Workflow + Trigger
|
|
835
844
|
come from the selected app's manifest binding (no manual entry). */}
|
|
836
845
|
{(
|
package/src/App.tsx
CHANGED
|
@@ -18,11 +18,8 @@ import {
|
|
|
18
18
|
PromptBlocksView,
|
|
19
19
|
} from "@unoverse/studio";
|
|
20
20
|
import { client, usePersistentState } from "./host";
|
|
21
|
-
import {
|
|
22
|
-
import { ConnectUniverse } from "./ConnectUniverse";
|
|
23
|
-
import { PublishDialog } from "./PublishDialog";
|
|
21
|
+
import { OfflineSession } from "./OfflineSession";
|
|
24
22
|
import { NewProject } from "./NewProject";
|
|
25
|
-
import { loadUniverses, type Universe } from "./universes";
|
|
26
23
|
// Local-only screens. Nodes are AUTHORED and TESTED here and nowhere else: Canvas keeps
|
|
27
24
|
// the node library (using nodes), the marketplace acquires them (a universe's job).
|
|
28
25
|
import { NodesView } from "./NodesView";
|
|
@@ -40,37 +37,23 @@ const NAV: { key: View; label: string; group: string }[] = [
|
|
|
40
37
|
];
|
|
41
38
|
|
|
42
39
|
/**
|
|
43
|
-
* The shell,
|
|
40
|
+
* The shell, inside the permanently signed-out session.
|
|
44
41
|
*
|
|
45
|
-
* The provider must sit ABOVE every screen, because a screen deep in the tree
|
|
46
|
-
* (TemplatesView)
|
|
47
|
-
*
|
|
42
|
+
* The provider must sit ABOVE every screen, because a shared screen deep in the tree
|
|
43
|
+
* (TemplatesView) calls `useAuth()` unconditionally. Local Studio has no login and no
|
|
44
|
+
* publish button — identity lives in the CLI at publish time (`unoverse publish`;
|
|
45
|
+
* LOCAL_STUDIO.md §Publish is terminal-only) — so the session it provides is the
|
|
46
|
+
* signed-out one, always.
|
|
48
47
|
*/
|
|
49
48
|
export function App() {
|
|
50
|
-
// Which universe is selected, remembered across refreshes. The list itself lives in
|
|
51
|
-
// localStorage (universes.ts); this is only the pointer into it.
|
|
52
|
-
const [activeUrl, setActiveUrl] = usePersistentState<string | null>("studio:universe", null);
|
|
53
|
-
const active = loadUniverses().find((u) => u.url === activeUrl) ?? null;
|
|
54
|
-
|
|
55
49
|
return (
|
|
56
|
-
<
|
|
57
|
-
<Shell
|
|
58
|
-
|
|
59
|
-
onSelectUniverse={(u: Universe | null) => setActiveUrl(u?.url ?? null)}
|
|
60
|
-
/>
|
|
61
|
-
</UniverseSession>
|
|
50
|
+
<OfflineSession>
|
|
51
|
+
<Shell />
|
|
52
|
+
</OfflineSession>
|
|
62
53
|
);
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
function Shell({
|
|
66
|
-
active,
|
|
67
|
-
onSelectUniverse,
|
|
68
|
-
}: {
|
|
69
|
-
active: Universe | null;
|
|
70
|
-
onSelectUniverse: (u: Universe | null) => void;
|
|
71
|
-
}) {
|
|
72
|
-
const [connectOpen, setConnectOpen] = useState(false);
|
|
73
|
-
const [publishOpen, setPublishOpen] = useState(false);
|
|
56
|
+
function Shell() {
|
|
74
57
|
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
|
75
58
|
const [view, setView] = usePersistentState<View>("studio:view", "components");
|
|
76
59
|
const [org, setOrg] = usePersistentState<string>("studio:org", "all");
|
|
@@ -168,43 +151,12 @@ function Shell({
|
|
|
168
151
|
>
|
|
169
152
|
{variant}
|
|
170
153
|
</button>
|
|
171
|
-
{/*
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
onClick={() => setConnectOpen(true)}
|
|
175
|
-
className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
|
|
176
|
-
title="Where your work publishes to"
|
|
177
|
-
>
|
|
178
|
-
{active ? active.label : "Not connected"}
|
|
179
|
-
</button>
|
|
180
|
-
{/* PUBLISHES THE SELECTED PROJECT. Disabled rather than hidden when there is
|
|
181
|
-
nowhere to send or nothing selected: a missing button reads as a missing
|
|
182
|
-
feature, a disabled one with a reason reads as a next step. */}
|
|
183
|
-
<button
|
|
184
|
-
onClick={() => setPublishOpen(true)}
|
|
185
|
-
disabled={!active || org === "all"}
|
|
186
|
-
title={
|
|
187
|
-
!active
|
|
188
|
-
? "Connect a universe first"
|
|
189
|
-
: org === "all"
|
|
190
|
-
? "Pick one project to publish"
|
|
191
|
-
: `Publish ${org} to ${active.label}`
|
|
192
|
-
}
|
|
193
|
-
className={
|
|
194
|
-
active && org !== "all"
|
|
195
|
-
? "rounded bg-emerald-500 px-3 py-1 font-medium text-emerald-950 hover:bg-emerald-400"
|
|
196
|
-
: "rounded border border-gray-700 px-2 py-1 text-gray-500 disabled:cursor-not-allowed"
|
|
197
|
-
}
|
|
198
|
-
>
|
|
199
|
-
Publish
|
|
200
|
-
</button>
|
|
154
|
+
{/* NO PUBLISH BUTTON, DELIBERATELY (LOCAL_STUDIO.md §Publish is terminal-only).
|
|
155
|
+
Studio renders; shipping is `unoverse publish` in the terminal the developer
|
|
156
|
+
already has open, which signs in by itself when it needs to. */}
|
|
201
157
|
</div>
|
|
202
158
|
</header>
|
|
203
159
|
|
|
204
|
-
{publishOpen && active && org !== "all" && (
|
|
205
|
-
<PublishDialog project={org} universe={active} onClose={() => setPublishOpen(false)} />
|
|
206
|
-
)}
|
|
207
|
-
|
|
208
160
|
{/* Created, then SELECTED. Making a project and leaving the developer looking at the
|
|
209
161
|
old one would be a step they have to discover. */}
|
|
210
162
|
{newProjectOpen && (
|
|
@@ -218,14 +170,6 @@ function Shell({
|
|
|
218
170
|
/>
|
|
219
171
|
)}
|
|
220
172
|
|
|
221
|
-
{connectOpen && (
|
|
222
|
-
<ConnectUniverse
|
|
223
|
-
active={active}
|
|
224
|
-
onSelect={onSelectUniverse}
|
|
225
|
-
onClose={() => setConnectOpen(false)}
|
|
226
|
-
/>
|
|
227
|
-
)}
|
|
228
|
-
|
|
229
173
|
{/* The shared screens size themselves with `flex-1`, so the host must be a flex
|
|
230
174
|
column. As a grid cell they would collapse to their content height. */}
|
|
231
175
|
<main className="flex min-h-0 flex-col overflow-hidden">
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permanently signed-out session local Studio hands the shared screens.
|
|
3
|
+
*
|
|
4
|
+
* Local Studio has NO auth chrome and NO publish path (LOCAL_STUDIO.md §Publish is
|
|
5
|
+
* terminal-only, 2026-08-21): authoring is offline, and identity exists only in the CLI
|
|
6
|
+
* at publish time (`unoverse login`). But the shared screens are also Canvas's, where a
|
|
7
|
+
* real session exists, so they call `useAuth()` unconditionally — and react-oidc-context
|
|
8
|
+
* warns loudly whenever the context VALUE is falsy. So the host supplies a real value
|
|
9
|
+
* meaning SIGNED OUT, permanently.
|
|
10
|
+
*
|
|
11
|
+
* The sign-in actions throw rather than no-op: reaching one from local Studio means a
|
|
12
|
+
* shared screen forgot which host it is in (the `hasAuth` capability flag is the check
|
|
13
|
+
* it should have made), and silence would hide that.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { AuthContext, type AuthContextProps } from "react-oidc-context";
|
|
17
|
+
|
|
18
|
+
function offline(name: string) {
|
|
19
|
+
return async () => {
|
|
20
|
+
throw new Error(`${name} is not available in local Studio. Publishing signs in from the terminal: unoverse publish.`);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cast because `AuthContextProps` also carries the oidc-client-ts settings and event
|
|
26
|
+
* surface, none of which exists without a provider and none of which the screens touch.
|
|
27
|
+
*/
|
|
28
|
+
const OFFLINE_SESSION = {
|
|
29
|
+
isAuthenticated: false,
|
|
30
|
+
isLoading: false,
|
|
31
|
+
user: undefined,
|
|
32
|
+
error: undefined,
|
|
33
|
+
activeNavigator: undefined,
|
|
34
|
+
signinRedirect: offline("signinRedirect"),
|
|
35
|
+
signinPopup: offline("signinPopup"),
|
|
36
|
+
signinSilent: offline("signinSilent"),
|
|
37
|
+
signoutRedirect: offline("signoutRedirect"),
|
|
38
|
+
signoutPopup: offline("signoutPopup"),
|
|
39
|
+
signoutSilent: offline("signoutSilent"),
|
|
40
|
+
removeUser: offline("removeUser"),
|
|
41
|
+
clearStaleState: offline("clearStaleState"),
|
|
42
|
+
querySessionStatus: offline("querySessionStatus"),
|
|
43
|
+
revokeTokens: offline("revokeTokens"),
|
|
44
|
+
startSilentRenew: () => {},
|
|
45
|
+
stopSilentRenew: () => {},
|
|
46
|
+
} as unknown as AuthContextProps;
|
|
47
|
+
|
|
48
|
+
export function OfflineSession({ children }: { children: ReactNode }) {
|
|
49
|
+
return <AuthContext.Provider value={OFFLINE_SESSION}>{children}</AuthContext.Provider>;
|
|
50
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -22,21 +22,19 @@ export const UNOVERSE_BASE = "";
|
|
|
22
22
|
export const hasAuth = false;
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* universe
|
|
25
|
+
* THE LIVE PREVIEW'S SERVER: always null here, meaning the local dev platform.
|
|
26
|
+
*
|
|
27
|
+
* This used to read the universe picked in the header (`studio:universe`), written by
|
|
28
|
+
* the connect button. That chrome is gone (LOCAL_STUDIO.md §Publish is terminal-only) —
|
|
29
|
+
* and reading the stale localStorage value after removing the UI left Live mode aimed
|
|
30
|
+
* at a universe nobody could change or clear, failing with that universe's errors
|
|
31
|
+
* (seen live: "Unknown Unoverse template" from a universe the app was never published
|
|
32
|
+
* to). So local Studio's Live preview targets the local dev platform, full stop.
|
|
33
|
+
* Pointing Live at the workspace's own universe (unoverse.yaml) is future work and
|
|
34
|
+
* will come from the workspace, not from a browser store.
|
|
31
35
|
*/
|
|
32
36
|
export function liveUniverseUrl(): string | null {
|
|
33
|
-
|
|
34
|
-
const raw = localStorage.getItem("studio:universe");
|
|
35
|
-
const url = raw === null ? null : (JSON.parse(raw) as string | null);
|
|
36
|
-
return url || null;
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
37
|
+
return null;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/**
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -188,11 +188,24 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
188
188
|
// connection, so a host badge watching its own (never-opened) in-page connection
|
|
189
189
|
// reads "offline" forever while this session streams (observed live). Report the
|
|
190
190
|
// truth the same way size is reported; hosts that don't listen lose nothing.
|
|
191
|
+
//
|
|
192
|
+
// `failure` is the last run's WORKFLOW_ERROR detail (which node, what error) —
|
|
193
|
+
// DEVELOPER information. A production embed host doesn't listen and its users see
|
|
194
|
+
// nothing; Studio's preview listens and shows it, so a dead run stops being a
|
|
195
|
+
// silence only the server logs could explain.
|
|
196
|
+
const [failure, setFailure] = useState(store.getLastFailure());
|
|
197
|
+
useEffect(() => store.subscribe(() => setFailure(store.getLastFailure())), [store]);
|
|
191
198
|
useEffect(() => {
|
|
192
199
|
// `refused` carries the server's terminal refusal text (register_session isError) —
|
|
193
200
|
// the one state a "Connecting…" badge must not sit on forever.
|
|
194
|
-
postToEmbedHost({
|
|
195
|
-
|
|
201
|
+
postToEmbedHost({
|
|
202
|
+
type: "unoverse:status",
|
|
203
|
+
ready: !!conn.isReady,
|
|
204
|
+
connected: !!conn.isConnected,
|
|
205
|
+
refused: conn.refusal ?? undefined,
|
|
206
|
+
failure: failure ?? undefined,
|
|
207
|
+
});
|
|
208
|
+
}, [conn.isReady, conn.isConnected, conn.refusal, failure]);
|
|
196
209
|
|
|
197
210
|
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
198
211
|
// in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
|
|
@@ -114,8 +114,19 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
114
114
|
// born WITH its first component, so the streaming-and-empty window never exists.
|
|
115
115
|
if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING" || m.state === "RESPONDING" || m.state === "WAITING") {
|
|
116
116
|
if (m.chatId) store.startResponse(m.chatId);
|
|
117
|
+
// A new run supersedes the previous run's failure detail.
|
|
118
|
+
if (m.state === "WORKFLOW_STARTED") store.setLastFailure(null);
|
|
117
119
|
} else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") {
|
|
118
120
|
store.completeResponse(m.chatId);
|
|
121
|
+
} else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") {
|
|
122
|
+
// SETTLE the turn exactly as completion does — a failed run must not leave the
|
|
123
|
+
// thinking state open forever (the eternal dots). What DIFFERS is invisible to
|
|
124
|
+
// a production user: the failure detail is stored for a developer host to
|
|
125
|
+
// read (Studio's preview); the SDK renders none of it.
|
|
126
|
+
store.completeResponse(m.chatId);
|
|
127
|
+
const failure = (m.metadata as { failure?: { nodeIds: string[]; errors: Record<string, string> } } | undefined)
|
|
128
|
+
?.failure;
|
|
129
|
+
if (failure) store.setLastFailure(failure);
|
|
119
130
|
}
|
|
120
131
|
// Lifecycle machine — the global turn status (projected for visibleWhen).
|
|
121
132
|
if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING") store.setLifecycle("thinking");
|
|
@@ -274,6 +274,23 @@ export class ComponentStore {
|
|
|
274
274
|
this.bump();
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// --- lastFailure: DEVELOPER detail from WORKFLOW_ERROR, stored and never rendered ---
|
|
278
|
+
//
|
|
279
|
+
// The SDK is a dumb renderer: a production app's experience does not change on a
|
|
280
|
+
// failed run (the turn settles exactly as on success). The failure detail is HELD
|
|
281
|
+
// here so a developer HOST that chooses to look (Studio's app preview) can show
|
|
282
|
+
// which node died and why. No SDK surface reads it.
|
|
283
|
+
private failure: { nodeIds: string[]; errors: Record<string, string> } | null = null;
|
|
284
|
+
|
|
285
|
+
/** The last run's failure detail, or null. Cleared when a new run starts. */
|
|
286
|
+
getLastFailure(): { nodeIds: string[]; errors: Record<string, string> } | null {
|
|
287
|
+
return this.failure;
|
|
288
|
+
}
|
|
289
|
+
setLastFailure(f: { nodeIds: string[]; errors: Record<string, string> } | null): void {
|
|
290
|
+
this.failure = f;
|
|
291
|
+
this.bump();
|
|
292
|
+
}
|
|
293
|
+
|
|
277
294
|
/** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
|
|
278
295
|
apply(event: UnoverseEvent): void {
|
|
279
296
|
const key = `${event.chatId}:${event.nodeId}`;
|
package/src/ConnectUniverse.tsx
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Connect to a universe: the "when you are ready" step.
|
|
3
|
-
*
|
|
4
|
-
* Everything before this point is offline. A developer builds components, templates and
|
|
5
|
-
* nodes against their own folders with no account and no network. This screen is where
|
|
6
|
-
* they choose somewhere to publish, and it is deliberately the only place Studio asks
|
|
7
|
-
* for anything.
|
|
8
|
-
*
|
|
9
|
-
* The flow is: type an address, Studio asks that universe who authenticates it, then
|
|
10
|
-
* signs in against whatever the answer was. Studio names no provider.
|
|
11
|
-
*
|
|
12
|
-
* See docs/architecture/LOCAL_STUDIO.md.
|
|
13
|
-
*/
|
|
14
|
-
import { useState } from "react";
|
|
15
|
-
import { useAuth } from "react-oidc-context";
|
|
16
|
-
import {
|
|
17
|
-
normaliseUrl,
|
|
18
|
-
defaultLabel,
|
|
19
|
-
discover,
|
|
20
|
-
loginReady,
|
|
21
|
-
canPublish,
|
|
22
|
-
loadUniverses,
|
|
23
|
-
saveUniverse,
|
|
24
|
-
removeUniverse,
|
|
25
|
-
type Universe,
|
|
26
|
-
} from "./universes";
|
|
27
|
-
|
|
28
|
-
/** Decode a JWT payload without verifying it. Display only: the universe verifies. */
|
|
29
|
-
function claimsOf(accessToken: string | undefined): Record<string, unknown> | null {
|
|
30
|
-
if (!accessToken) return null;
|
|
31
|
-
try {
|
|
32
|
-
return JSON.parse(atob(accessToken.split(".")[1] ?? ""));
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Sign out of the connected universe.
|
|
40
|
-
*
|
|
41
|
-
* `removeUser` clears the local session only: it does not sign you out of the identity
|
|
42
|
-
* provider, so signing in again may return the same account without prompting. That is the
|
|
43
|
-
* provider's decision, not ours, and a full federated logout needs a logout URL configured
|
|
44
|
-
* per universe. Local is the honest scope for a tool.
|
|
45
|
-
*/
|
|
46
|
-
function SignOut({ auth }: { auth: ReturnType<typeof useAuth> }) {
|
|
47
|
-
return (
|
|
48
|
-
<button
|
|
49
|
-
onClick={() => auth?.removeUser()}
|
|
50
|
-
className="text-xs text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline"
|
|
51
|
-
>
|
|
52
|
-
Sign out
|
|
53
|
-
</button>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function ConnectUniverse({
|
|
58
|
-
active,
|
|
59
|
-
onSelect,
|
|
60
|
-
onClose,
|
|
61
|
-
}: {
|
|
62
|
-
active: Universe | null;
|
|
63
|
-
onSelect: (universe: Universe | null) => void;
|
|
64
|
-
onClose: () => void;
|
|
65
|
-
}) {
|
|
66
|
-
const [universes, setUniverses] = useState<Universe[]>(() => loadUniverses());
|
|
67
|
-
const [address, setAddress] = useState("");
|
|
68
|
-
const [busy, setBusy] = useState(false);
|
|
69
|
-
const [error, setError] = useState<string | null>(null);
|
|
70
|
-
|
|
71
|
-
// Undefined when no provider is mounted, which is the normal offline case.
|
|
72
|
-
const auth = useAuth();
|
|
73
|
-
const claims = claimsOf(auth?.user?.access_token);
|
|
74
|
-
const signedIn = Boolean(auth?.isAuthenticated);
|
|
75
|
-
|
|
76
|
-
async function connect() {
|
|
77
|
-
setError(null);
|
|
78
|
-
setBusy(true);
|
|
79
|
-
try {
|
|
80
|
-
const url = normaliseUrl(address);
|
|
81
|
-
const config = await discover(url);
|
|
82
|
-
const universe: Universe = {
|
|
83
|
-
url,
|
|
84
|
-
label: defaultLabel(url),
|
|
85
|
-
config,
|
|
86
|
-
connectedAt: new Date().toISOString(),
|
|
87
|
-
};
|
|
88
|
-
setUniverses(saveUniverse(universe));
|
|
89
|
-
setAddress("");
|
|
90
|
-
onSelect(universe);
|
|
91
|
-
} catch (e: any) {
|
|
92
|
-
// These messages are written to be actionable (universes.ts), so show them as-is.
|
|
93
|
-
setError(e?.message ?? String(e));
|
|
94
|
-
} finally {
|
|
95
|
-
setBusy(false);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function forget(url: string) {
|
|
100
|
-
setUniverses(removeUniverse(url));
|
|
101
|
-
if (active?.url === url) onSelect(null);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const readiness = active ? loginReady(active.config) : null;
|
|
105
|
-
const allowed = active && claims ? canPublish(claims, active.config) : false;
|
|
106
|
-
/** The signed-in address. Namespace-aware: Auth0 emits `email` ONLY namespaced on an
|
|
107
|
-
* access token, so a plain `claims.email` is undefined and the id leaks into the UI. */
|
|
108
|
-
const email = (() => {
|
|
109
|
-
const direct = (claims as any)?.email;
|
|
110
|
-
if (typeof direct === "string") return direct;
|
|
111
|
-
const key = Object.keys(claims ?? {}).find((k) => k.endsWith("/email") || k.endsWith("/claims/email"));
|
|
112
|
-
const value = key ? (claims as any)[key] : undefined;
|
|
113
|
-
return typeof value === "string" ? value : null;
|
|
114
|
-
})();
|
|
115
|
-
|
|
116
|
-
/** What the token says, for the message below. Trimmed, as the checks are. */
|
|
117
|
-
const permissionList: string[] = (() => {
|
|
118
|
-
const direct = (claims as any)?.permissions;
|
|
119
|
-
const nsKey = Object.keys(claims ?? {}).find((k) => k.endsWith("/permissions"));
|
|
120
|
-
const list = Array.isArray(direct) ? direct : ((claims as any)?.[nsKey ?? ""] ?? []);
|
|
121
|
-
return Array.isArray(list) ? list.map((p) => String(p).trim()) : [];
|
|
122
|
-
})();
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
|
|
126
|
-
<div
|
|
127
|
-
className="w-full max-w-lg rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
|
|
128
|
-
onClick={(e) => e.stopPropagation()}
|
|
129
|
-
>
|
|
130
|
-
<h2 className="text-base font-semibold text-gray-100">Publish to a universe</h2>
|
|
131
|
-
<p className="mt-1 text-xs text-gray-400">
|
|
132
|
-
Studio works offline. Connect a universe when you are ready to publish.
|
|
133
|
-
</p>
|
|
134
|
-
|
|
135
|
-
{/* Add one */}
|
|
136
|
-
<div className="mt-5 flex gap-2">
|
|
137
|
-
<input
|
|
138
|
-
value={address}
|
|
139
|
-
onChange={(e) => setAddress(e.target.value)}
|
|
140
|
-
onKeyDown={(e) => e.key === "Enter" && !busy && connect()}
|
|
141
|
-
placeholder="your-universe.com"
|
|
142
|
-
className="flex-1 rounded border border-gray-700 bg-[#1b2129] px-3 py-2 text-sm text-gray-200 placeholder:text-gray-600 focus:border-gray-500 focus:outline-none"
|
|
143
|
-
/>
|
|
144
|
-
<button
|
|
145
|
-
onClick={connect}
|
|
146
|
-
disabled={busy || !address.trim()}
|
|
147
|
-
className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white disabled:opacity-30"
|
|
148
|
-
>
|
|
149
|
-
{busy ? "Checking..." : "Connect"}
|
|
150
|
-
</button>
|
|
151
|
-
</div>
|
|
152
|
-
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
|
153
|
-
|
|
154
|
-
{/* The ones this developer has connected to. There is no central directory of
|
|
155
|
-
universes and there cannot be: each is independent with its own provider. */}
|
|
156
|
-
{universes.length > 0 && (
|
|
157
|
-
<ul className="mt-5 space-y-2">
|
|
158
|
-
{universes.map((u) => (
|
|
159
|
-
<li
|
|
160
|
-
key={u.url}
|
|
161
|
-
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm ${
|
|
162
|
-
active?.url === u.url
|
|
163
|
-
? "border-gray-500 bg-white/5"
|
|
164
|
-
: "border-gray-800 hover:border-gray-700"
|
|
165
|
-
}`}
|
|
166
|
-
>
|
|
167
|
-
<button className="text-left" onClick={() => onSelect(u)}>
|
|
168
|
-
<span className="font-medium">{u.label}</span>
|
|
169
|
-
<span className="ml-2 text-gray-500">{u.url}</span>
|
|
170
|
-
</button>
|
|
171
|
-
<button className="text-xs text-gray-500 hover:text-red-400" onClick={() => forget(u.url)}>
|
|
172
|
-
Forget
|
|
173
|
-
</button>
|
|
174
|
-
</li>
|
|
175
|
-
))}
|
|
176
|
-
</ul>
|
|
177
|
-
)}
|
|
178
|
-
|
|
179
|
-
{/* Sign-in state for the selected universe. Every branch says what to do next. */}
|
|
180
|
-
{active && (
|
|
181
|
-
<div className="mt-5 border-t border-gray-800 pt-4 text-sm">
|
|
182
|
-
{readiness && !readiness.ok ? (
|
|
183
|
-
// A universe with auth off, or one misconfigured. Publishing is not
|
|
184
|
-
// possible, and saying so beats a redirect to a provider that is not there.
|
|
185
|
-
<p className="text-xs text-amber-400">{readiness.reason}</p>
|
|
186
|
-
) : !signedIn ? (
|
|
187
|
-
<button
|
|
188
|
-
onClick={() => auth?.signinRedirect()}
|
|
189
|
-
className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white"
|
|
190
|
-
>
|
|
191
|
-
Sign in to {active.label}
|
|
192
|
-
</button>
|
|
193
|
-
) : allowed ? (
|
|
194
|
-
<div className="flex items-center justify-between gap-3">
|
|
195
|
-
{/* The fact, not the identifier. A raw `auth0|692be24b…` is noise: it is not
|
|
196
|
-
how anyone thinks of themselves, and it appeared only because `email` is a
|
|
197
|
-
NAMESPACED claim that a plain read misses (AUTH_TOKEN_FLOW.md names this
|
|
198
|
-
exact footgun). Read properly it is an address; absent, the sentence is
|
|
199
|
-
better without it. */}
|
|
200
|
-
<p className="text-sm font-medium text-emerald-400">
|
|
201
|
-
Ready to publish{email ? <span className="font-normal text-gray-400"> as {email}</span> : null}
|
|
202
|
-
</p>
|
|
203
|
-
<SignOut auth={auth} />
|
|
204
|
-
</div>
|
|
205
|
-
) : (
|
|
206
|
-
// Signed in but without the permission. Said HERE, not at the first push,
|
|
207
|
-
// so nobody builds for an hour and then finds out.
|
|
208
|
-
<div>
|
|
209
|
-
<p className="text-xs text-amber-400">
|
|
210
|
-
Signed in, but your account does not have{" "}
|
|
211
|
-
<code>{active.config.publishPermission}</code> on this universe. Ask whoever
|
|
212
|
-
administers it to grant it.
|
|
213
|
-
</p>
|
|
214
|
-
{/* WHAT THE TOKEN ACTUALLY CARRIES. Kept, in a reduced form, because "you do not
|
|
215
|
-
have this permission" is unhelpful when the identity provider visibly
|
|
216
|
-
says you do. Printing the claim turns a guess into a fact: it is how a
|
|
217
|
-
leading space in an Auth0 permission (" marketplace:publish") was found,
|
|
218
|
-
after the screens, the token and the label had all agreed it was correct. */}
|
|
219
|
-
<p className="mt-2 text-xs text-gray-500">
|
|
220
|
-
Your token carries: <code>{permissionList.join(", ") || "no permissions claim"}</code>
|
|
221
|
-
</p>
|
|
222
|
-
<div className="mt-2">
|
|
223
|
-
<SignOut auth={auth} />
|
|
224
|
-
</div>
|
|
225
|
-
</div>
|
|
226
|
-
)}
|
|
227
|
-
</div>
|
|
228
|
-
)}
|
|
229
|
-
</div>
|
|
230
|
-
</div>
|
|
231
|
-
);
|
|
232
|
-
}
|
package/src/PublishDialog.tsx
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Publish a project to the connected universe.
|
|
3
|
-
*
|
|
4
|
-
* PLAN, THEN SEND. Publishing writes rows that every workflow in a universe can use, so
|
|
5
|
-
* "what am I about to do" is answered before anything moves. Blind publishing is how a
|
|
6
|
-
* project half-lands and nobody can tell by looking.
|
|
7
|
-
*
|
|
8
|
-
* The plan runs the moment this opens, unasked: it reads disk and dry-runs against the
|
|
9
|
-
* universe, writing nothing. Only the second press sends. An empty dialog with a button
|
|
10
|
-
* marked "check" made the developer ask for the answer they opened the dialog to get.
|
|
11
|
-
*
|
|
12
|
-
* The work happens in Studio's local host (`local/plugin.ts`), not here: it reads the
|
|
13
|
-
* developer's disk, which a browser cannot. This screen collects a decision and shows a
|
|
14
|
-
* result. It calls the same functions as `unoverse publish assets`, so the two cannot
|
|
15
|
-
* disagree about what a publish is.
|
|
16
|
-
*
|
|
17
|
-
* See docs/architecture/DECLARATIVE_NODES.md §9.
|
|
18
|
-
*/
|
|
19
|
-
import { useEffect, useRef, useState } from "react";
|
|
20
|
-
import { useAuth } from "react-oidc-context";
|
|
21
|
-
import { canPublish, type Universe } from "./universes";
|
|
22
|
-
|
|
23
|
-
interface Item {
|
|
24
|
-
kind: string;
|
|
25
|
-
name: string;
|
|
26
|
-
why?: string;
|
|
27
|
-
mode?: string;
|
|
28
|
-
}
|
|
29
|
-
interface Plan {
|
|
30
|
-
create: Item[];
|
|
31
|
-
update: Item[];
|
|
32
|
-
unchanged: Item[];
|
|
33
|
-
refused: Item[];
|
|
34
|
-
}
|
|
35
|
-
interface Problem {
|
|
36
|
-
level: string;
|
|
37
|
-
file: string;
|
|
38
|
-
line?: number;
|
|
39
|
-
msg: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Decode a JWT payload without verifying it. Display only: the universe verifies. */
|
|
43
|
-
function claimsOf(token: string | undefined): Record<string, unknown> | null {
|
|
44
|
-
if (!token) return null;
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(atob(token.split(".")[1] ?? ""));
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function PublishDialog({
|
|
53
|
-
project,
|
|
54
|
-
universe,
|
|
55
|
-
onClose,
|
|
56
|
-
}: {
|
|
57
|
-
project: string;
|
|
58
|
-
universe: Universe;
|
|
59
|
-
onClose: () => void;
|
|
60
|
-
}) {
|
|
61
|
-
const auth = useAuth();
|
|
62
|
-
const token = auth?.user?.access_token;
|
|
63
|
-
|
|
64
|
-
const [plan, setPlan] = useState<Plan | null>(null);
|
|
65
|
-
const [problems, setProblems] = useState<Problem[]>([]);
|
|
66
|
-
const [blocked, setBlocked] = useState<Problem[] | null>(null);
|
|
67
|
-
const [sent, setSent] = useState<{ sent: Item[]; failed: Item[] } | null>(null);
|
|
68
|
-
const [busy, setBusy] = useState(false);
|
|
69
|
-
const [error, setError] = useState<string | null>(null);
|
|
70
|
-
|
|
71
|
-
const allowed = canPublish(claimsOf(token), universe.config);
|
|
72
|
-
|
|
73
|
-
async function call(step: "plan" | "send") {
|
|
74
|
-
setError(null);
|
|
75
|
-
setBusy(true);
|
|
76
|
-
try {
|
|
77
|
-
const res = await fetch(`/publish/${step}`, {
|
|
78
|
-
method: "POST",
|
|
79
|
-
headers: { "content-type": "application/json" },
|
|
80
|
-
body: JSON.stringify({ project, universe: universe.url, token }),
|
|
81
|
-
});
|
|
82
|
-
const body = await res.json();
|
|
83
|
-
if (body.error) throw new Error(body.error);
|
|
84
|
-
// Errors STOP the publish. Reported as findings rather than a failure, because the
|
|
85
|
-
// developer's next action is to fix a file, not to retry.
|
|
86
|
-
if (body.blocked) {
|
|
87
|
-
setBlocked(body.errors);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
setProblems(body.problems ?? []);
|
|
91
|
-
setPlan(body.plan);
|
|
92
|
-
if (step === "send") setSent({ sent: body.sent ?? [], failed: body.failed ?? [] });
|
|
93
|
-
} catch (e: any) {
|
|
94
|
-
setError(e?.message ?? String(e));
|
|
95
|
-
} finally {
|
|
96
|
-
setBusy(false);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* PLAN ON OPEN. The dialog's whole job is to answer "what am I about to publish", so it
|
|
102
|
-
* answers it without being asked. Planning reads disk and dry-runs against the universe;
|
|
103
|
-
* it writes nothing, which is what makes running it unprompted safe.
|
|
104
|
-
*
|
|
105
|
-
* The guard is a ref rather than the usual empty dep array: `call` closes over state that
|
|
106
|
-
* changes, and a second plan on a re-render would be a wasted round trip and a flicker.
|
|
107
|
-
*/
|
|
108
|
-
const planned = useRef(false);
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (planned.current || !allowed || !token) return;
|
|
111
|
-
planned.current = true;
|
|
112
|
-
call("plan");
|
|
113
|
-
}, [allowed, token]);
|
|
114
|
-
|
|
115
|
-
const toSend = (plan?.create.length ?? 0) + (plan?.update.length ?? 0);
|
|
116
|
-
const warnings = problems.filter((p) => p.level === "warn").length;
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* ONE ROW PER ITEM. A comma-joined list per status cannot be scanned: the question a
|
|
120
|
-
* developer opens this with is "which of MY things is about to move", and that is a
|
|
121
|
-
* property of the item, not of a group heading. So every item gets a line carrying its
|
|
122
|
-
* own status, and the statuses sort so the ones that will move sit at the top.
|
|
123
|
-
*
|
|
124
|
-
* The kind is singular here, because a row is one thing. `bpp` is a style set and
|
|
125
|
-
* `bpp-chat-layout` is an app; without that word they read as five apps, which is how a
|
|
126
|
-
* developer concludes their components were never collected when they were.
|
|
127
|
-
*/
|
|
128
|
-
const KIND_LABEL: Record<string, string> = {
|
|
129
|
-
component: "component",
|
|
130
|
-
atom: "atom",
|
|
131
|
-
style: "styles",
|
|
132
|
-
template: "app",
|
|
133
|
-
node: "node",
|
|
134
|
-
skill: "skill",
|
|
135
|
-
"prompt-block": "prompt block",
|
|
136
|
-
recipe: "recipe",
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Contrast is the whole job here. Dark text on a dark panel made a five row list
|
|
141
|
-
* unreadable, so the name carries full weight, the status carries colour, and only the
|
|
142
|
-
* kind is allowed to recede.
|
|
143
|
-
*/
|
|
144
|
-
const STATUS = {
|
|
145
|
-
create: { label: "new", pill: "bg-emerald-400/15 text-emerald-300 ring-emerald-400/30", order: 0 },
|
|
146
|
-
update: { label: "changed", pill: "bg-amber-400/15 text-amber-300 ring-amber-400/30", order: 1 },
|
|
147
|
-
refused: { label: "refused", pill: "bg-red-400/15 text-red-300 ring-red-400/30", order: 2 },
|
|
148
|
-
unchanged: { label: "unchanged", pill: "bg-gray-500/10 text-gray-400 ring-gray-500/30", order: 3 },
|
|
149
|
-
} as const;
|
|
150
|
-
type Status = keyof typeof STATUS;
|
|
151
|
-
|
|
152
|
-
/** Every item, flattened, carrying its status. Sorted by what will move, then by name. */
|
|
153
|
-
const rows: Array<Item & { status: Status }> = plan
|
|
154
|
-
? (Object.keys(STATUS) as Status[])
|
|
155
|
-
.flatMap((status) => plan[status].map((i) => ({ ...i, status })))
|
|
156
|
-
.sort(
|
|
157
|
-
(a, b) =>
|
|
158
|
-
STATUS[a.status].order - STATUS[b.status].order ||
|
|
159
|
-
a.kind.localeCompare(b.kind) ||
|
|
160
|
-
a.name.localeCompare(b.name),
|
|
161
|
-
)
|
|
162
|
-
: [];
|
|
163
|
-
|
|
164
|
-
return (
|
|
165
|
-
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
|
|
166
|
-
<div
|
|
167
|
-
className="w-full max-w-xl rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
|
|
168
|
-
onClick={(e) => e.stopPropagation()}
|
|
169
|
-
>
|
|
170
|
-
<h2 className="text-base font-semibold text-gray-100">
|
|
171
|
-
Publish <span className="text-gray-400">{project}</span> to {universe.label}
|
|
172
|
-
</h2>
|
|
173
|
-
|
|
174
|
-
{/* Said HERE, before any work, not at the first push. */}
|
|
175
|
-
{!allowed && (
|
|
176
|
-
<p className="mt-3 text-xs text-amber-400">
|
|
177
|
-
Your account does not have <code>{universe.config.publishPermission}</code> on this
|
|
178
|
-
universe. Ask whoever administers it to grant it.
|
|
179
|
-
</p>
|
|
180
|
-
)}
|
|
181
|
-
|
|
182
|
-
{error && <p className="mt-3 text-xs text-red-400">{error}</p>}
|
|
183
|
-
|
|
184
|
-
{busy && !plan && !blocked && (
|
|
185
|
-
<p className="mt-4 text-sm text-gray-500">Reading {project}…</p>
|
|
186
|
-
)}
|
|
187
|
-
|
|
188
|
-
{/* Lint errors: nothing was sent, and the file and line are what fixes it. */}
|
|
189
|
-
{blocked && (
|
|
190
|
-
<div className="mt-4">
|
|
191
|
-
<p className="text-xs text-red-400">
|
|
192
|
-
{blocked.length} error{blocked.length === 1 ? "" : "s"} in {project}. Nothing was sent.
|
|
193
|
-
</p>
|
|
194
|
-
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto text-xs">
|
|
195
|
-
{blocked.slice(0, 12).map((p, i) => (
|
|
196
|
-
<li key={i} className="text-gray-400">
|
|
197
|
-
<span className="text-gray-500">
|
|
198
|
-
{p.file}
|
|
199
|
-
{p.line ? `:${p.line}` : ""}
|
|
200
|
-
</span>{" "}
|
|
201
|
-
{p.msg}
|
|
202
|
-
</li>
|
|
203
|
-
))}
|
|
204
|
-
</ul>
|
|
205
|
-
</div>
|
|
206
|
-
)}
|
|
207
|
-
|
|
208
|
-
{plan && !blocked && (
|
|
209
|
-
<div className="mt-4">
|
|
210
|
-
{/* The count sentence answers "how much", the list answers "which". Both, always. */}
|
|
211
|
-
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
|
|
212
|
-
{[
|
|
213
|
-
plan.create.length && `${plan.create.length} new`,
|
|
214
|
-
plan.update.length && `${plan.update.length} changed`,
|
|
215
|
-
plan.unchanged.length && `${plan.unchanged.length} unchanged`,
|
|
216
|
-
plan.refused.length && `${plan.refused.length} refused`,
|
|
217
|
-
]
|
|
218
|
-
.filter(Boolean)
|
|
219
|
-
.join(", ") || "nothing to publish"}
|
|
220
|
-
</p>
|
|
221
|
-
|
|
222
|
-
<div className="mt-3 max-h-72 divide-y divide-white/5 overflow-y-auto rounded-lg border border-white/10 bg-black/20">
|
|
223
|
-
{rows.map((r) => (
|
|
224
|
-
<div key={`${r.kind}/${r.name}`} className="flex items-baseline gap-3 px-3 py-2 hover:bg-white/5">
|
|
225
|
-
<span
|
|
226
|
-
className={`w-[74px] shrink-0 rounded px-1.5 py-0.5 text-center text-[11px] font-semibold uppercase tracking-wide ring-1 ${STATUS[r.status].pill}`}
|
|
227
|
-
>
|
|
228
|
-
{STATUS[r.status].label}
|
|
229
|
-
</span>
|
|
230
|
-
<span
|
|
231
|
-
className={`flex-1 truncate text-sm font-medium ${
|
|
232
|
-
r.status === "unchanged" ? "text-gray-400" : "text-white"
|
|
233
|
-
}`}
|
|
234
|
-
>
|
|
235
|
-
{r.name}
|
|
236
|
-
</span>
|
|
237
|
-
{/* The kind sits last and quiet: it labels the name rather than competing with it. */}
|
|
238
|
-
<span className="shrink-0 text-xs text-gray-500">{KIND_LABEL[r.kind] ?? r.kind}</span>
|
|
239
|
-
{/* The reason belongs ON the refused row: it is the only row it explains. */}
|
|
240
|
-
{r.why && <span className="shrink-0 text-xs text-red-300">{r.why}</span>}
|
|
241
|
-
</div>
|
|
242
|
-
))}
|
|
243
|
-
</div>
|
|
244
|
-
{/*
|
|
245
|
-
Said once, in the place where its absence gets noticed. The Components tab
|
|
246
|
-
lists the design system alongside this project's own, so a plan holding two
|
|
247
|
-
components where the tab showed eighteen reads as a collection bug.
|
|
248
|
-
*/}
|
|
249
|
-
<p className="pt-3 text-xs leading-relaxed text-gray-500">
|
|
250
|
-
Only {project} publishes. Design system components, atoms and styles belong to
|
|
251
|
-
the universe already.
|
|
252
|
-
</p>
|
|
253
|
-
{/*
|
|
254
|
-
A COUNT ALONE IS USELESS. "2 warnings" tells a developer something is wrong
|
|
255
|
-
and gives them no way to find it, which is worse than silence: it is a nag
|
|
256
|
-
with no address. The file, line and message are what fixes it, so they are
|
|
257
|
-
here, one click away rather than one grep away.
|
|
258
|
-
*/}
|
|
259
|
-
{warnings > 0 && (
|
|
260
|
-
<details className="pt-1">
|
|
261
|
-
<summary className="cursor-pointer text-xs text-amber-300/80 hover:text-amber-300">
|
|
262
|
-
{warnings} warning{warnings === 1 ? "" : "s"}, not blocking
|
|
263
|
-
</summary>
|
|
264
|
-
<ul className="mt-2 max-h-40 space-y-2 overflow-y-auto text-xs">
|
|
265
|
-
{problems
|
|
266
|
-
.filter((p) => p.level === "warn")
|
|
267
|
-
.map((p, i) => (
|
|
268
|
-
<li key={i} className="leading-relaxed">
|
|
269
|
-
<span className="text-gray-500">
|
|
270
|
-
{p.file?.replace(/^.*\/design\//, "")}
|
|
271
|
-
{p.line ? `:${p.line}` : ""}
|
|
272
|
-
</span>
|
|
273
|
-
<br />
|
|
274
|
-
<span className="text-gray-400">{p.msg}</span>
|
|
275
|
-
</li>
|
|
276
|
-
))}
|
|
277
|
-
</ul>
|
|
278
|
-
</details>
|
|
279
|
-
)}
|
|
280
|
-
</div>
|
|
281
|
-
)}
|
|
282
|
-
|
|
283
|
-
{sent && (
|
|
284
|
-
<p className={`mt-4 text-sm ${sent.failed.length ? "text-red-400" : "text-emerald-400"}`}>
|
|
285
|
-
{sent.sent.length} published{sent.failed.length ? `, ${sent.failed.length} failed` : ""}
|
|
286
|
-
</p>
|
|
287
|
-
)}
|
|
288
|
-
|
|
289
|
-
<div className="mt-6 flex items-center gap-2">
|
|
290
|
-
{plan && !sent && toSend > 0 && (
|
|
291
|
-
<button
|
|
292
|
-
onClick={() => call("send")}
|
|
293
|
-
disabled={busy}
|
|
294
|
-
className="rounded bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 hover:bg-emerald-400 disabled:opacity-30"
|
|
295
|
-
>
|
|
296
|
-
{busy ? "Publishing…" : `Publish ${toSend} item${toSend === 1 ? "" : "s"}`}
|
|
297
|
-
</button>
|
|
298
|
-
)}
|
|
299
|
-
{plan && !sent && toSend === 0 && (
|
|
300
|
-
<p className="text-sm text-emerald-400">{project} is already up to date</p>
|
|
301
|
-
)}
|
|
302
|
-
<button onClick={onClose} className="rounded px-3 py-2 text-sm text-gray-400 hover:text-gray-200">
|
|
303
|
-
{sent ? "Done" : "Cancel"}
|
|
304
|
-
</button>
|
|
305
|
-
</div>
|
|
306
|
-
</div>
|
|
307
|
-
</div>
|
|
308
|
-
);
|
|
309
|
-
}
|
package/src/UniverseSession.tsx
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The signed-in session for ONE universe.
|
|
3
|
-
*
|
|
4
|
-
* Local Studio is offline and unauthenticated by default, and that must not change:
|
|
5
|
-
* building a component needs no account, no network and no provider. Authentication
|
|
6
|
-
* appears only when a developer connects to a universe in order to publish.
|
|
7
|
-
*
|
|
8
|
-
* So the provider is mounted CONDITIONALLY. With no universe selected, or one that has
|
|
9
|
-
* auth switched off, children render exactly as they did before this file existed.
|
|
10
|
-
*
|
|
11
|
-
* THE CONFIG COMES FROM THE UNIVERSE, NOT FROM HERE. Canvas reads its issuer and client
|
|
12
|
-
* id from `window.__GRAVITY_CONFIG__` injected into its own page; Studio runs on the
|
|
13
|
-
* developer's machine and cannot, so it asks (`universes.ts` → discover). That is what
|
|
14
|
-
* keeps the tooling provider-agnostic: nothing in Studio names Auth0.
|
|
15
|
-
*
|
|
16
|
-
* See docs/architecture/LOCAL_STUDIO.md.
|
|
17
|
-
*/
|
|
18
|
-
import type { ReactNode } from "react";
|
|
19
|
-
import { AuthProvider, AuthContext, type AuthContextProps } from "react-oidc-context";
|
|
20
|
-
import { WebStorageStateStore } from "oidc-client-ts";
|
|
21
|
-
import { loginReady, type Universe } from "./universes";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The OIDC settings for one universe. Mirrors Canvas's `oidcConfig`
|
|
25
|
-
* (`apps/canvas/src/App.jsx`) so a token minted for Studio looks the same to the
|
|
26
|
-
* platform as one minted for Canvas, with one difference: every value is read from the
|
|
27
|
-
* universe rather than from this app's build.
|
|
28
|
-
*/
|
|
29
|
-
function oidcConfigFor(universe: Universe) {
|
|
30
|
-
const { issuer, clientId, audience } = universe.config;
|
|
31
|
-
return {
|
|
32
|
-
authority: issuer as string,
|
|
33
|
-
client_id: clientId as string,
|
|
34
|
-
// Studio's own origin. The universe's identity provider must list this as an allowed
|
|
35
|
-
// callback, which is the one setup step a customer does on their side.
|
|
36
|
-
redirect_uri: window.location.origin,
|
|
37
|
-
scope: "openid profile email",
|
|
38
|
-
// Auth0 and friends only mint an API access token when an audience is asked for.
|
|
39
|
-
// Without it the login succeeds and the token is useless against the universe.
|
|
40
|
-
...(audience ? { extraQueryParams: { audience } } : {}),
|
|
41
|
-
// sessionStorage, not localStorage: the token dies with the tab. Studio's standing
|
|
42
|
-
// rule is that it never persists a secret (local/keys.ts), and a token is a secret.
|
|
43
|
-
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
|
|
44
|
-
// Same reason Canvas disables it: a stored user carries the redirect_uri it was
|
|
45
|
-
// minted with, and a silent renew against a different universe reuses the wrong one.
|
|
46
|
-
automaticSilentRenew: false,
|
|
47
|
-
onSigninCallback: () => {
|
|
48
|
-
window.history.replaceState({}, document.title, window.location.pathname);
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A signed-out session, for when Studio is not connected to a universe.
|
|
55
|
-
*
|
|
56
|
-
* Cast because `AuthContextProps` also carries the oidc-client-ts settings and event
|
|
57
|
-
* surface, none of which exists without a provider and none of which the screens touch.
|
|
58
|
-
* The honest alternative is a fake settings object, which would be a bigger lie than this.
|
|
59
|
-
*/
|
|
60
|
-
const OFFLINE_SESSION = {
|
|
61
|
-
isAuthenticated: false,
|
|
62
|
-
isLoading: false,
|
|
63
|
-
user: undefined,
|
|
64
|
-
error: undefined,
|
|
65
|
-
activeNavigator: undefined,
|
|
66
|
-
signinRedirect: offline("signinRedirect"),
|
|
67
|
-
signinPopup: offline("signinPopup"),
|
|
68
|
-
signinSilent: offline("signinSilent"),
|
|
69
|
-
signoutRedirect: offline("signoutRedirect"),
|
|
70
|
-
signoutPopup: offline("signoutPopup"),
|
|
71
|
-
signoutSilent: offline("signoutSilent"),
|
|
72
|
-
removeUser: offline("removeUser"),
|
|
73
|
-
clearStaleState: offline("clearStaleState"),
|
|
74
|
-
querySessionStatus: offline("querySessionStatus"),
|
|
75
|
-
revokeTokens: offline("revokeTokens"),
|
|
76
|
-
startSilentRenew: () => {},
|
|
77
|
-
stopSilentRenew: () => {},
|
|
78
|
-
} as unknown as AuthContextProps;
|
|
79
|
-
|
|
80
|
-
/** Reject loudly: calling this means a caller forgot Studio has no universe connected. */
|
|
81
|
-
function offline(name: string) {
|
|
82
|
-
return async () => {
|
|
83
|
-
throw new Error(`${name} needs a universe. Connect one first (Studio is offline).`);
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function UniverseSession({
|
|
88
|
-
universe,
|
|
89
|
-
children,
|
|
90
|
-
}: {
|
|
91
|
-
universe: Universe | null;
|
|
92
|
-
children: ReactNode;
|
|
93
|
-
}) {
|
|
94
|
-
// No universe, or one that cannot be logged into: the offline case, and the normal one.
|
|
95
|
-
//
|
|
96
|
-
// The shared screens call `useAuth()` unconditionally and cope with having no session.
|
|
97
|
-
// But react-oidc-context warns whenever the context VALUE is falsy:
|
|
98
|
-
//
|
|
99
|
-
// const context = React.useContext(AuthContext);
|
|
100
|
-
// if (!context) console.warn("AuthProvider context is undefined, ...");
|
|
101
|
-
//
|
|
102
|
-
// so mounting the provider with `undefined` warns exactly as loudly as mounting nothing
|
|
103
|
-
// (both were tried). With TemplatesView re-rendering it is a stream of warnings about a
|
|
104
|
-
// state that is fully supported.
|
|
105
|
-
//
|
|
106
|
-
// So hand it a real value meaning SIGNED OUT. Everything the screens read is present and
|
|
107
|
-
// false; the sign-in actions throw rather than no-op, because reaching one from here
|
|
108
|
-
// would mean a code path forgot there is no universe, and silence would hide that.
|
|
109
|
-
if (!universe || !loginReady(universe.config).ok) {
|
|
110
|
-
return <AuthContext.Provider value={OFFLINE_SESSION}>{children}</AuthContext.Provider>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// `key` forces a fresh provider when the universe changes. Without it the library keeps
|
|
114
|
-
// the previous authority and client id, and a developer switching universes would stay
|
|
115
|
-
// signed in to the old one while appearing to be on the new.
|
|
116
|
-
return (
|
|
117
|
-
<AuthProvider key={universe.url} {...oidcConfigFor(universe)}>
|
|
118
|
-
{children}
|
|
119
|
-
</AuthProvider>
|
|
120
|
-
);
|
|
121
|
-
}
|
package/src/universes.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The universes this developer can publish to.
|
|
3
|
-
*
|
|
4
|
-
* A UNIVERSE NAMES ITS OWN IDENTITY PROVIDER. Studio hardcodes no issuer and no vendor:
|
|
5
|
-
* it asks a universe at `/.well-known/unoverse-universe` and follows the answer, so
|
|
6
|
-
* Auth0, Okta, Entra and Keycloak all work with no change here. That is the whole reason
|
|
7
|
-
* login lives in Studio (a browser, where OIDC is a redirect) rather than in the CLI.
|
|
8
|
-
*
|
|
9
|
-
* THERE IS NO CENTRAL DIRECTORY, and there cannot be. Every universe is independent with
|
|
10
|
-
* its own database and its own IdP (docs/AUTH_TOKEN_FLOW.md: "OIDC-provider-agnostic").
|
|
11
|
-
* Nothing sits above them to enumerate. So "the universes I can deploy to" means the ones
|
|
12
|
-
* this developer has connected to, held here on their machine. That is the honest answer,
|
|
13
|
-
* not a placeholder for a registry.
|
|
14
|
-
*
|
|
15
|
-
* NO TOKEN IS EVER WRITTEN HERE. Only the URL, a label, and the public config. The access
|
|
16
|
-
* token stays wherever the OIDC library keeps it for the session. Local Studio already
|
|
17
|
-
* holds this line for credentials (`local/keys.ts`: "Studio therefore never WRITES a
|
|
18
|
-
* secret") and connecting to a universe must not be the thing that breaks it.
|
|
19
|
-
*
|
|
20
|
-
* See docs/architecture/LOCAL_STUDIO.md.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** What a universe publishes about itself. Mirrors `getPublicAuthConfig()` on the server. */
|
|
24
|
-
export interface UniverseConfig {
|
|
25
|
-
/** False means the universe is not gated at all: do not send anyone to log in. */
|
|
26
|
-
authEnabled: boolean;
|
|
27
|
-
issuer: string | null;
|
|
28
|
-
clientId: string | null;
|
|
29
|
-
audience: string | null;
|
|
30
|
-
/** The permission a token needs before this universe accepts a publish. */
|
|
31
|
-
publishPermission: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface Universe {
|
|
35
|
-
/** Origin, normalised. The identity of the entry: one row per universe. */
|
|
36
|
-
url: string;
|
|
37
|
-
/** What the developer calls it. Defaults to the host. */
|
|
38
|
-
label: string;
|
|
39
|
-
config: UniverseConfig;
|
|
40
|
-
/** When it was last reachable, ISO. Purely informational. */
|
|
41
|
-
connectedAt?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const STORAGE_KEY = "studio:universes";
|
|
45
|
-
|
|
46
|
-
/** The path a universe serves its public config on. */
|
|
47
|
-
export const DISCOVERY_PATH = "/.well-known/unoverse-universe";
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Turn whatever a developer typed into an origin.
|
|
51
|
-
*
|
|
52
|
-
* People type `localhost:4105`, `example.com/`, and `https://example.com/canvas`. All
|
|
53
|
-
* three mean one universe, and storing them as three entries would let the same universe
|
|
54
|
-
* hold three different logins. So this is the identity function for the list.
|
|
55
|
-
*
|
|
56
|
-
* Bare hosts default to https, EXCEPT localhost and 127.0.0.1, which are overwhelmingly
|
|
57
|
-
* a developer's own machine over http and would otherwise fail confusingly.
|
|
58
|
-
*/
|
|
59
|
-
export function normaliseUrl(input: string): string {
|
|
60
|
-
const trimmed = (input ?? "").trim();
|
|
61
|
-
if (!trimmed) throw new Error("Enter a universe address");
|
|
62
|
-
|
|
63
|
-
const isLocal = /^(https?:\/\/)?(localhost|127\.0\.0\.1)(:|\/|$)/i.test(trimmed);
|
|
64
|
-
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `${isLocal ? "http" : "https"}://${trimmed}`;
|
|
65
|
-
|
|
66
|
-
let parsed: URL;
|
|
67
|
-
try {
|
|
68
|
-
parsed = new URL(withScheme);
|
|
69
|
-
} catch {
|
|
70
|
-
throw new Error(`"${input}" is not a valid address`);
|
|
71
|
-
}
|
|
72
|
-
return parsed.origin;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** A readable default name: the host, without the port for a real domain. */
|
|
76
|
-
export function defaultLabel(url: string): string {
|
|
77
|
-
try {
|
|
78
|
-
return new URL(url).host;
|
|
79
|
-
} catch {
|
|
80
|
-
return url;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Ask a universe how to log in.
|
|
86
|
-
*
|
|
87
|
-
* Failure here is the common case, not the exception: wrong address, universe down, a
|
|
88
|
-
* plain website that is not a universe at all. Each gets a message a developer can act
|
|
89
|
-
* on, because "Failed to fetch" tells them nothing about which of the three it was.
|
|
90
|
-
*/
|
|
91
|
-
export async function discover(
|
|
92
|
-
url: string,
|
|
93
|
-
fetchImpl: typeof fetch = fetch,
|
|
94
|
-
): Promise<UniverseConfig> {
|
|
95
|
-
let response: Response;
|
|
96
|
-
try {
|
|
97
|
-
response = await fetchImpl(`${url}${DISCOVERY_PATH}`, { headers: { accept: "application/json" } });
|
|
98
|
-
} catch {
|
|
99
|
-
throw new Error(`Could not reach ${url}. Check the address, and that the universe is running.`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (response.status === 404) {
|
|
103
|
-
throw new Error(`Something answered at ${url}, but it is not a universe.`);
|
|
104
|
-
}
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
throw new Error(`${url} answered with an error (${response.status}).`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
let body: unknown;
|
|
110
|
-
try {
|
|
111
|
-
body = await response.json();
|
|
112
|
-
} catch {
|
|
113
|
-
throw new Error(`${url} answered, but not like a universe. If it is one, it may need restarting.`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const cfg = body as Partial<UniverseConfig>;
|
|
117
|
-
// authEnabled is the field that decides whether we send someone to log in, so its
|
|
118
|
-
// absence is a broken response rather than a default worth guessing at.
|
|
119
|
-
if (typeof cfg?.authEnabled !== "boolean") {
|
|
120
|
-
throw new Error(`${url} answered, but not like a universe. If it is one, it may need restarting.`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
authEnabled: cfg.authEnabled,
|
|
125
|
-
issuer: cfg.issuer ?? null,
|
|
126
|
-
clientId: cfg.clientId ?? null,
|
|
127
|
-
audience: cfg.audience ?? null,
|
|
128
|
-
publishPermission: cfg.publishPermission ?? "marketplace:publish",
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Can this universe actually be logged into?
|
|
134
|
-
*
|
|
135
|
-
* A universe can say `authEnabled: true` and still be missing an issuer or a client id,
|
|
136
|
-
* which is a misconfigured universe rather than a Studio problem. Saying so beats
|
|
137
|
-
* redirecting the browser to `undefined`.
|
|
138
|
-
*/
|
|
139
|
-
export function loginReady(config: UniverseConfig): { ok: true } | { ok: false; reason: string } {
|
|
140
|
-
if (!config.authEnabled) return { ok: false, reason: "This universe has authentication switched off." };
|
|
141
|
-
if (!config.issuer) return { ok: false, reason: "This universe has no identity provider configured." };
|
|
142
|
-
if (!config.clientId) return { ok: false, reason: "This universe has no client id configured for sign-in." };
|
|
143
|
-
return { ok: true };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Does this token allow publishing here?
|
|
148
|
-
*
|
|
149
|
-
* Checked so Studio can say "signed in, but not allowed to publish" up front, instead of
|
|
150
|
-
* letting someone build for an hour and fail on the push. Namespace-aware for the same
|
|
151
|
-
* reason the server is: an IdP may emit `permissions` under a namespaced claim, and a
|
|
152
|
-
* plain read silently returns nothing (docs/AUTH_TOKEN_FLOW.md).
|
|
153
|
-
*/
|
|
154
|
-
export function canPublish(claims: Record<string, unknown> | null | undefined, config: UniverseConfig): boolean {
|
|
155
|
-
if (!claims) return false;
|
|
156
|
-
const direct = claims.permissions;
|
|
157
|
-
const namespacedKey = Object.keys(claims).find(
|
|
158
|
-
(k) => k.endsWith("/permissions") || k.endsWith("/claims/permissions"),
|
|
159
|
-
);
|
|
160
|
-
const list = Array.isArray(direct) ? direct : namespacedKey && Array.isArray(claims[namespacedKey]) ? (claims[namespacedKey] as unknown[]) : [];
|
|
161
|
-
|
|
162
|
-
// TRIMMED, because a permission is typed into an identity provider by a person and a
|
|
163
|
-
// stray space is invisible everywhere it is displayed. One arrived as " marketplace:publish"
|
|
164
|
-
// and cost a long debugging session: the Auth0 screen showed it assigned, the token
|
|
165
|
-
// carried it, the label printed it, and the comparison correctly said no. Nothing about
|
|
166
|
-
// surrounding whitespace is ever meaningful in a permission name.
|
|
167
|
-
const wanted = String(config.publishPermission).trim();
|
|
168
|
-
return list.some((p) => String(p).trim() === wanted);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ── The stored list ───────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
type Store = Pick<Storage, "getItem" | "setItem">;
|
|
174
|
-
|
|
175
|
-
function store(): Store | null {
|
|
176
|
-
try {
|
|
177
|
-
return typeof localStorage === "undefined" ? null : localStorage;
|
|
178
|
-
} catch {
|
|
179
|
-
return null; // storage blocked (private mode, embedded contexts)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Every universe this developer has connected to. Never throws: a corrupt entry is
|
|
184
|
-
* dropped rather than taking the whole list, and the app down with it. */
|
|
185
|
-
export function loadUniverses(storage: Store | null = store()): Universe[] {
|
|
186
|
-
const raw = storage?.getItem(STORAGE_KEY);
|
|
187
|
-
if (!raw) return [];
|
|
188
|
-
try {
|
|
189
|
-
const parsed = JSON.parse(raw);
|
|
190
|
-
if (!Array.isArray(parsed)) return [];
|
|
191
|
-
return parsed.filter((u): u is Universe => typeof u?.url === "string" && typeof u?.config?.authEnabled === "boolean");
|
|
192
|
-
} catch {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Add or update one, keyed on url so reconnecting refreshes rather than duplicates. */
|
|
198
|
-
export function saveUniverse(universe: Universe, storage: Store | null = store()): Universe[] {
|
|
199
|
-
const next = [...loadUniverses(storage).filter((u) => u.url !== universe.url), universe].sort((a, b) =>
|
|
200
|
-
a.label.localeCompare(b.label),
|
|
201
|
-
);
|
|
202
|
-
storage?.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
203
|
-
return next;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function removeUniverse(url: string, storage: Store | null = store()): Universe[] {
|
|
207
|
-
const next = loadUniverses(storage).filter((u) => u.url !== url);
|
|
208
|
-
storage?.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
209
|
-
return next;
|
|
210
|
-
}
|