Skip to content

Commit e80d1b5

Browse files
committed
Merge remote-tracking branch 'origin/main' into ci/parallel-safe-tests
2 parents 19e2389 + d64365f commit e80d1b5

39 files changed

Lines changed: 1035 additions & 511 deletions

RUNNING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ develop on its `main`, publish a bump, then bump the dependency here. The
3030
- Self-host boots standalone with just env vars — see
3131
`e2e/setup/selfhost.globalsetup.ts` for the canonical recipe (data dir,
3232
bootstrap admin email/password, base URL, `EXECUTOR_ALLOW_LOCAL_NETWORK`)
33+
- Self-host Vite dev shows first-run setup by default. Set
34+
`EXECUTOR_DEV_SEED_ADMIN=1` to seed `admin@example.com` /
35+
`executor-dev-admin` instead.
3336
- Cloud needs WorkOS + Autumn; for a no-.env boot, point it at emulators —
3437
see `e2e/setup/cloud.globalsetup.ts` for the canonical recipe (the real
3538
SDKs against emulated services, PGlite dev DB)

apps/host-selfhost/src/admin/invites.node.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ const signUp = (body: Record<string, unknown>) =>
2727
}),
2828
);
2929

30+
const inviteStatus = async (code: string): Promise<boolean> => {
31+
const response = await handler(
32+
new Request(`${BASE}/api/invite-status/${encodeURIComponent(code)}`),
33+
);
34+
expect(response.status).toBe(200);
35+
const body = (await response.json()) as { valid?: boolean };
36+
return body.valid === true;
37+
};
38+
3039
test("open signup is closed: a signup without a valid invite code is rejected", async () => {
3140
const res = await signUp({
3241
email: "intruder@invite.test",
@@ -47,6 +56,8 @@ test("open signup is closed: a signup without a valid invite code is rejected",
4756
test("a code minted via the admin API redeems into a real org membership", async () => {
4857
// Minted through the TYPED admin HttpApi client (see mint-invite.ts).
4958
const inviteCode = await mintInviteCode(handler);
59+
expect(await inviteStatus("AAAA-BBBB-CCCC")).toBe(false);
60+
expect(await inviteStatus(inviteCode)).toBe(true);
5061

5162
const res = await signUp({
5263
email: "member@invite.test",
@@ -76,4 +87,5 @@ test("a code minted via the admin API redeems into a real org membership", async
7687
inviteCode,
7788
});
7889
expect(reuse.status).not.toBe(200);
90+
expect(await inviteStatus(inviteCode)).toBe(false);
7991
});

apps/host-selfhost/src/system/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass<SystemError>()(
2020

2121
export const HealthResponse = Schema.Struct({ status: Schema.String });
2222
export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean });
23+
export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean });
24+
25+
const InviteStatusParams = { code: Schema.String };
2326

2427
export const SystemApi = HttpApiGroup.make("system")
2528
.add(
@@ -33,6 +36,13 @@ export const SystemApi = HttpApiGroup.make("system")
3336
success: SetupStatusResponse,
3437
error: [SystemError],
3538
}),
39+
)
40+
.add(
41+
HttpApiEndpoint.get("inviteStatus", "/invite-status/:code", {
42+
params: InviteStatusParams,
43+
success: InviteStatusResponse,
44+
error: [SystemError],
45+
}),
3646
);
3747

3848
export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi);

apps/host-selfhost/src/system/handlers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect";
55
import { SystemError, SystemHttpApi } from "./api";
66
import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth";
77
import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";
8+
import { findRedeemableCode } from "../auth/invites";
89

910
// ---------------------------------------------------------------------------
1011
// Handlers for the public system API. Unauthenticated; every DB touch is an
@@ -38,6 +39,16 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han
3839
});
3940
return { needsSetup: count === 0 };
4041
}),
42+
)
43+
.handle("inviteStatus", ({ params }) =>
44+
Effect.gen(function* () {
45+
const { client } = yield* SelfHostDb;
46+
const code = yield* Effect.tryPromise({
47+
try: () => findRedeemableCode(client, params.code),
48+
catch: () => new SystemError({ message: "failed to read invite status" }),
49+
});
50+
return { valid: code !== null };
51+
}),
4152
),
4253
);
4354

apps/host-selfhost/vite.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ const DEV_PORT = 5173;
3434
// via real env for anything you care about (esp. BETTER_AUTH_SECRET in prod).
3535
process.env.EXECUTOR_DATA_DIR ??= fileURLToPath(new URL("./.executor-dev/", import.meta.url));
3636
process.env.BETTER_AUTH_SECRET ??= "executor-selfhost-dev-secret-change-me-0123456789";
37-
process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL ??= "admin@example.com";
38-
process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD ??= "executor-dev-admin";
37+
if (process.env.EXECUTOR_DEV_SEED_ADMIN === "1") {
38+
process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL ??= "admin@example.com";
39+
process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD ??= "executor-dev-admin";
40+
}
3941
process.env.EXECUTOR_WEB_BASE_URL ??= `http://localhost:${DEV_PORT}`;
4042

