Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions crates/spock-runtime/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,37 @@ async fn studio_index() -> Response {
serve_studio_asset("index.html")
}

// GET /~studio/{*path} — the built assets (hashed JS/CSS, bundled fonts). The
// SPA has no client-side routes, so an unknown path is a genuine 404.
// GET /~studio/{*path} — the built assets (hashed JS/CSS, bundled fonts) plus
// the SPA history fallback. The studio routes client-side via the History API
// (studio/src/lib/router.ts), so a hard reload or deep link like
// `/~studio/table/user` arrives here as a path that isn't an embedded file —
// serve the app shell and let the SPA restore the view from window.location. A
// path whose last segment carries a file extension is a real asset request, so
// a miss stays a genuine 404: a broken script/style src fails loudly instead of
// silently loading HTML.
async fn studio_asset(Path(path): Path<String>) -> Response {
serve_studio_asset(&path)
if let Some(resp) = studio_file(&path) {
return resp;
}
let last = path.rsplit('/').next().unwrap_or(path.as_str());
if last.contains('.') {
return StatusCode::NOT_FOUND.into_response();
}
serve_studio_asset("index.html")
}

fn serve_studio_asset(path: &str) -> Response {
match StudioAssets::get(path) {
Some(file) => (
studio_file(path).unwrap_or_else(|| StatusCode::NOT_FOUND.into_response())
}

fn studio_file(path: &str) -> Option<Response> {
StudioAssets::get(path).map(|file| {
(
[(axum::http::header::CONTENT_TYPE, file.metadata.mimetype())],
file.data.into_owned(),
)
.into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
.into_response()
})
}

// GET /~personas — the dev actor picker (RFD 0014 §4.3, RFD 0015 §6.1): the
Expand Down
14 changes: 14 additions & 0 deletions crates/spock-runtime/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ Rust tests skip themselves, with a note, when the bundle hasn't been built.)
behavior depends on. Pure presentational pieces with no state stay as plain
function components (that's not a hook).

## Routing

The current view lives in the URL, so a browser reload or a shared deep link
restores it instead of dropping back to the overview. `src/lib/router.ts` maps a
`Route` ↔ a pathname under the `/~studio` base (`/~studio/table/user`,
`/~studio/fn/create_post`, `/~studio/storage`, …) using the **History API** —
`app.tsx` pushes on navigation and mirrors `popstate` (back/forward) back into
the view; no `#` hash. Because it's real paths, the server needs a matching SPA
fallback: `serve_studio_asset`/`studio_asset` in `crates/spock-runtime/src/http.rs`
serve `index.html` for any `/~studio/*` path that isn't an embedded asset (a path
whose last segment has a file extension stays a genuine 404, so a broken
`script`/`link` src still fails loudly). The impersonated **Actor** is a session
toggle, not part of the URL — a reload restores the view but resets to anonymous.

## Theme

The design system is the shadcn **Mira** style + **neutral** (monochrome) theme,
Expand Down
35 changes: 33 additions & 2 deletions crates/spock-runtime/studio/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { api } from "@/lib/api"
import { AppContext } from "@/lib/app-context"
import type { AppState, StatusContent } from "@/lib/app-context"
import { pathToRoute, routeTitle, routeToPath, samePath } from "@/lib/router"
import { isDark, toggleTheme } from "@/lib/theme"
import { isActorSensitive } from "@/lib/contract"
import { hasStorage, userTables } from "@/lib/storage"
Expand Down Expand Up @@ -65,7 +66,9 @@ export default class App extends Component<Record<string, never>, AppData> {
contract: null,
personas: [],
actor: null,
route: { kind: "overview" },
// The view lives in the URL (lib/router.ts), so a reload or deep link
// restores it instead of resetting to the overview.
route: pathToRoute(window.location.pathname),
reloadKey: 0,
status: {},
whoami: null,
Expand All @@ -74,6 +77,9 @@ export default class App extends Component<Record<string, never>, AppData> {
}

async componentDidMount() {
// Back/forward navigation drives the view straight off the URL.
window.addEventListener("popstate", this.onPopState)
document.title = routeTitle(this.state.route)
const c = await api("/~contract", null)
if (c.status !== 200) {
this.setState({ bootError: `could not load /~contract (HTTP ${c.status})` })
Expand All @@ -85,6 +91,10 @@ export default class App extends Component<Record<string, never>, AppData> {
void this.refreshWhoami()
}

componentWillUnmount() {
window.removeEventListener("popstate", this.onPopState)
}

componentDidUpdate(_prevProps: Record<string, never>, prev: AppData) {
if (prev.actor !== this.state.actor || prev.reloadKey !== this.state.reloadKey) {
void this.refreshWhoami()
Expand All @@ -96,7 +106,24 @@ export default class App extends Component<Record<string, never>, AppData> {
this.setState({ whoami: r.body as WhoAmI })
}

private navigate = (route: Route) => this.setState({ route, status: {} })
// A user clicked something: push the target onto history (skipping a no-op
// that would just duplicate the current entry) and render it.
private navigate = (route: Route) => {
const path = routeToPath(route)
if (!samePath(path, window.location.pathname)) {
window.history.pushState(null, "", path)
}
document.title = routeTitle(route)
this.setState({ route, status: {} })
}

// The URL changed under us (back/forward button): mirror it into the view
// without pushing a new entry.
private onPopState = () => {
const route = pathToRoute(window.location.pathname)
document.title = routeTitle(route)
this.setState({ route, status: {} })
}
private reload = () => this.setState((s) => ({ reloadKey: s.reloadKey + 1 }))
private setActor = (actor: string | null) => this.setState({ actor })
private setStatus = (status: StatusContent) => this.setState({ status })
Expand Down Expand Up @@ -155,6 +182,10 @@ export default class App extends Component<Record<string, never>, AppData> {
function renderView(route: Route): ReactNode {
switch (route.kind) {
case "overview":
// Records have per-record pages but no dedicated overview view, so any
// records-level path (`/~studio/records`, or a nameless `/~studio/record/`)
// falls back to the home summary rather than rendering blank.
case "records":
return <Overview />
case "tables":
return <TablesOverview />
Expand Down
2 changes: 1 addition & 1 deletion crates/spock-runtime/studio/src/components/err-codes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function ErrCodes({ codes }: { codes: ErrCode[] }) {
"text-xs px-2 py-0.5 rounded border bg-muted/50 font-mono",
e.refusal && "border-foreground/40",
)}
title={e.refusal ? "minted via spock_refuse()" : undefined}
title={e.refusal ? "a declared refusal (spock_refuse)" : undefined}
>
{e.code}
{e.kind ? <span className="text-muted-foreground"> · {e.kind}</span> : null}
Expand Down
8 changes: 8 additions & 0 deletions crates/spock-runtime/studio/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
/* Import react-data-grid's stylesheet HERE, after Tailwind — not in main.tsx.
RDG wraps all of its styles in `@layer rdg`, and cascade layers are ordered by
first appearance: importing it last registers `rdg` after Tailwind's `base`
layer, so RDG's cell padding wins over Tailwind v4 Preflight's
`*{ padding:0; margin:0 }` (which lives in `base`). Imported before Tailwind,
`rdg` would sort first (lowest priority) and Preflight would flatten the grid.
Our own `.rdg{…}` rules further down stay unlayered, so they beat all layers. */
@import "react-data-grid/lib/styles.css";

@custom-variant dark (&:is(.dark *));

Expand Down
98 changes: 98 additions & 0 deletions crates/spock-runtime/studio/src/lib/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// URL ↔ Route mapping for the studio SPA.
//
// The studio is a single-page app embedded in the spock binary and served at
// `/~studio` (crates/spock-runtime/src/http.rs). Navigation used to live purely
// in React state, so a browser reload always dropped you back on the overview.
// This module makes the current view part of the URL — via the History API, not
// a `#` hash — so a hard reload or a shared deep link restores the same view.
// The Rust side has the matching SPA fallback: any `/~studio/*` path that isn't
// an embedded asset serves the app shell, which reads window.location on boot.

import type { Route } from "@/types"

// The mount point, kept in sync with `base` in vite.config.ts. BASE_URL is
// "/~studio/"; drop the trailing slash so we can append route segments.
const BASE = import.meta.env.BASE_URL.replace(/\/+$/, "")

// A Route → absolute pathname, always rooted at BASE.
export function routeToPath(route: Route): string {
switch (route.kind) {
case "overview":
return `${BASE}/`
case "tables":
return `${BASE}/tables`
case "fns":
return `${BASE}/functions`
case "records":
return `${BASE}/records`
case "storage":
return `${BASE}/storage`
case "table":
return `${BASE}/table/${encodeURIComponent(route.name)}`
case "fn":
return `${BASE}/fn/${encodeURIComponent(route.name)}`
case "record":
return `${BASE}/record/${encodeURIComponent(route.name)}`
}
}

// An absolute pathname → Route. Anything unrecognized (including BASE itself, or
// a stale/hand-typed URL) falls back to the overview, so navigation never
// dead-ends on a blank view.
export function pathToRoute(pathname: string): Route {
let rest = pathname
if (rest.startsWith(BASE)) rest = rest.slice(BASE.length)
rest = rest.replace(/^\/+/, "").replace(/\/+$/, "")
if (rest === "") return { kind: "overview" }

const slash = rest.indexOf("/")
const head = slash === -1 ? rest : rest.slice(0, slash)
const name = slash === -1 ? "" : decodeURIComponent(rest.slice(slash + 1))

switch (head) {
case "tables":
return { kind: "tables" }
case "functions":
return { kind: "fns" }
case "records":
return { kind: "records" }
case "storage":
return { kind: "storage" }
case "table":
return name ? { kind: "table", name } : { kind: "tables" }
case "fn":
return name ? { kind: "fn", name } : { kind: "fns" }
case "record":
return name ? { kind: "record", name } : { kind: "records" }
default:
return { kind: "overview" }
}
}

// Whether two pathnames point at the same view, ignoring a trailing slash — so
// clicking the view you're already on doesn't push a duplicate history entry.
export function samePath(a: string, b: string): boolean {
const norm = (p: string) => p.replace(/\/+$/, "")
return norm(a) === norm(b)
}

// Document title for a route, so tabs and history entries read meaningfully.
export function routeTitle(route: Route): string {
const suffix = "spock studio"
switch (route.kind) {
case "overview":
return suffix
case "tables":
return `Tables · ${suffix}`
case "fns":
return `Functions · ${suffix}`
case "records":
return `Records · ${suffix}`
case "storage":
return `Storage · ${suffix}`
case "table":
case "fn":
case "record":
return `${route.name} · ${suffix}`
}
}
4 changes: 2 additions & 2 deletions crates/spock-runtime/studio/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
// react-data-grid styles first, so our .rdg token overrides in index.css win
import "react-data-grid/lib/styles.css"
// index.css owns the cascade-layer order and imports react-data-grid's stylesheet
// into the correctly-ordered `rdg` layer (see the @layer note there).
import "./index.css"
import { initTheme } from "@/lib/theme"
import App from "@/app"
Expand Down
8 changes: 4 additions & 4 deletions crates/spock-runtime/studio/src/views/fn-runner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export class FnRunner extends Component<{ name: string }, State> {
<h1 className="text-xl font-semibold tracking-tight flex items-center gap-2">
{fn.name}
<Badge variant="outline">{fn.readonly ? "read" : "mut"}</Badge>
{isActorSensitive(fn) ? <Badge variant="outline">reads spock_actor()?</Badge> : null}
{isActorSensitive(fn) ? <Badge variant="outline">actor-sensitive</Badge> : null}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
function · {fn.readonly ? "GET" : "POST"} <code>/rest/v1/rpc/{fn.name}</code>
Expand All @@ -116,9 +116,9 @@ export class FnRunner extends Component<{ name: string }, State> {

{readsActor(fn) ? (
<div className="mt-2 border-l-2 border-primary/50 bg-muted/40 rounded-r-md px-3.5 py-2.5 text-[13px] text-muted-foreground">
This body references <code>spock_actor()</code> — its answer depends on the{" "}
<b className="text-foreground">Actor</b> selector above. Switch persona and re-run to
see it re-answer.
This function reads who you're acting as — its result depends on the{" "}
<b className="text-foreground">Actor</b> selector above. Switch persona and re-run
to see it change.
</div>
) : null}

Expand Down
32 changes: 14 additions & 18 deletions crates/spock-runtime/studio/src/views/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class Overview extends Component {
declare context: AppState

componentDidMount() {
this.context.setStatus({ left: <span>exposure surface · read-only</span> })
this.context.setStatus({ left: <span>read-only</span> })
}

render() {
Expand All @@ -37,8 +37,8 @@ export class Overview extends Component {
<div className="p-6 max-w-5xl">
<h1 className="text-xl font-semibold tracking-tight">Surface ledger</h1>
<p className="text-sm text-muted-foreground mt-0.5">
the v0 slice of the exposure surface, rendered from <code>/~contract</code> — role × via
arrives with v1 governance
A summary of everything this schema exposes — its tables, functions, and how
identity flows through them.
</p>
{/* the contract's own `//!` documentation (RFD 0016) */}
<Doc
Expand Down Expand Up @@ -79,18 +79,16 @@ export class Overview extends Component {
))}
</div>
<p className="text-sm text-muted-foreground mt-2.5">
Stamped from the current actor, unforgeable on the floor —{" "}
<b className="text-foreground">provenance, not governance</b>.
Set by the server from whoever you're acting as — the client can't set
or forge them.
</p>
</>
) : (
<span className="text-muted-foreground text-sm">none</span>
)}
</Card>

<SectionLabel>
Actor-sensitive functions <Badge variant="outline">heuristic</Badge>
</SectionLabel>
<SectionLabel>Actor-sensitive functions</SectionLabel>
<Card className="p-4">
{actorFns.length ? (
<>
Expand All @@ -105,8 +103,8 @@ export class Overview extends Component {
))}
</div>
<p className="text-sm text-muted-foreground mt-2.5">
Bodies that reference <code>spock_actor()</code> (a body scan, not an authoritative
contract bit). These re-answer under impersonation.
These functions read who you're acting as, so they return different
results per persona. Switch the Actor and re-run to see it.
</p>
</>
) : (
Expand All @@ -116,18 +114,16 @@ export class Overview extends Component {

{anchor ? (
<div className="mt-4 border-l-2 border-muted-foreground/50 bg-muted/40 rounded-r-md px-4 py-3 text-sm text-muted-foreground">
<b className="text-foreground">⚠ ungoverned floor write — no guard.</b> The floor
(auto-derived GraphQL/REST writes) stays actor-blind in v0: only <code>= me</code>{" "}
columns are server-stamped. The seam is preparatory, not protective, until v1{" "}
<code>policy</code>. Studio shows this rather than faking governance the language does
not yet have.
<b className="text-foreground">⚠ Writes aren't access-controlled yet.</b>{" "}
Auto-generated table writes don't check who you're acting as — only{" "}
<code>= me</code> columns are set from the actor. Impersonation changes what
you see, not what you're allowed to do.
</div>
) : null}

<SectionLabel>Per-op outcomes</SectionLabel>
<SectionLabel>Per-operation outcomes</SectionLabel>
<p className="text-sm text-muted-foreground -mt-1 mb-2">
every declared failure, from the contract — <code>errors[]</code> with the minted{" "}
<code>refusals[]</code> subset marked
Every failure each operation can return. Explicit refusals are marked with ✦.
</p>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
Expand Down
Loading
Loading