Public: anyone can read this; the crew can change it.

Press Stop on the front door and the front door is gone #218

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/205-press-stop-on-the-front-door-and-the-fro → main 2 commits fork

  • Merged. mason/205-press-stop-on-the-front-door-and-the-fro was merged into main as 65a5432.

Review

Sign in to review this change.
5 files changed +289 −25
docs/STUDIO.md +7 −1
@@ -85,7 +85,13 @@ it adds how many of how many, linking to them, and says nothing extra in a
8585 clean week. The page keeps itself
8686 current about deployments: a deploy run for the app being viewed refetches
8787 the head card and previews list in place, so a change serving in its
88 preview shows up without a reload.
88 preview shows up without a reload. **Redeploy** and **Stop** therefore stay
89 where they were pressed — the app's page, or a change's — rather than
90 leaving for dev's App page or the deploy's run: the card refreshing in
91 place is what shows the result, and the flash says what was done. The page
92 to return to is the referer, and only when it is this forge's own origin
93 (`safeBack`, `src/http/context.ts`), never a form field; with no referer it
94 is the app's own studio page. Dev mode keeps its destinations.
8995
9096 **How this app runs itself** (`/:owner/:name/settings`, in studio, on an
9197 app) is that one place: the crew, each with their role in the studio's
src/http/context.ts +16
@@ -70,6 +70,22 @@ export function crossOrigin(request: Request, url: URL, log?: Log): boolean {
7070 return mismatch;
7171 }
7272
73 /**
74 * The page a form was submitted from, when it is one of ours: the referer's
75 * path and query, and only when its origin is this forge's. A referer is a
76 * hint, not a claim to trust — it decides where a person lands, never what
77 * is done — and it is absent under some privacy settings, so every caller
78 * carries its own fallback. Never take this from a form field: that is a
79 * redirect a caller writes.
80 */
81 export function safeBack(ctx: Ctx): string | undefined {
82 const referer = ctx.request.headers.get("referer");
83 if (referer === null) return undefined;
84 const parsed = URL.parse(referer);
85 if (!parsed || parsed.origin !== ctx.url.origin) return undefined;
86 return parsed.pathname + parsed.search;
87 }
88
7389 /** One cookie's value from the request, or undefined when absent. */
7490 export function cookieValue(
7591 request: Request,
src/web/pages/apps.ts +36 −14
@@ -38,7 +38,7 @@ import {
3838 } from "../../core/repos.ts";
3939 import { isTerminal } from "../../core/runs.ts";
4040 import type { User } from "../../core/users.ts";
41 import type { Ctx } from "../../http/context.ts";
41 import { safeBack, type Ctx } from "../../http/context.ts";
4242 import { resolveAppHostname } from "../../apps/proxy.ts";
4343 import {
4444 crumbsFor,
@@ -888,6 +888,22 @@ async function envForm(
888888 return { repo, env: ctx.params.env ?? "", user: ctx.auth.user };
889889 }
890890
891 /**
892 * Where an environment form sends the person who pressed it. In the studio
893 * that is the page the form was on — the app's own page, or a change's —
894 * because the head card refreshes itself on the deploy event
895 * (`appStateFragment`, `GET /:owner/:name/app/state`), so staying put shows
896 * the result better than any page a redirect could pick. The page comes
897 * from the referer, and only when the referer is this forge's own origin
898 * (`safeBack`), never from a form field; with no referer the fallback is
899 * the app's studio page. Dev mode keeps its own App page.
900 */
901 function envBack(ctx: Ctx, repo: Repo): string {
902 const base = repoBase(repo);
903 if (ctx.mode !== "studio") return `${base}/app`;
904 return safeBack(ctx) ?? base;
905 }
906
891907 function envFailure(ctx: Ctx, repo: Repo, error: string): Response {
892908 if (error === "not_found" || error === "no_such_env")
893909 return notFoundPage(ctx);
@@ -897,7 +913,7 @@ function envFailure(ctx: Ctx, repo: Repo, error: string): Response {
897913 nothing_to_deploy:
898914 "There is nothing to deploy: the branch has no commits, or the pull request is closed.",
899915 };
900 return flashTo(ctx, `${repoBase(repo)}/app`, {
916 return flashTo(ctx, envBack(ctx, repo), {
901917 tone: "danger",
902918 message: words[error] ?? "That could not be done.",
903919 });
@@ -912,18 +928,24 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> {
912928 if (!queued.ok) return envFailure(ctx, repo, queued.error);
913929 const base = repoBase(repo);
914930 const run = queued.value;
915 return redirect(
916 run === undefined ? `${base}/app` : `${base}/runs/${run.id}`,
917 {
918 url: ctx.url,
919 flash: {
920 message:
921 run === undefined
922 ? `${envLabel(env as EnvName)} already serves that commit.`
923 : `Redeploying ${envLabel(env as EnvName)}.`,
924 },
931 // Dev's App page hands the deploy over to its run; the studio stays on the
932 // page that asked for it and watches the head card change there. When
933 // there is no run at all, both fall back to a page that exists.
934 const to =
935 ctx.mode === "studio"
936 ? envBack(ctx, repo)
937 : run === undefined
938 ? `${base}/app`
939 : `${base}/runs/${run.id}`;
940 return redirect(to, {
941 url: ctx.url,
942 flash: {
943 message:
944 run === undefined
945 ? `${envLabel(env as EnvName)} already serves that commit.`
946 : `Redeploying ${envLabel(env as EnvName)}.`,
925947 },
926 );
948 });
927949 }
928950
929951 // POST /:owner/:name/app/:env/stop
@@ -933,7 +955,7 @@ export async function appStop(ctx: Ctx): Promise<Response> {
933955 const { repo, env, user } = gate;
934956 const stopped = await stopEnv(ctx.app, user, repo, env);
935957 if (!stopped.ok) return envFailure(ctx, repo, stopped.error);
936 return flashTo(ctx, `${repoBase(repo)}/app`, {
958 return flashTo(ctx, envBack(ctx, repo), {
937959 message: `Stopped ${envLabel(env as EnvName)}. Its data is kept; Redeploy brings it back.`,
938960 });
939961 }
src/web/pages/repo.ts +1 −10
@@ -21,7 +21,7 @@ import {
2121 import { isSafeRef, isSafeTreePath } from "../../git/guard.ts";
2222 import { blobForRender, type TreeEntry } from "../../git/read.ts";
2323 import { repoDir } from "../../git/repos.ts";
24 import type { Ctx } from "../../http/context.ts";
24 import { safeBack, type Ctx } from "../../http/context.ts";
2525 import { json, notFound } from "../../http/respond.ts";
2626 import type { Blob, RepoSource } from "../../view/source.ts";
2727 import {
@@ -274,15 +274,6 @@ export async function pinSubmit(ctx: Ctx): Promise<Response> {
274274 });
275275 }
276276
277 /** The page the form was submitted from, when it is one of ours. */
278 function safeBack(ctx: Ctx): string | undefined {
279 const referer = ctx.request.headers.get("referer");
280 if (referer === null) return undefined;
281 const parsed = URL.parse(referer);
282 if (!parsed || parsed.origin !== ctx.url.origin) return undefined;
283 return parsed.pathname + parsed.search;
284 }
285
286277 // ---- shared pieces ----
287278
288279 /**
test/studio.test.ts +229
@@ -38,6 +38,7 @@ import {
3838 createRun,
3939 finishRun,
4040 getRun,
41 listRuns,
4142 markServing,
4243 noteRunProgress,
4344 previewLatency,
@@ -5823,3 +5824,231 @@ describe("every studio page's headings form one outline", () => {
58235824 }
58245825 });
58255826 });
5827
5828 /**
5829 * Issue #205: the studio's head card has three buttons, and two of them used
5830 * to leave the studio — Stop for dev's App page, Redeploy for the deploy's
5831 * run page. Both now come back to the page the form was pressed on, because
5832 * the card refreshes itself on the deploy event and staying put shows the
5833 * result. Dev mode keeps its own destinations, and the page is taken from
5834 * the referer only when it is this forge's own (`safeBack`), never from the
5835 * form.
5836 */
5837 describe("the studio's Stop and Redeploy come back to the page they were pressed on", () => {
5838 let dataDir: string;
5839 let app: App;
5840 let servers: Servers;
5841 let base: string;
5842 let repo: Repo;
5843 let runtime: AppsRuntime;
5844 let cookie: string;
5845
5846 /** A form the head card draws, submitted from `from` in one of the modes. */
5847 const press = (
5848 what: "stop" | "redeploy",
5849 mode: "studio" | "dev",
5850 from: string | undefined,
5851 ): Promise<Response> => {
5852 const headers: Record<string, string> = {
5853 cookie: `${cookie}; forge_mode=${mode}`,
5854 origin: base,
5855 };
5856 if (from !== undefined) headers["referer"] = from;
5857 return fetch(`${base}/alice/notes/app/production/${what}`, {
5858 method: "POST",
5859 headers,
5860 body: new URLSearchParams({}),
5861 redirect: "manual",
5862 });
5863 };
5864
5865 /** The flash a redirect carries, decoded from its cookie. */
5866 const flashOf = (response: Response): string =>
5867 decodeURIComponent(response.headers.get("set-cookie") ?? "");
5868
5869 const studioPage = (): Promise<string> =>
5870 fetch(`${base}/alice/notes`, {
5871 headers: { cookie: `${cookie}; forge_mode=studio` },
5872 redirect: "manual",
5873 }).then((r) => r.text());
5874
5875 /**
5876 * Production serving, as the runtime holds it, without running a real
5877 * deploy — the pages read `runtime.status(record)`, and `stopEnv` only
5878 * asks the handle to stop. Same shape the "not up" head's `put` uses.
5879 */
5880 const putLive = (): void => {
5881 (
5882 runtime as unknown as { envs: Map<string, Record<string, unknown>> }
5883 ).envs.set(`${getAppByRepo(app.db, repo.id)!.id}/production`, {
5884 app: getAppByRepo(app.db, repo.id)!,
5885 env: "production",
5886 chain: Promise.resolve(),
5887 restarts: [],
5888 live: {
5889 commitOid: "a".repeat(40),
5890 handle: { port: 4321, stop: async () => {} },
5891 runId: "fake",
5892 since: new Date().toISOString(),
5893 manifest: { tty: null },
5894 },
5895 });
5896 };
5897
5898 beforeAll(async () => {
5899 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-envback-"));
5900 app = createApp({ dataDir, port: 0 });
5901 servers = await serve(app);
5902 base = servers.url;
5903 runtime = new AppsRuntime(app, {
5904 domain: "apps.test",
5905 scheme: "http",
5906 publicPort: servers.port,
5907 forgeUrl: servers.url,
5908 concurrency: 1,
5909 });
5910 const a = await createUser(app.db, {
5911 username: "alice",
5912 email: "[email protected]",
5913 password: "a-good-passphrase",
5914 adminIfFirst: true,
5915 });
5916 if (!a.ok) throw new Error(a.error);
5917 cookie = (
5918 (
5919 await fetch(`${base}/signin`, {
5920 method: "POST",
5921 body: new URLSearchParams({
5922 username: "alice",
5923 password: "a-good-passphrase",
5924 }),
5925 redirect: "manual",
5926 })
5927 ).headers.get("set-cookie") ?? ""
5928 ).split(";")[0]!;
5929 const made = await createRepo(app, a.value, {
5930 name: "notes",
5931 visibility: "public",
5932 description: "a place for notes",
5933 });
5934 if (!made.ok) throw new Error(String(made.error));
5935 repo = made.value;
5936 const seeded = await commitToBranch(app, a.value, repo, {
5937 message: "Start",
5938 files: [{ path: "README.md", content: "notes\n" }],
5939 });
5940 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
5941 const branched = await commitToBranch(app, a.value, repo, {
5942 branch: "spelling",
5943 message: "Fix a word",
5944 files: [{ path: "README.md", content: "notes.\n" }],
5945 });
5946 if (!branched.ok) throw new Error(JSON.stringify(branched.error));
5947 const pull = await openPull(app, a.value, repo, {
5948 title: "Fix a word",
5949 body: "",
5950 headBranch: "spelling",
5951 baseBranch: repo.defaultBranch,
5952 });
5953 if (!pull.ok) throw new Error(String(pull.error));
5954 const enabled = await enableApp(app, a.value, repo, { slug: "notes" });
5955 if (!enabled.ok) throw new Error(String(enabled.error));
5956 });
5957
5958 afterAll(async () => {
5959 await runtime.stop();
5960 servers.stop();
5961 app.close();
5962 await rm(dataDir, { recursive: true, force: true });
5963 });
5964
5965 test("Stop returns to the app's studio page with its flash, not to dev's App page", async () => {
5966 const stopped = await press("stop", "studio", `${base}/alice/notes`);
5967 expect(stopped.status).toBe(303);
5968 expect(stopped.headers.get("location")).toBe("/alice/notes");
5969 expect(flashOf(stopped)).toContain(
5970 "Stopped production. Its data is kept; Redeploy brings it back.",
5971 );
5972 // And the flash really lands on that page, in front of the person.
5973 const flash = (stopped.headers.get("set-cookie") ?? "").split(";")[0]!;
5974 const landed = await fetch(`${base}/alice/notes`, {
5975 headers: { cookie: `${cookie}; forge_mode=studio; ${flash}` },
5976 redirect: "manual",
5977 });
5978 expect(landed.status).toBe(200);
5979 expect((await landed.text()).replace(/\s+/g, " ")).toContain(
5980 "Stopped production. Its data is kept; Redeploy brings it back.",
5981 );
5982 });
5983
5984 test("the head card shows the new state where it stands: the live fragment, no second navigation", async () => {
5985 putLive();
5986 const serving = await studioPage();
5987 expect(serving).toContain("serving");
5988 expect(serving).toContain('action="/alice/notes/app/production/stop"');
5989 // The page a person stays on is the one that refreshes itself: its live
5990 // mount asks for the app's state on a deploy event.
5991 expect(serving).toContain('data-app="1"');
5992
5993 const stopped = await press("stop", "studio", `${base}/alice/notes`);
5994 expect(stopped.headers.get("location")).toBe("/alice/notes");
5995
5996 // What the live client fetches, with no navigation in between: the same
5997 // head card, now saying the app is down and offering Redeploy instead.
5998 const fragment = await fetch(`${base}/alice/notes/app/state`, {
5999 headers: { cookie: `${cookie}; forge_mode=studio` },
6000 redirect: "manual",
6001 });
6002 expect(fragment.status).toBe(200);
6003 const html = (await fragment.text()).replace(/\s+/g, " ");
6004 expect(html).toContain('id="app-head"');
6005 expect(html).toContain("not running");
6006 expect(html).toContain('action="/alice/notes/app/production/redeploy"');
6007 expect(html).not.toContain('action="/alice/notes/app/production/stop"');
6008 });
6009
6010 test("pressed on a change's page, Stop comes back to that change", async () => {
6011 const stopped = await press(
6012 "stop",
6013 "studio",
6014 `${base}/alice/notes/changes/1`,
6015 );
6016 expect(stopped.status).toBe(303);
6017 expect(stopped.headers.get("location")).toBe("/alice/notes/changes/1");
6018 });
6019
6020 test("no referer, or one from somewhere else, falls back to the app's studio page — never the raw /app", async () => {
6021 for (const from of [undefined, "https://evil.example/alice/notes"]) {
6022 const stopped = await press("stop", "studio", from);
6023 expect(stopped.status).toBe(303);
6024 expect(stopped.headers.get("location")).toBe("/alice/notes");
6025 }
6026 });
6027
6028 test("Redeploy stays on the page too, instead of leaving for the deploy's run page", async () => {
6029 const queued = await press("redeploy", "studio", `${base}/alice/notes`);
6030 expect(queued.status).toBe(303);
6031 expect(queued.headers.get("location")).toBe("/alice/notes");
6032 expect(queued.headers.get("location")).not.toContain("/runs/");
6033 expect(flashOf(queued)).toContain("Redeploying production.");
6034 // A run was really queued: the studio only chose not to navigate to it.
6035 expect(
6036 listRuns(app.db, repo.id, { kind: "deploy", limit: 5 }).length,
6037 ).toBeGreaterThan(0);
6038 });
6039
6040 test("dev mode keeps its destinations: Stop to the App page, Redeploy to the run", async () => {
6041 const stopped = await press("stop", "dev", `${base}/alice/notes`);
6042 expect(stopped.status).toBe(303);
6043 expect(stopped.headers.get("location")).toBe("/alice/notes/app");
6044 expect(flashOf(stopped)).toContain("Stopped production.");
6045
6046 const queued = await press("redeploy", "dev", `${base}/alice/notes`);
6047 expect(queued.status).toBe(303);
6048 const run = listRuns(app.db, repo.id, { kind: "deploy", limit: 1 })[0];
6049 expect(queued.headers.get("location")).toBe(
6050 `/alice/notes/runs/${run?.id}`,
6051 );
6052 expect(flashOf(queued)).toContain("Redeploying production.");
6053 });
6054 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs