Public: anyone can read this; the crew can change it.
`/agents` points people to the home page's form, and an anonymous "Sign up" on the top forge leads there #11305
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11241-agents-points-people-to-the-home-page-s → main 4 commits fork 3 threads · 3 unresolved
4 files
changed +127
−2
src/settings/signup.ts +15
| @@ -44,6 +44,19 @@ export function getSignup(db: Database): SignupMode { | ||
| 44 | 44 | return parsed.ok ? parsed.value : SIGNUP_DEFAULT; |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | /** The mode last read or written per database: the header asks on every anonymous page, and only the writers below change it. */ | |
| 48 | const known = new WeakMap<Database, SignupMode>(); | |
| 49 | ||
| 50 | /** `getSignup` without a query once known; every write here forgets it. */ | |
| 51 | export function signupModeKnown(db: Database): SignupMode { | |
| 52 | let mode = known.get(db); | |
| 53 | if (mode === undefined) { | |
| 54 | mode = getSignup(db); | |
| 55 | known.set(db, mode); | |
| 56 | } | |
| 57 | return mode; | |
| 58 | } | |
| 59 | ||
| 47 | 60 | /** The actor kept for the value a new forge starts with. */ |
| 48 | 61 | export const FRESH_ACTOR = "fresh"; |
| 49 | 62 | |
| @@ -57,6 +70,7 @@ export const FRESH_ACTOR = "fresh"; | ||
| 57 | 70 | export function seedSignup(db: Database, fresh: boolean): void { |
| 58 | 71 | if (!fresh) return; |
| 59 | 72 | const stored = db.query("SELECT 1 FROM forge_setting WHERE key = ?1").get(STORE_KEY); |
| 73 | known.delete(db); | |
| 60 | 74 | if (stored === null) writeSetting(db, STORE_KEY, "invite", SIGNUP_DEFAULT, "invite", FRESH_ACTOR); |
| 61 | 75 | } |
| 62 | 76 | |
| @@ -65,6 +79,7 @@ export function setSignup(db: Database, value: SignupMode, actorId: string): voi | ||
| 65 | 79 | const from = getSignup(db); |
| 66 | 80 | if (from === value) return; |
| 67 | 81 | writeSetting(db, STORE_KEY, value, from, value, actorId); |
| 82 | known.delete(db); | |
| 68 | 83 | } |
| 69 | 84 | |
| 70 | 85 | /** `FORGE_SIGNUP` from the environment: unset is no opinion, a bad value is a message. */ |
src/web/layout.ts +18 −1
| @@ -28,6 +28,8 @@ import { | ||
| 28 | 28 | listReposVisibleTo, |
| 29 | 29 | } from "../core/repos.ts"; |
| 30 | 30 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 31 | import { signupModeKnown } from "../settings/signup.ts"; | |
| 32 | import { parentIssuerOf } from "../core/federation.ts"; | |
| 31 | 33 | import type { User } from "../core/users.ts"; |
| 32 | 34 | import type { Ctx } from "../http/context.ts"; |
| 33 | 35 | import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl, edgeStrangerLogin } from "../http/edge.ts"; |
| @@ -630,6 +632,21 @@ export function signInHref(ctx: Ctx): string { | ||
| 630 | 632 | return token === undefined ? "/signin" : edgeLoginUrl(ctx.url.pathname + ctx.url.search); |
| 631 | 633 | } |
| 632 | 634 | |
| 635 | /** | |
| 636 | * Where an anonymous visitor's "Sign up" leads on the top forge: `/signup` | |
| 637 | * while signup there is open, the home page's form (`/`) while it is closed. | |
| 638 | * Undefined where the button is not offered: behind a platform edge, or on a | |
| 639 | * forge that has a parent (its people come in through it). | |
| 640 | */ | |
| 641 | export function signUpHref(ctx: Ctx): string | undefined { | |
| 642 | if (ctx.auth.user !== undefined) return undefined; | |
| 643 | // The token is read from config first: no edge, no query. | |
| 644 | if (ctx.app.config.edgeToken !== undefined && activeEdgeToken(ctx.app) !== undefined) return undefined; | |
| 645 | if (parentIssuerOf(ctx.app.config) !== undefined) return undefined; | |
| 646 | const mode = signupModeKnown(ctx.app.db); | |
| 647 | return mode === "open" || mode === "request" ? "/signup" : "/"; | |
| 648 | } | |
| 649 | ||
| 633 | 650 | /** The Talk button; the count of confirms waiting on the viewer's yes is asked only here, where the button renders. */ |
| 634 | 651 | function talkButton(ctx: Ctx, user: User, about?: string): Html { |
| 635 | 652 | const waiting = waitingCount(ctx, user); |
| @@ -684,7 +701,7 @@ function topbar( | ||
| 684 | 701 | : { label: "Sign out", post: "/logout", icon: "signOut" }, |
| 685 | 702 | about, |
| 686 | 703 | )}` |
| 687 | : html`${guestMenu(ctx, theme)}${btn("Sign in", { | |
| 704 | : html`${guestMenu(ctx, theme)}${when(signUpHref(ctx), (href) => btn("Sign up", { href }))}${btn("Sign in", { | |
| 688 | 705 | href: signInHref(ctx), |
| 689 | 706 | })}`}`} |
| 690 | 707 | </span> |
src/web/pages/agents.ts +1 −1
| @@ -70,7 +70,7 @@ export function agentsPage(ctx: Ctx): Response { | ||
| 70 | 70 | description: |
| 71 | 71 | "What this forge is and how an AI assistant can help you use it: build one of its public apps, report a problem, or get a forge of your own.", |
| 72 | 72 | body: html`${pageHead("For AI assistants", { |
| 73 | sub: html`What this forge is. Plain text: <a href="/llms.txt">/llms.txt</a>`, | |
| 73 | sub: html`People start at <a href="/">home</a>. Assistants: <a href="/llms.txt">/llms.txt</a>`, | |
| 74 | 74 | })} |
| 75 | 75 | <section class="stack" aria-labelledby="agent-forge-h"> |
outdated
Principle 1: a second sentence above the form explains what the head sub could carry; put the home-page link in the sub and keep the WORDS_CEILING at 56. |
||
| 76 | 76 | <h2 id="agent-forge-h">Get a forge of your own</h2> |
test/header-signup.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** The anonymous header's "Sign up" on the top forge, and /agents pointing people home. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { setCapacity } from "../src/core/capacity.ts"; | |
| 9 | import { setSignup } from "../src/settings/signup.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | ||
| 12 | quietLogs(); | |
| 13 | ||
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-header-signup-")); | |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | }); | |
| 23 | afterAll(async () => { | |
| 24 | await servers.stop?.(); | |
| 25 | app.close(); | |
| 26 | await rm(dataDir, { recursive: true, force: true }); | |
| 27 | }); | |
| 28 | ||
| 29 | const header = async (path: string): Promise<string> => { | |
| 30 | const page = await (await fetch(`${servers.url}${path}`)).text(); | |
| 31 | return page.slice(page.indexOf('<header class="topbar"'), page.indexOf("</header>")); | |
| 32 | }; | |
| 33 | const signUpLink = (head: string) => head.match(/<a\s[^>]*href="([^"]*)"[^>]*>Sign up<\/a/)?.[1]; | |
| 34 | ||
| 35 | test("signup open: the header's Sign up leads to /signup", async () => { | |
| 36 | setSignup(app.db, "open", "test"); | |
| 37 | expect(signUpLink(await header("/explore"))).toBe("/signup"); | |
| 38 | }); | |
outdated
Add tests for request and invite modes, a forge with a parent, and an edge token: each is a branch of signUpHref with no test. |
||
| 39 | ||
| 40 | test("signup closed: the header's Sign up leads to the home page's form", async () => { | |
| 41 | setSignup(app.db, "closed", "test"); | |
| 42 | expect(signUpLink(await header("/explore"))).toBe("/"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("request opens /signup, invite and closed lead home", async () => { | |
| 46 | for (const [mode, href] of [["request", "/signup"], ["invite", "/"], ["closed", "/"], ["open", "/signup"]] as const) { | |
| 47 | setSignup(app.db, mode, "test"); | |
| 48 | expect(signUpLink(await header("/explore"))).toBe(href); | |
| 49 | } | |
| 50 | }); | |
| 51 | ||
| 52 | test("a forge with a parent offers no Sign up", async () => { | |
| 53 | setSignup(app.db, "open", "test"); | |
| 54 | const dir = await mkdtemp(join(tmpdir(), "forge-header-parent-")); | |
| 55 | const child = createApp({ dataDir: dir, port: 0, parentUrl: "https://parent.example" }); | |
| 56 | const s = await serve(child); | |
| 57 | try { | |
| 58 | const page = await (await fetch(`${s.url}/explore`)).text(); | |
| 59 | const head = page.slice(page.indexOf('<header class="topbar"'), page.indexOf("</header>")); | |
| 60 | expect(signUpLink(head)).toBeUndefined(); | |
| 61 | expect(head).toContain(">Sign in<"); | |
| 62 | } finally { | |
| 63 | await s.stop?.(); | |
| 64 | child.close(); | |
| 65 | await rm(dir, { recursive: true, force: true }); | |
| 66 | } | |
| 67 | }); | |
| 68 | ||
| 69 | test("behind a platform edge there is no Sign up, and with the edge switched off there is", async () => { | |
| 70 | const dir = await mkdtemp(join(tmpdir(), "forge-header-edge-")); | |
| 71 | const edge = createApp({ dataDir: dir, port: 0, edgeToken: "edge-secret" }); | |
| 72 | const s = await serve(edge); | |
| 73 | const headOf = async () => { | |
| 74 | const page = await (await fetch(`${s.url}/explore`)).text(); | |
| 75 | return page.slice(page.indexOf('<header class="topbar"'), page.indexOf("</header>")); | |
| 76 | }; | |
| 77 | try { | |
| 78 | setSignup(edge.db, "open", "test"); | |
| 79 | expect(signUpLink(await headOf())).toBeUndefined(); | |
| 80 | setCapacity(edge.db, "signin.edge", "off", "test"); | |
| 81 | expect(signUpLink(await headOf())).toBe("/signup"); | |
| 82 | } finally { | |
| 83 | await s.stop?.(); | |
| 84 | edge.close(); | |
| 85 | await rm(dir, { recursive: true, force: true }); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | test("/agents keeps its form and says people start on the home page", async () => { | |
| 90 | const page = await (await fetch(`${servers.url}/agents`)).text(); | |
| 91 | expect(page).toContain('id="agent-forge"'); | |
| 92 | expect(page).toMatch(/People start at <a href="\/">home<\/a>/); | |
| 93 | }); |
new above
offline
Extra DB query on every anonymous page; the pins in did-you-mean and signin-timing had to be raised to hide it. Fold this read into one already made, and revert the pins.