Public: anyone can read this; the crew can change it.
A signed-out visitor sees Make it yours on every public app in /explore, and it carries them through sign-in to the plan of their copy #7862
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7748-a-signed-out-visitor-sees-make-it-yours → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +61
−3
README.md +2
| @@ -324,6 +324,8 @@ An unknown `kind:` or `sort:` word is dropped and named after "Read as:", with | ||
| 324 | 324 | the words it takes, on the page, in the terminal and in a `note` field of |
| 325 | 325 | `/api/v1/explore` and the MCP `explore` tool; results still show. |
| 326 | 326 | |
| 327 | A signed-out visitor to `/explore` sees **Make it yours** on every public app (not on a private one, nor one only its writers may enter); it leads to sign-in and back to the plan of their copy, and nothing is copied until they confirm. Behind a platform edge the sign-in is the edge's; a newcomer it vouched for is sent to `/signup` and may need to come back. API and MCP rows say `copyable: true` for a guest too; making the copy needs a signed-in caller, and the terminal is always signed in and offers `Y` on the repository screen. | |
| 328 | ||
| 327 | 329 | ## On your machine |
| 328 | 330 | |
| 329 | 331 | The `forge` command keeps a local copy of the parts you work on: **[docs/ON-YOUR-MACHINE.md](docs/ON-YOUR-MACHINE.md)**. |
docs/STUDIO.md +3 −1
| @@ -469,7 +469,9 @@ count of apps links there), `archived:yes` (put-away repositories too), | ||
| 469 | 469 | and words to match. An agent's working fork is how the crew builds, not a |
| 470 | 470 | find, and is left out; a private app's hours are told only to those who may |
| 471 | 471 | enter it. Every app row offers **Make it yours** (a fork) to someone signed |
| 472 | in (in the terminal, `Y` on a repository screen: name, `tab` for visibility, the plan, then a confirm). The same rows: `GET /api/v1/explore?q=…` and the MCP tool `explore` | |
| 472 | in, and a public app's row offers it to a signed-out visitor too, by way of | |
| 473 | sign-in; the README says how. In the terminal, `Y` on a repository screen makes | |
| 474 | it yours: name, `tab` for visibility, the plan, then a confirm. The same rows: `GET /api/v1/explore?q=…` and the MCP tool `explore` | |
| 473 | 475 | (`actions/explore.ts`). It is in the account menu and the palette, and the |
| 474 | 476 | front page's "All public apps" leads here. |
| 475 | 477 |
src/actions/explore.ts +4 −1
| @@ -380,7 +380,10 @@ function rank( | ||
| 380 | 380 | }; |
| 381 | 381 | |
| 382 | 382 | const copyable = (repo: Repo): boolean => { |
| 383 | if (viewer === undefined || viewer.kind === "agent" || !isActive(viewer)) return false; | |
| 383 | // A signed-out visitor is offered only public apps; the page they land on | |
| 384 | // sends them to sign in first, and the copy is made after. | |
| 385 | if (viewer === undefined) return records.get(repo.id)!.access === "public"; | |
| 386 | if (viewer.kind === "agent" || !isActive(viewer)) return false; | |
| 384 | 387 | if (repo.ownerId === viewer.id) return false; |
| 385 | 388 | const record = records.get(repo.id)!; |
| 386 | 389 | return ( |
test/edge-auth.test.ts +13
| @@ -6,6 +6,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { edgeIdentity, edgeLoginUrl, edgeLogoutUrl } from "../src/http/edge.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | 8 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | 10 | import { mintInvite } from "../src/core/invites.ts"; |
| 10 | 11 | import { setSignup } from "../src/settings/signup.ts"; |
| 11 | 12 | import { seedAdmin } from "./helpers/admin.ts"; |
| @@ -89,6 +90,18 @@ describe("a forge behind the platform edge", () => { | ||
| 89 | 90 | expect(page).not.toContain('href="/signin"'); |
| 90 | 91 | }); |
| 91 | 92 | |
| 93 | test("a signed-out visitor on Make it yours is sent to the edge's sign-in, back to the same path; a vouched newcomer to /signup", async () => { | |
| 94 | const olive = getUserByName(app.db, "olive")!; | |
| 95 | const made = await createRepo(app, olive, { name: "loved", visibility: "public" }); | |
| 96 | if (!made.ok) throw new Error(String(made.error)); | |
| 97 | const path = "/olive/loved/make-it-yours"; | |
| 98 | const anon = await fetch(`${base}${path}`, { redirect: "manual" }); | |
| 99 | expect(anon.headers.get("location")).toBe(`/_plat/login?rd=${encodeURIComponent(path)}`); | |
| 100 | const stranger = { "x-plat-user": "newcomer", "x-plat-edge-token": TOKEN }; | |
| 101 | const vouched = await fetch(`${base}${path}`, { headers: stranger, redirect: "manual" }); | |
| 102 | expect(vouched.headers.get("location")).toBe("/signup"); | |
| 103 | }); | |
| 104 | ||
| 92 | 105 | test("an unknown edge login creates no user and is offered /signup", async () => { |
| 93 | 106 | const stranger = { "x-plat-user": "newcomer", "x-plat-edge-token": TOKEN }; |
| 94 | 107 | expect((await fetch(`${base}/api/v1/user`, { headers: stranger })).status).toBe(401); |
test/explore.test.ts +39 −1
| @@ -232,7 +232,7 @@ describe("explore", () => { | ||
| 232 | 232 | const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("kind:app sort:ships")}`)).text(); |
| 233 | 233 | expect(page).toContain('value="kind:app sort:ships"'); |
| 234 | 234 | expect(page.indexOf('href="/bob/busy"')).toBeLessThan(page.indexOf('href="/alice/loved"')); |
| 235 | expect(page).not.toContain("Make it yours"); | |
| 235 | expect(page).toContain("Make it yours"); | |
| 236 | 236 | expect(page).not.toContain("alice/secret"); |
| 237 | 237 | const signedIn = await ( |
| 238 | 238 | await fetch(`${base}/explore`, { |
| @@ -312,6 +312,44 @@ describe("explore", () => { | ||
| 312 | 312 | expect(asAlice).not.toContain('href="/alice/loved/make-it-yours"'); |
| 313 | 313 | }); |
| 314 | 314 | |
| 315 | test("Make it yours is offered to a signed-out visitor on public apps only, linking to the make-it-yours path", async () => { | |
| 316 | const rows = (await api("kind:app")).items; | |
| 317 | expect(rows.find((r) => r.name === "alice/loved").copyable).toBe(true); | |
| 318 | // A public repository whose app only its writers enter is shown, not | |
| 319 | // copyable; a private app is not among a guest's rows at all. | |
| 320 | expect(rows.find((r) => r.name === "alice/gated").copyable).toBe(false); | |
| 321 | expect(rows.find((r) => r.name === "alice/secret")).toBeUndefined(); | |
| 322 | const page = await (await fetch(`${base}/explore`)).text(); | |
| 323 | expect(page).toContain('href="/alice/loved/make-it-yours"'); | |
| 324 | expect(page).not.toContain('href="/alice/gated/make-it-yours"'); | |
| 325 | expect(page).not.toContain("alice/secret"); | |
| 326 | }); | |
| 327 | ||
| 328 | test("the signed-out Make it yours link carries the visitor through sign-in to the plan of their copy, and makes no copy", async () => { | |
| 329 | const path = "/alice/loved/make-it-yours"; | |
| 330 | const next = encodeURIComponent(path); | |
| 331 | const res = await fetch(`${base}${path}`, { redirect: "manual" }); | |
| 332 | expect(res.status).toBe(303); | |
| 333 | expect(res.headers.get("location")).toBe(`/signin?next=${next}`); | |
| 334 | const copies = () => (app.db.query(`SELECT COUNT(*) AS n FROM repo WHERE owner_id = ?1`).get(bob.id) as { n: number }).n; | |
| 335 | const before = copies(); | |
| 336 | const signedIn = await fetch(`${base}/signin?next=${next}`, { | |
| 337 | method: "POST", | |
| 338 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 339 | redirect: "manual", | |
| 340 | }); | |
| 341 | expect(signedIn.status).toBe(303); | |
| 342 | expect(signedIn.headers.get("location")).toBe(path); | |
| 343 | const cookie = (signedIn.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 344 | const plan = await fetch(`${base}${signedIn.headers.get("location")}`, { headers: { cookie } }); | |
| 345 | expect(plan.status).toBe(200); | |
| 346 | const body = await plan.text(); | |
| 347 | expect(body).toContain("Make it yours"); | |
| 348 | expect(body).toContain("Yours as"); | |
| 349 | // Only the plan: the copy is made by the POST. | |
| 350 | expect(copies()).toBe(before); | |
| 351 | }); | |
| 352 | ||
| 315 | 353 | test("updated: narrows to what moved in the window, on the API and the page chips", async () => { |
| 316 | 354 | const ago = async (name: string, days: number) => { |
| 317 | 355 | const repo = await appOf(alice, name); |
new above
offline
Vague: a guest must not be shown alice/secret at all. Assert
secretis undefined.