@miadi/stateloom-web 0.1.0 → 0.1.2
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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +4 -4
- package/server/.next/prerender-manifest.json +3 -3
- package/server/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error/page.js +1 -1
- package/server/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/server/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/server/.next/server/app/_not-found/page.js +1 -1
- package/server/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js +3 -2
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page/build-manifest.json +2 -2
- package/server/.next/server/app/page.js +1 -1
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +8 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__2955c0d8._.js +3 -0
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__b4e7f753._.js → [root-of-the-server]__385c85a7._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__a977f174._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__bfd16096._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -4
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__9cafe12a._.js → [root-of-the-server]__f25b51be._.js} +2 -2
- package/server/.next/server/middleware-build-manifest.js +2 -2
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/server/server-reference-manifest.js +1 -1
- package/server/.next/server/server-reference-manifest.json +1 -1
- package/server/.next/static/chunks/0a0f393b77d3422b.css +2 -0
- package/server/.next/static/chunks/{8a03c0d2436d4c0f.js → 662d1b0ce457c16b.js} +1 -1
- package/server/.next/static/chunks/aea5b1716c68391c.js +1 -0
- package/server/.next/static/chunks/e29b67abadc7c68a.js +10 -0
- package/server/.next/static/chunks/{turbopack-8f57da603e40457d.js → turbopack-34fc7de1568e2a7f.js} +1 -1
- package/server/package-lock.json +38 -5
- package/server/package.json +2 -1
- package/server/src/app/api/file/route.ts +20 -6
- package/server/src/app/api/watch/route.ts +11 -3
- package/server/src/app/globals.css +7 -64
- package/server/src/app/layout.tsx +3 -0
- package/server/src/components/BridgeProvider.tsx +10 -3
- package/server/src/components/Canvas.tsx +78 -1325
- package/server/src/components/DocSwitcher.tsx +84 -0
- package/server/src/components/SettingsPanel.tsx +11 -6
- package/server/src/components/SocketBridgeProvider.tsx +11 -4
- package/server/src/components/Toolbar.tsx +11 -3
- package/server/src/lib/docParam.ts +51 -0
- package/server/src/lib/layoutMemory.ts +20 -2
- package/server/src/lib/projectFile.ts +68 -1
- package/server/src/types/definition.ts +12 -2
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__0a18d740._.js +0 -8
- package/server/.next/server/chunks/ssr/[root-of-the-server]__517c9b38._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__632c82d7._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__ecf19999._.js +0 -3
- package/server/.next/static/chunks/367d28d76d2be05f.js +0 -10
- package/server/.next/static/chunks/b6bd7719e3445512.css +0 -1
- /package/server/.next/static/{GkITWCpEKNsMrqxlkqzGB → Q7HQy4udl4A_DaAl9bdUt}/_buildManifest.js +0 -0
- /package/server/.next/static/{GkITWCpEKNsMrqxlkqzGB → Q7HQy4udl4A_DaAl9bdUt}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{GkITWCpEKNsMrqxlkqzGB → Q7HQy4udl4A_DaAl9bdUt}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { navigateToDoc, useRequestedDoc } from "@/lib/docParam";
|
|
5
|
+
import { rememberedDocs } from "@/lib/layoutMemory";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A human switches diagrams without an agent and without a terminal
|
|
9
|
+
* (chart_1785683062725). Presentation over state that already exists: recents
|
|
10
|
+
* are the documents this browser holds layout memory for, plus a free path
|
|
11
|
+
* field. Choosing navigates via `?doc=` — the providers re-key (step 3), the
|
|
12
|
+
* server allowlist guards (step 1), no reload, no new transport.
|
|
13
|
+
*/
|
|
14
|
+
export default function DocSwitcher() {
|
|
15
|
+
const requested = useRequestedDoc();
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [typed, setTyped] = useState("");
|
|
18
|
+
|
|
19
|
+
const go = (doc: string | null) => {
|
|
20
|
+
setOpen(false);
|
|
21
|
+
setTyped("");
|
|
22
|
+
navigateToDoc(doc);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const recents = open
|
|
26
|
+
? rememberedDocs().filter((d) => d.startsWith("/") && d !== requested)
|
|
27
|
+
: [];
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<span className="relative inline-flex">
|
|
31
|
+
<button
|
|
32
|
+
onClick={() => setOpen((v) => !v)}
|
|
33
|
+
className="toolbar-btn"
|
|
34
|
+
title={requested ? `Document: ${requested}` : "Switch document"}
|
|
35
|
+
>
|
|
36
|
+
⇄
|
|
37
|
+
</button>
|
|
38
|
+
{open && (
|
|
39
|
+
<div className="absolute left-0 top-full z-50 mt-1 w-72 rounded border border-gray-700 bg-gray-900 p-2 shadow-lg">
|
|
40
|
+
<div className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">
|
|
41
|
+
switch document
|
|
42
|
+
</div>
|
|
43
|
+
{requested && (
|
|
44
|
+
<button
|
|
45
|
+
onClick={() => go(null)}
|
|
46
|
+
className="block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800"
|
|
47
|
+
title="Return to the default project file"
|
|
48
|
+
>
|
|
49
|
+
⌂ default document
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
{recents.map((d) => (
|
|
53
|
+
<button
|
|
54
|
+
key={d}
|
|
55
|
+
onClick={() => go(d)}
|
|
56
|
+
className="block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800"
|
|
57
|
+
title={d}
|
|
58
|
+
>
|
|
59
|
+
{d.split("/").pop()}
|
|
60
|
+
<span className="ml-1 text-[10px] text-gray-600">{d}</span>
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
<form
|
|
64
|
+
onSubmit={(e) => {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (typed.trim()) go(typed.trim());
|
|
67
|
+
}}
|
|
68
|
+
className="mt-1 flex gap-1"
|
|
69
|
+
>
|
|
70
|
+
<input
|
|
71
|
+
value={typed}
|
|
72
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
73
|
+
placeholder="/absolute/path/to/doc.smdf.json"
|
|
74
|
+
className="min-w-0 flex-1 rounded border border-gray-700 bg-gray-950 px-1.5 py-1 text-xs text-gray-200 placeholder:text-gray-600"
|
|
75
|
+
/>
|
|
76
|
+
<button type="submit" className="toolbar-btn" title="Open this document">
|
|
77
|
+
→
|
|
78
|
+
</button>
|
|
79
|
+
</form>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -55,11 +55,13 @@ export default function SettingsPanel() {
|
|
|
55
55
|
<span className="text-xs text-gray-500">Context Class</span>
|
|
56
56
|
<input
|
|
57
57
|
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200"
|
|
58
|
-
value={context?.
|
|
58
|
+
value={context?.class ?? ""}
|
|
59
59
|
placeholder="e.g. MyStateMachineContext"
|
|
60
60
|
onChange={(e) =>
|
|
61
61
|
updateSettings({
|
|
62
|
-
|
|
62
|
+
// `class`, not `className`: the generator names the context from
|
|
63
|
+
// `settings.context?.class`, and nothing has ever read the other.
|
|
64
|
+
context: { ...context, class: e.target.value || undefined },
|
|
63
65
|
})
|
|
64
66
|
}
|
|
65
67
|
/>
|
|
@@ -97,15 +99,18 @@ export default function SettingsPanel() {
|
|
|
97
99
|
<h4 className="text-xs font-semibold text-gray-500">Object References</h4>
|
|
98
100
|
<textarea
|
|
99
101
|
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 h-12 font-mono"
|
|
100
|
-
value={(settings.objects ?? []).map((o) => `${o.
|
|
101
|
-
placeholder="
|
|
102
|
+
value={(settings.objects ?? []).map((o) => `${o.instance}: ${o.class}`).join("\n")}
|
|
103
|
+
placeholder="instance: Class (one per line)"
|
|
102
104
|
onChange={(e) => {
|
|
105
|
+
// `instance: Class` — the pair the generator constructs the context
|
|
106
|
+
// with. Written as `{ name, type }` until now, which is a shape
|
|
107
|
+
// `codegen.ts` has never read.
|
|
103
108
|
const objects = e.target.value
|
|
104
109
|
.split("\n")
|
|
105
110
|
.filter((l) => l.includes(":"))
|
|
106
111
|
.map((l) => {
|
|
107
|
-
const [
|
|
108
|
-
return {
|
|
112
|
+
const [instance, cls] = l.split(":").map((s) => s.trim());
|
|
113
|
+
return { instance, class: cls };
|
|
109
114
|
});
|
|
110
115
|
updateSettings({ objects });
|
|
111
116
|
}}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type StateMachineDefinition,
|
|
12
12
|
} from "@miadi/stateloom-protocol";
|
|
13
13
|
import { useDesignerStore } from "@/store/useDesignerStore";
|
|
14
|
+
import { docQuery, useRequestedDoc } from "@/lib/docParam";
|
|
14
15
|
|
|
15
16
|
const OUTBOUND_DEBOUNCE_MS = 60;
|
|
16
17
|
|
|
@@ -26,6 +27,9 @@ const OUTBOUND_DEBOUNCE_MS = 60;
|
|
|
26
27
|
*/
|
|
27
28
|
export default function SocketBridgeProvider({ url }: { url: string }) {
|
|
28
29
|
const presence = useDesignerStore((s) => s.presence);
|
|
30
|
+
// The requested `?doc=` (step 2): passed through to the file API, which
|
|
31
|
+
// resolves and guards it; the join below keys the room by the RESOLVED path.
|
|
32
|
+
const requested = useRequestedDoc();
|
|
29
33
|
|
|
30
34
|
useEffect(() => {
|
|
31
35
|
if (!url) return;
|
|
@@ -42,7 +46,7 @@ export default function SocketBridgeProvider({ url }: { url: string }) {
|
|
|
42
46
|
// Learn the docId (the resolved project-file path) from the file API.
|
|
43
47
|
let docId = "";
|
|
44
48
|
try {
|
|
45
|
-
const r = await fetch(
|
|
49
|
+
const r = await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" });
|
|
46
50
|
if (r.ok) docId = (await r.json())?.path ?? "";
|
|
47
51
|
} catch {
|
|
48
52
|
// Connect anyway; the hub keys the doc by whatever id we send.
|
|
@@ -115,9 +119,12 @@ export default function SocketBridgeProvider({ url }: { url: string }) {
|
|
|
115
119
|
if (unsub) unsub();
|
|
116
120
|
if (session) session.disconnect();
|
|
117
121
|
};
|
|
118
|
-
// Re-run when the URL arrives
|
|
119
|
-
// /api/config
|
|
120
|
-
|
|
122
|
+
// Re-run when the URL arrives (DesignBridge resolves it asynchronously from
|
|
123
|
+
// /api/config) AND when the requested document changes (step 3): the
|
|
124
|
+
// cleanup disconnects the old session, the new run joins the new room —
|
|
125
|
+
// switching needs no reload. applyRemote already rebuilds everything from
|
|
126
|
+
// an arbitrary definition, so the arriving full-def repaints the canvas.
|
|
127
|
+
}, [url, requested]);
|
|
121
128
|
|
|
122
129
|
if (presence.length === 0) return null;
|
|
123
130
|
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
type ExportFormat,
|
|
10
10
|
} from "@/lib/exportImage";
|
|
11
11
|
import type { StateDef } from "@/types/definition";
|
|
12
|
+
import DocSwitcher from "./DocSwitcher";
|
|
13
|
+
import { docQuery, useRequestedDoc } from "@/lib/docParam";
|
|
12
14
|
|
|
13
15
|
export default function Toolbar() {
|
|
14
16
|
const definition = useDesignerStore((s) => s.definition);
|
|
@@ -36,6 +38,11 @@ export default function Toolbar() {
|
|
|
36
38
|
const setRemoteMtime = useDesignerStore((s) => s.setRemoteMtime);
|
|
37
39
|
const applyRemote = useDesignerStore((s) => s.applyRemote);
|
|
38
40
|
|
|
41
|
+
// Save and reload must follow the SAME document the page is viewing —
|
|
42
|
+
// without this, 💾 on a switched document would silently write the default
|
|
43
|
+
// file (the exact mismatch the allowlist exists to prevent).
|
|
44
|
+
const requested = useRequestedDoc();
|
|
45
|
+
|
|
39
46
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
40
47
|
const [addingState, setAddingState] = useState(false);
|
|
41
48
|
const [newStateName, setNewStateName] = useState("");
|
|
@@ -63,7 +70,7 @@ export default function Toolbar() {
|
|
|
63
70
|
const json = exportJson();
|
|
64
71
|
setSaving(true);
|
|
65
72
|
try {
|
|
66
|
-
const r = await fetch(
|
|
73
|
+
const r = await fetch(`/api/file${docQuery(requested)}`, {
|
|
67
74
|
method: "PUT",
|
|
68
75
|
headers: { "Content-Type": "application/json" },
|
|
69
76
|
body: JSON.stringify({ content: json }),
|
|
@@ -115,7 +122,7 @@ export default function Toolbar() {
|
|
|
115
122
|
|
|
116
123
|
const handleReloadFromDisk = async () => {
|
|
117
124
|
try {
|
|
118
|
-
const r = await fetch(
|
|
125
|
+
const r = await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" });
|
|
119
126
|
if (!r.ok) {
|
|
120
127
|
setRemoteStatus("error", "Reload failed");
|
|
121
128
|
return;
|
|
@@ -198,11 +205,12 @@ export default function Toolbar() {
|
|
|
198
205
|
className="hidden"
|
|
199
206
|
/>
|
|
200
207
|
|
|
201
|
-
{/* File name + bridge status */}
|
|
208
|
+
{/* File name + document switcher + bridge status */}
|
|
202
209
|
<span className="text-xs text-gray-400 mr-1 truncate max-w-[50vw] md:max-w-none">
|
|
203
210
|
{fileName ?? "untitled.smdf.json"}
|
|
204
211
|
{dirty && <span className="text-yellow-500 ml-0.5">●</span>}
|
|
205
212
|
</span>
|
|
213
|
+
<DocSwitcher />
|
|
206
214
|
{remoteStatus === "synced" && (
|
|
207
215
|
<span className="text-[10px] text-emerald-500" title="In sync with disk">⌁ synced</span>
|
|
208
216
|
)}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one client-side source of the requested document (chart steps 2/3/6 of
|
|
7
|
+
* chart_1785683010290): the `?doc=` URL parameter. The browser only ever
|
|
8
|
+
* PASSES the request through — resolution and the root allowlist live on the
|
|
9
|
+
* server (lib/projectFile.ts), and the browser learns the resolved path from
|
|
10
|
+
* the API response exactly as before. No parameter → the default document,
|
|
11
|
+
* byte-identical to the old behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DOC_CHANGE_EVENT = "stateloom:doc-change";
|
|
15
|
+
|
|
16
|
+
export function requestedDoc(): string | null {
|
|
17
|
+
if (typeof window === "undefined") return null;
|
|
18
|
+
return new URLSearchParams(window.location.search).get("doc");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Query-string tail for API calls: "?doc=…" or "". */
|
|
22
|
+
export function docQuery(doc: string | null): string {
|
|
23
|
+
return doc ? `?doc=${encodeURIComponent(doc)}` : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Switch documents without a reload (step 6): rewrite the URL, tell the
|
|
27
|
+
* providers. History gets an entry so back/forward walk documents too. */
|
|
28
|
+
export function navigateToDoc(doc: string | null): void {
|
|
29
|
+
const url = new URL(window.location.href);
|
|
30
|
+
if (doc) url.searchParams.set("doc", doc);
|
|
31
|
+
else url.searchParams.delete("doc");
|
|
32
|
+
window.history.pushState({}, "", url);
|
|
33
|
+
window.dispatchEvent(new Event(DOC_CHANGE_EVENT));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The requested doc as reactive state: updates on picker navigation and on
|
|
37
|
+
* back/forward. Server render sees null; the first client effect corrects. */
|
|
38
|
+
export function useRequestedDoc(): string | null {
|
|
39
|
+
const [doc, setDoc] = useState<string | null>(null);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const update = () => setDoc(requestedDoc());
|
|
42
|
+
update();
|
|
43
|
+
window.addEventListener("popstate", update);
|
|
44
|
+
window.addEventListener(DOC_CHANGE_EVENT, update);
|
|
45
|
+
return () => {
|
|
46
|
+
window.removeEventListener("popstate", update);
|
|
47
|
+
window.removeEventListener(DOC_CHANGE_EVENT, update);
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
return doc;
|
|
51
|
+
}
|
|
@@ -36,6 +36,21 @@ export interface LayoutMemory {
|
|
|
36
36
|
viewport: Viewport;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Documents this browser holds layout memory for — the picker's recents
|
|
40
|
+
* (chart_1785683062725: presentation over state that already exists). */
|
|
41
|
+
export function rememberedDocs(): string[] {
|
|
42
|
+
try {
|
|
43
|
+
const docs: string[] = [];
|
|
44
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
45
|
+
const k = window.localStorage.key(i);
|
|
46
|
+
if (k?.startsWith(KEY_PREFIX)) docs.push(k.slice(KEY_PREFIX.length));
|
|
47
|
+
}
|
|
48
|
+
return docs.sort();
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export function memoryKey(docId: string): string {
|
|
40
55
|
return `${KEY_PREFIX}${docId}`;
|
|
41
56
|
}
|
|
@@ -108,10 +123,13 @@ export function writeMemory(
|
|
|
108
123
|
}
|
|
109
124
|
}
|
|
110
125
|
|
|
111
|
-
/** The document this browser is remembering for: the resolved project path.
|
|
126
|
+
/** The document this browser is remembering for: the resolved project path.
|
|
127
|
+
* The requested `?doc=` rides along (step 2) so memory keys follow the
|
|
128
|
+
* document the page actually asked for, not always the default. */
|
|
112
129
|
async function resolveDocId(): Promise<string> {
|
|
113
130
|
try {
|
|
114
|
-
const
|
|
131
|
+
const { requestedDoc, docQuery } = await import("./docParam");
|
|
132
|
+
const r = await fetch(`/api/file${docQuery(requestedDoc())}`, { cache: "no-store" });
|
|
115
133
|
if (r.ok) {
|
|
116
134
|
const path = (await r.json())?.path;
|
|
117
135
|
if (typeof path === "string" && path.length > 0) return path;
|
|
@@ -1,6 +1,73 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, realpathSync } from "fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve, sep } from "path";
|
|
2
3
|
import { envAlias } from "@miadi/stateloom-protocol";
|
|
3
4
|
|
|
4
5
|
export function getProjectFilePath(): string {
|
|
5
6
|
return resolve(envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json");
|
|
6
7
|
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Optional-document resolution for the file API (chart chart_1785683022927).
|
|
11
|
+
*
|
|
12
|
+
* The guard is not deferrable and ships in the same change as the parameter:
|
|
13
|
+
* the canvas binds beyond loopback and PUT /api/file writes to disk, so an
|
|
14
|
+
* unconstrained path parameter would be arbitrary file write across the
|
|
15
|
+
* tailnet. A `doc` is admitted only when it is an absolute `.json` path whose
|
|
16
|
+
* real location sits under an allowed root.
|
|
17
|
+
*
|
|
18
|
+
* Roots come from STATELOOM_DOC_ROOTS (SMCRAFT_ twin honored), colon-
|
|
19
|
+
* separated absolute paths. Unset, the allowlist is exactly the directory of
|
|
20
|
+
* the default project file — the pre-parameter surface, unchanged.
|
|
21
|
+
*/
|
|
22
|
+
export function allowedDocRoots(): string[] {
|
|
23
|
+
const raw = envAlias("DOC_ROOTS");
|
|
24
|
+
const roots = raw
|
|
25
|
+
? raw.split(":").map((r) => r.trim()).filter(Boolean)
|
|
26
|
+
: [dirname(getProjectFilePath())];
|
|
27
|
+
const real: string[] = [];
|
|
28
|
+
for (const root of roots) {
|
|
29
|
+
try {
|
|
30
|
+
real.push(realpathSync(resolve(root)));
|
|
31
|
+
} catch {
|
|
32
|
+
// A configured root that does not exist admits nothing — skipped, not fatal.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return real;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type DocResolution =
|
|
39
|
+
| { ok: true; path: string }
|
|
40
|
+
| { ok: false; error: string };
|
|
41
|
+
|
|
42
|
+
/** Resolve an optional `doc` request parameter to an admitted absolute path.
|
|
43
|
+
* No doc → the default project file, byte-identical to the old behavior. */
|
|
44
|
+
export function resolveDocPath(doc: string | null | undefined): DocResolution {
|
|
45
|
+
if (!doc) return { ok: true, path: getProjectFilePath() };
|
|
46
|
+
|
|
47
|
+
if (!isAbsolute(doc)) {
|
|
48
|
+
return { ok: false, error: "doc must be an absolute path" };
|
|
49
|
+
}
|
|
50
|
+
if (!doc.endsWith(".json")) {
|
|
51
|
+
return { ok: false, error: "doc must name a .json document" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lexical = resolve(doc);
|
|
55
|
+
// Realpath the file itself when it exists (GET/PUT of a known doc), else its
|
|
56
|
+
// parent (PUT may create the file) — symlinks cannot smuggle a path outside
|
|
57
|
+
// the allowlist either way. A doc whose parent does not exist is refused.
|
|
58
|
+
let anchored: string;
|
|
59
|
+
try {
|
|
60
|
+
anchored = existsSync(lexical)
|
|
61
|
+
? realpathSync(lexical)
|
|
62
|
+
: resolve(realpathSync(dirname(lexical)), lexical.slice(lexical.lastIndexOf(sep) + 1));
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, error: "doc directory does not exist" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const root of allowedDocRoots()) {
|
|
68
|
+
if (anchored === root || anchored.startsWith(root + sep)) {
|
|
69
|
+
return { ok: true, path: anchored };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, error: "doc is outside the allowed roots" };
|
|
73
|
+
}
|
|
@@ -10,8 +10,11 @@ export interface ObjectRef {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface ContextConfig {
|
|
13
|
+
/** The generated context's class name — what `ts/src/codegen.ts` reads. */
|
|
13
14
|
class?: string;
|
|
14
15
|
instance?: string;
|
|
16
|
+
/** The designer's own extra: a base the generated context extends. */
|
|
17
|
+
baseClass?: string;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export interface SettingsModel {
|
|
@@ -19,8 +22,15 @@ export interface SettingsModel {
|
|
|
19
22
|
name?: string;
|
|
20
23
|
description?: string;
|
|
21
24
|
asynchronous: boolean;
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Objects the generated context is constructed with. `ObjectRef` is what
|
|
27
|
+
* `ts/src/codegen.ts` actually reads (`obj.instance`, `obj.class`); the
|
|
28
|
+
* `{ name, type }` pair this field used to carry was a shape no generator
|
|
29
|
+
* could consume, so an "Object References" line typed into the designer was
|
|
30
|
+
* silently dropped on the way to code.
|
|
31
|
+
*/
|
|
32
|
+
objects?: ObjectRef[];
|
|
33
|
+
context?: ContextConfig;
|
|
24
34
|
imports?: string[];
|
|
25
35
|
using?: string[];
|
|
26
36
|
targetLanguage?: string;
|