@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 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|publish)\b/.test(path)) return next();
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
- * PUBLISH, in two calls: plan then send.
253
- *
254
- * It runs HERE rather than in the browser because publishing reads the
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -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
- <div style={style as React.CSSProperties}>The quick brown fox</div>
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 { UniverseSession } from "./UniverseSession";
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, wrapped in the session for the selected universe.
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) already calls `useAuth()`. With no universe selected nothing is
47
- * mounted and Studio behaves exactly as it did offline.
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
- <UniverseSession universe={active}>
57
- <Shell
58
- active={active}
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
- {/* The ONLY place Studio asks for anything. Unconnected reads as an invitation,
172
- not an error: working offline is a supported state, not a missing step. */}
173
- <button
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
- * The universe Studio is signed into (the header connection), or null when offline.
26
- * THE LIVE PREVIEW'S SERVER. One source of truth: the same selection App.tsx keeps
27
- * (`studio:universe` is its usePersistentState pointer into the saved universe list),
28
- * so it is impossible to be signed into one universe and silently previewing against
29
- * another. The shared screens fall back to the local dev platform only when no
30
- * universe is connected.
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
- try {
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
  /**
@@ -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({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected, refused: conn.refusal ?? undefined });
195
- }, [conn.isReady, conn.isConnected, conn.refusal]);
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}`;
@@ -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
- }
@@ -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
- }
@@ -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
- }