Public: anyone can read this; the crew can change it.
Press Stop on the front door and the front door is gone #218
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/205-press-stop-on-the-front-door-and-the-fro → main 2 commits fork
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 | ||
| 85 | 85 | clean week. The page keeps itself |
| 86 | 86 | current about deployments: a deploy run for the app being viewed refetches |
| 87 | 87 | 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. | |
| 89 | 95 | |
| 90 | 96 | **How this app runs itself** (`/:owner/:name/settings`, in studio, on an |
| 91 | 97 | 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 { | ||
| 70 | 70 | return mismatch; |
| 71 | 71 | } |
| 72 | 72 | |
| 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 | ||
| 73 | 89 | /** One cookie's value from the request, or undefined when absent. */ |
| 74 | 90 | export function cookieValue( |
| 75 | 91 | request: Request, |
src/web/pages/apps.ts +36 −14
| @@ -38,7 +38,7 @@ import { | ||
| 38 | 38 | } from "../../core/repos.ts"; |
| 39 | 39 | import { isTerminal } from "../../core/runs.ts"; |
| 40 | 40 | import type { User } from "../../core/users.ts"; |
| 41 | import type { Ctx } from "../../http/context.ts"; | |
| 41 | import { safeBack, type Ctx } from "../../http/context.ts"; | |
| 42 | 42 | import { resolveAppHostname } from "../../apps/proxy.ts"; |
| 43 | 43 | import { |
| 44 | 44 | crumbsFor, |
| @@ -888,6 +888,22 @@ async function envForm( | ||
| 888 | 888 | return { repo, env: ctx.params.env ?? "", user: ctx.auth.user }; |
| 889 | 889 | } |
| 890 | 890 | |
| 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 | ||
| 891 | 907 | function envFailure(ctx: Ctx, repo: Repo, error: string): Response { |
| 892 | 908 | if (error === "not_found" || error === "no_such_env") |
| 893 | 909 | return notFoundPage(ctx); |
| @@ -897,7 +913,7 @@ function envFailure(ctx: Ctx, repo: Repo, error: string): Response { | ||
| 897 | 913 | nothing_to_deploy: |
| 898 | 914 | "There is nothing to deploy: the branch has no commits, or the pull request is closed.", |
| 899 | 915 | }; |
| 900 | return flashTo(ctx, `${repoBase(repo)}/app`, { | |
| 916 | return flashTo(ctx, envBack(ctx, repo), { | |
| 901 | 917 | tone: "danger", |
| 902 | 918 | message: words[error] ?? "That could not be done.", |
| 903 | 919 | }); |
| @@ -912,18 +928,24 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> { | ||
| 912 | 928 | if (!queued.ok) return envFailure(ctx, repo, queued.error); |
| 913 | 929 | const base = repoBase(repo); |
| 914 | 930 | 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)}.`, | |
| 925 | 947 | }, |
| 926 | ); | |
| 948 | }); | |
| 927 | 949 | } |
| 928 | 950 | |
| 929 | 951 | // POST /:owner/:name/app/:env/stop |
| @@ -933,7 +955,7 @@ export async function appStop(ctx: Ctx): Promise<Response> { | ||
| 933 | 955 | const { repo, env, user } = gate; |
| 934 | 956 | const stopped = await stopEnv(ctx.app, user, repo, env); |
| 935 | 957 | if (!stopped.ok) return envFailure(ctx, repo, stopped.error); |
| 936 | return flashTo(ctx, `${repoBase(repo)}/app`, { | |
| 958 | return flashTo(ctx, envBack(ctx, repo), { | |
| 937 | 959 | message: `Stopped ${envLabel(env as EnvName)}. Its data is kept; Redeploy brings it back.`, |
| 938 | 960 | }); |
| 939 | 961 | } |
src/web/pages/repo.ts +1 −10
| @@ -21,7 +21,7 @@ import { | ||
| 21 | 21 | import { isSafeRef, isSafeTreePath } from "../../git/guard.ts"; |
| 22 | 22 | import { blobForRender, type TreeEntry } from "../../git/read.ts"; |
| 23 | 23 | import { repoDir } from "../../git/repos.ts"; |
| 24 | import type { Ctx } from "../../http/context.ts"; | |
| 24 | import { safeBack, type Ctx } from "../../http/context.ts"; | |
| 25 | 25 | import { json, notFound } from "../../http/respond.ts"; |
| 26 | 26 | import type { Blob, RepoSource } from "../../view/source.ts"; |
| 27 | 27 | import { |
| @@ -274,15 +274,6 @@ export async function pinSubmit(ctx: Ctx): Promise<Response> { | ||
| 274 | 274 | }); |
| 275 | 275 | } |
| 276 | 276 | |
| 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 | ||
| 286 | 277 | // ---- shared pieces ---- |
| 287 | 278 | |
| 288 | 279 | /** |
test/studio.test.ts +229
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | createRun, |
| 39 | 39 | finishRun, |
| 40 | 40 | getRun, |
| 41 | listRuns, | |
| 41 | 42 | markServing, |
| 42 | 43 | noteRunProgress, |
| 43 | 44 | previewLatency, |
| @@ -5823,3 +5824,231 @@ describe("every studio page's headings form one outline", () => { | ||
| 5823 | 5824 | } |
| 5824 | 5825 | }); |
| 5825 | 5826 | }); |
| 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 | }); |
new above
offline