4143
// Dev-only: forward /api, /mcp, /docs to the self-host Effect handler in-process
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { ReactNode } from "react";
2+
3+
import { Wordmark } from "@executor-js/react/components/wordmark";
4+
5+
// Split auth layout for the chromeless pages (setup, login, join): a promo
6+
// panel on the left, the form on the right. The panel follows design.md's
7+
// registry-minimal rules — graph-paper texture, mono eyebrow + index numerals,
8+
// grayscale only — so the first screen a person ever sees speaks the same
9+
// language as the app behind it. Collapses to form-only below lg.
10+
const PANEL_POINTS: ReadonlyArray<{ index: string; title: string; body: string }> = [
11+
{
12+
index: "01",
13+
title: "Connect",
14+
body: "OpenAPI, GraphQL, and MCP sources become tools your agent can call.",
15+
},
16+
{
17+
index: "02",
18+
title: "Control",
19+
body: "Policies decide which tools run, which ask first, and which are blocked.",
20+
},
21+
{
22+
index: "03",
23+
title: "Audit",
24+
body: "Every invocation is recorded, with approvals where they matter.",
25+
},
26+
];
27+
28+
export function AuthLayout(props: { readonly children: ReactNode }) {
29+
return (
30+
<div className="grid min-h-screen bg-background lg:grid-cols-[1.1fr_1fr]">
31+
<aside className="relative hidden flex-col justify-between overflow-hidden border-r border-border bg-sidebar p-12 lg:flex">
32+
{/* Graph-paper texture, faded toward the bottom (design.md signature). */}
33+
<div
34+
aria-hidden
35+
className="pointer-events-none absolute inset-0 text-foreground opacity-[0.05]"
36+
style={{
37+
backgroundImage:
38+
"linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
39+
backgroundSize: "32px 32px",
40+
maskImage: "linear-gradient(to bottom, black 30%, transparent 85%)",
41+
}}
42+
/>
43+
44+
<div className="relative">
45+
<Wordmark />
46+
</div>
47+
48+
<div className="relative max-w-md space-y-10">
49+
<div className="space-y-4">
50+
<p className="font-mono text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
51+
The integration layer for AI agents
52+
</p>
53+
<h2 className="text-4xl font-semibold tracking-[-0.04em] text-foreground">
54+
Every tool your agent needs, behind one endpoint.
55+
</h2>
56+
<p className="text-sm leading-6 text-muted-foreground">
57+
Connect your APIs once. Any MCP-compatible agent gets the whole catalog, governed by
58+
your policies.
59+
</p>
60+
</div>
61+
62+
<ul className="space-y-5">
63+
{PANEL_POINTS.map((point) => (
64+
<li key={point.index} className="flex gap-4">
65+
<span className="font-mono text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70 pt-0.5">
66+
{point.index}
67+
</span>
68+
<div>
69+
<p className="text-sm font-medium text-foreground">{point.title}</p>
70+
<p className="text-sm leading-6 text-muted-foreground">{point.body}</p>
71+
</div>
72+
</li>
73+
))}
74+
</ul>
75+
</div>
76+
77+
<p className="relative font-mono text-[11px] tracking-[0.08em] text-muted-foreground">
78+
self-hosted
79+
</p>
80+
</aside>
81+
82+
<main className="flex flex-col items-center justify-center gap-6 p-6">
83+
<div className="lg:hidden">
84+
<Wordmark />
85+
</div>
86+
{props.children}
87+
</main>
88+
</div>
89+
);
90+
}

apps/host-selfhost/web/login.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Input } from "@executor-js/react/components/input";
55
import { Label } from "@executor-js/react/components/label";
66

77
import { authClient } from "./auth-client";
8+
import { AuthLayout } from "./auth-layout";
89
import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";
910

1011
// Self-host login: email + password sign-in via Better Auth. On success we
@@ -54,12 +55,16 @@ export const LoginPage = () => {
5455
};
5556

5657
return (
57-
<div className="flex min-h-screen items-center justify-center bg-background p-6">
58+
<AuthLayout>
5859
<div className="w-full max-w-sm space-y-4 rounded-xl border border-border bg-card p-6 shadow-sm">
59-
<div className="space-y-1 text-center">
60-
<h1 className="font-mono text-2xl tracking-tight text-foreground">Executor</h1>
60+
<div className="space-y-1">
61+
<h1 className="text-xl font-semibold tracking-tight text-foreground">
62+
{mode === "signin" ? "Sign in" : "Join this instance"}
63+
</h1>
6164
<p className="text-sm text-muted-foreground">
62-
{mode === "signin" ? "Sign in to your instance" : "Join with your invite code"}
65+
{mode === "signin"
66+
? "Welcome back. Use your instance account."
67+
: "Enter the invite code you were given."}
6368
</p>
6469
</div>
6570

@@ -127,6 +132,6 @@ export const LoginPage = () => {
127132
</Button>
128133
</div>
129134
</div>
130-
</div>
135+
</AuthLayout>
131136
);
132137
};

apps/host-selfhost/web/routes/__root.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { fetchNeedsSetup } from "../setup-status";
3333
// ---------------------------------------------------------------------------
3434

3535
export const Route = createRootRoute({
36+
notFoundComponent: NotFoundPage,
3637
component: RootComponent,
3738
});
3839

@@ -50,6 +51,26 @@ const signOut = async () => {
5051
window.location.href = "/";
5152
};
5253

54+
function NotFoundPage() {
55+
return (
56+
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-10">
57+
<section className="w-full max-w-md text-center">
58+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p>
59+
<h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1>
60+
<p className="mt-2 text-sm leading-6 text-muted-foreground">
61+
There&apos;s nothing at this address.
62+
</p>
63+
<a
64+
href="/"
65+
className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
66+
>
67+
Go home
68+
</a>
69+
</section>
70+
</main>
71+
);
72+
}
73+
5374
const Loading = () => (
5475
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
5576
Loading…

apps/host-selfhost/web/routes/app/admin.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CopyButton } from "@executor-js/react/components/copy-button";
1010
import { Input } from "@executor-js/react/components/input";
1111
import { Label } from "@executor-js/react/components/label";
1212
import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
13+
import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title";
1314
import {
1415
orgMembersAtom,
1516
removeMember,
@@ -29,6 +30,7 @@ const ROLES = ["member", "admin"] as const;
2930
// are the self-host join mechanism. The API gates to owner/admin, so a
3031
// non-admin who opens this just sees load failures.
3132
function AdminPage() {
33+
useExecutorDocumentTitle("Admin");
3234
return (
3335
<div className="min-h-0 flex-1 overflow-y-auto">
3436
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-6 py-10 lg:px-8 lg:py-14">

apps/host-selfhost/web/routes/public/join.$code.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createFileRoute } from "@tanstack/react-router";
2-
import { useState, type FormEvent } from "react";
2+
import { useEffect, useState, type FormEvent } from "react";
33

44
import { Button } from "@executor-js/react/components/button";
5+
import { Card, CardDescription, CardHeader, CardTitle } from "@executor-js/react/components/card";
56
import { Input } from "@executor-js/react/components/input";
67
import { Label } from "@executor-js/react/components/label";
78

@@ -18,12 +19,37 @@ export const Route = createFileRoute("/join/$code")({
1819
// auth gate (an un-redeemed visitor has no session yet).
1920
function JoinPage() {
2021
const { code } = Route.useParams();
22+
const [inviteState, setInviteState] = useState<"checking" | "valid" | "invalid">("checking");
2123
const [name, setName] = useState("");
2224
const [email, setEmail] = useState("");
2325
const [password, setPassword] = useState("");
2426
const [error, setError] = useState<string | null>(null);
2527
const [busy, setBusy] = useState(false);
2628

29+
useEffect(() => {
30+
let alive = true;
31+
setInviteState("checking");
32+
void fetch(`/api/invite-status/${encodeURIComponent(code)}`, {
33+
credentials: "same-origin",
34+
}).then(
35+
async (response) => {
36+
const body = response.ok
37+
? ((await response.json().then(
38+
(value) => value,
39+
() => ({}),
40+
)) as { valid?: boolean })
41+
: {};
42+
if (alive) setInviteState(body.valid === true ? "valid" : "invalid");
43+
},
44+
() => {
45+
if (alive) setInviteState("invalid");
46+
},
47+
);
48+
return () => {
49+
alive = false;
50+
};
51+
}, [code]);
52+
2753
const submit = async (event: FormEvent) => {
2854
event.preventDefault();
2955
setBusy(true);
@@ -43,6 +69,30 @@ function JoinPage() {
4369
window.location.href = "/";
4470
};
4571

72+
if (inviteState === "checking") {
73+
return (
74+
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
75+
Loading…
76+
</div>
77+
);
78+
}
79+
80+
if (inviteState === "invalid") {
81+
return (
82+
<div className="flex min-h-screen items-center justify-center bg-background p-6">
83+
<Card className="w-full max-w-md">
84+
<CardHeader>
85+
<CardTitle>Invite not valid</CardTitle>
86+
<CardDescription>
87+
This invite link is invalid or has expired. Ask the person who invited you for a new
88+
link.
89+
</CardDescription>
90+
</CardHeader>
91+
</Card>
92+
</div>
93+
);
94+
}
95+
4696
return (
4797
<div className="flex min-h-screen items-center justify-center bg-background p-6">
4898
<form

0 commit comments

Comments
 (0)