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

A phone share sheet sends a link or text to the forge as a tell, with the manifest's share_target and a page that confirms before it files #12342

Merged opened this

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

Merged

scout/forge:scout/12327-a-phone-share-sheet-sends-a-link-or-text → main 15 commits fork 4 threads · 4 unresolved

  • Merged. scout/12327-a-phone-share-sheet-sends-a-link-or-text was merged into main as 414b53e.

Review

Sign in to review this change.
15 files changed +522 −10
docs/design/audit.md +1
@@ -67,6 +67,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
6767 - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen.
6868 - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here.
6969 - `/talk` — `pages/talk.ts` · the person's one conversation with the forge: history, composer, Confirm and Cancel; replies stream in over `/talk/stream` · `/settings/confirms` (the same yes, as a list), the home tell box (one line to an app's crew) · none seen; styles in `docs/design/areas/talk.md`.
70 - `/tell` — `studio/share_tell.ts` · where a phone's share sheet lands: the shared words in a form, the last repository chosen, nothing filed until Send · the phone's share sheet (manifest `share_target`) · none seen.
7071 - `/notes` — `pages/notes.ts` · what the person wrote, newest first, with the planner's verdict and live state in one line, and one tap to overrule it · the home tell box (Notes button), the user menu, the palette · none seen; styles in `docs/design/areas/notes.md`.
7172 - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen.
7273 - `/packs` — `pages/packs.ts` · the premade forges this forge ships, what each brings, how to deploy it and how a stranger helps build its apps · the user menu, the signed-out menu, the palette · none seen; uses the shared card, list and button components.
docs/features/share-tell.md added +19
@@ -0,0 +1,19 @@
1 # Tell the crew from a share, with a look before it files (#12327)
2
3 **Phone.** With the forge installed as an app, share a link or text to it. It opens
4 `/tell?title=…&text=…&url=…`: the words joined into one draft in a form, the place you
5 told last already chosen (an app, or "the forge"), and nothing filed until you tap Send.
6
7 - Signed out: you are sent to sign in and back to the same draft.
8 - A share with nothing in it, a link that is not http(s), or more than 2,000 characters is
9 refused with a sentence. Nothing is cut: a share too long is not kept, so no words vanish.
10 - Send twice (double tap)? It tells once.
11 - "Last told" is the newest of your issues in an app you can still write to and your
12 tells to the forge (kept as thoughts); the later one is chosen.
13
14 **Terminal.** `ssh` in, open the palette (`:`) and pick "Share a link or text with the crew".
15 Type or paste the words (a link last is read as the link), choose where (the last told
16 is first), and confirm with `y` or Enter on Send. `n` or Esc files nothing.
17
18 Code: `src/web/share_target.ts` (contract and refusal), `src/web/studio/share_tell.ts`
19 (page), `src/tui/screens/share_tell.ts` (terminal), `src/core/last_told.ts` (last told).
src/core/last_told.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * Where a person last told: the repository of their newest tell, or "forge"
3 * when their newest tell was a thought (a tell to no app is kept as one).
4 * A tell to a repository is an issue its teller opened; a tell to the forge is a thought, so
5 * the later of the two decides. `repoIds` are the ones the person may still write to.
6 */
7 import type { Database } from "bun:sqlite";
8
9 export const TOLD_FORGE = "forge";
10
11 export function lastTold(db: Database, userId: string, repoIds: readonly string[]): string | undefined {
12 const thought = db
13 .query<{ at: string }, [string]>(
14 "SELECT received_at AS at FROM thought WHERE author = ?1 AND source IN ('composer', 'share') ORDER BY received_at DESC LIMIT 1",
15 )
16 .get(userId)?.at;
17 const issues = db
18 .query<{ repo_id: string; at: string }, [string]>(
19 "SELECT repo_id, created_at AS at FROM issue WHERE author_id = ?1 AND kind = 'issue' ORDER BY created_at DESC LIMIT 50",
20 )
21 .all(userId);
22 const issue = issues.find((r) => repoIds.includes(r.repo_id));
23 if (thought !== undefined && (issue === undefined || thought > issue.at)) return TOLD_FORGE;
24 return issue?.repo_id;
25 }
src/http/server.ts +3
@@ -293,6 +293,7 @@ import {
293293 composerSubmit,
294294 tellSubmit,
295295 } from "../web/studio/actions.ts";
296 import { shareTellPage } from "../web/studio/share_tell.ts";
296297 import { forgeReady } from "../web/pages/forge-ready.ts";
297298 import { forgeName } from "../web/pages/forge-name.ts";
298299 import { claimFragmentPage, claimOptions, claimSubmit, welcomePage, welcomeSubmit } from "../web/pages/claim.ts";
@@ -1333,6 +1334,8 @@ export function buildRouter(): Router<Handler> {
13331334 router.add("POST", "/:owner/:name/tell", tellSubmit);
13341335 // The home's composer without a script: a tell to the app it names (#599).
13351336 router.add("POST", "/tell", composerSubmit);
1337 // Where the phone share sheet lands: the shared words in a form, filed only on Send.
1338 router.add("GET", "/tell", shareTellPage);
13361339 // A like from a card without a script (#7751): the feed's `liked`, then back to the feed.
13371340 router.add("POST", "/feed/like", likeSubmit);
13381341 router.add("POST", "/:owner/:name/pulls/:number/checks", pullChecksSubmit);
src/tui/forge.ts +2
@@ -19,6 +19,7 @@ import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
2121 import { needsCommands } from "./screens/needs.ts";
22 import { shareTellCommand } from "./screens/share_tell.ts";
2223 import { sendChangeCommand } from "./screens/send_change.ts";
2324 import { interruptedOutCommand } from "./screens/interrupted_out.ts";
2425 import { getForgeCommand } from "./screens/getforge.ts";
@@ -260,6 +261,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
260261 },
261262 ...needsCommands(session, ctx),
262263 packsCommand(session, ctx),
264 shareTellCommand(session, ctx),
263265 sendChangeCommand(ctx),
264266 interruptedOutCommand(session, ctx),
265267 getForgeCommand(session, ctx),
src/tui/screens/share_tell.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * The terminal's door to the phone share page (`src/web/studio/share_tell.ts`): a
3 * draft typed or pasted, a repository chosen (the one told last first), and
4 * a confirm; nothing is filed until the person says yes. The same refusal
5 * (`refuseShare`), draft (`sharedDraft`), last-told rule (`lastTold`) and
6 * filing (`tell`, `keepThought`) as the page.
7 */
8 import { appsFirst } from "../../actions/first_ask.ts";
9 import { keepThought } from "../../actions/thought.ts";
10 import { tell } from "../../actions/tell.ts";
11 import { lastTold, TOLD_FORGE } from "../../core/last_told.ts";
12 import { listReposWritableTo } from "../../core/repos.ts";
13 import { refuseShare, sharedDraft } from "../../web/share_target.ts";
14 import { tellAnswer } from "../../actions/tell_answer.ts";
15 import type { Ctx } from "../app/app.ts";
16 import type { User } from "../../core/users.ts";
17 import type { Session } from "../session.ts";
18 import { blank, confirmScreen, later, pickerScreen, promptScreen, type PickItem } from "./common.ts";
19 import { wrap } from "../term/width.ts";
20
21 /** The words typed as one share: text, then optionally a link last, split on blank space. */
22 export function parseShare(said: string): URLSearchParams {
23 const params = new URLSearchParams();
24 const words = said.trim().split(/\s+/).filter((w) => w !== "");
25 const last = words.at(-1);
26 if (last !== undefined && /^[a-z][a-z0-9+.-]*:/i.test(last)) {
27 params.set("url", last);
28 words.pop();
29 }
30 if (words.length > 0) params.set("text", words.join(" "));
31 return params;
32 }
33
34 export function shareTellCommand(f: Session, ctx: Ctx) {
35 return {
36 id: "share-tell",
37 title: "Share a link or text with the crew",
38 hint: "a draft, then where; nothing is sent until you confirm",
39 run: () => {
40 const user = f.user;
41 if (user === undefined) return ctx.toast("Sign in to tell the crew.", "danger");
42 const parent = blank("Tell the crew");
43 ctx.push(
44 promptScreen(ctx, parent, {
45 title: "Tell the crew",
46 label: "link or text",
47 hint: "enter continues",
48 onSubmit: (said) => {
49 const params = parseShare(said);
50 const refused = refuseShare(params);
51 if (refused !== undefined) return refused;
52 const draft = sharedDraft(params);
53 const items = shareTargets(f, user);
54 return later(ctx, () =>
55 pickerScreen(ctx, parent, {
56 title: "Tell where",
57 items,
58 onPick: ([id]) => {
59 if (id === undefined) return;
60 ctx.push(
61 confirmScreen(ctx, parent, {
62 title: "Send this tell?",
63 body: [...wrap(draft, 60), "", `to ${id === TOLD_FORGE ? "the forge" : id}`],
64 actions: [{ label: "Send" }, { label: "Cancel" }],
65 yesNo: true,
66 onChoose: (i) => {
67 if (i !== 0) return;
68 void ctx.track(fileShare(f, user, id, draft).then((a) => ctx.toast(a.text, a.tone)));
69 },
70 }),
71 );
72 },
73 }),
74 );
75 },
76 }),
77 );
78 },
79 };
80 }
81
82 /**
83 * Where a share may go: every app the person can write to, then the forge; the one told last
84 * first.
85 */
86 export function shareTargets(f: Pick<Session, "app" | "db">, user: User): PickItem[] {
87 const repos = listReposWritableTo(f.db, user, { limit: 200, withoutArchived: true });
88 const { ordered } = appsFirst(f.app, repos);
89 const last = lastTold(f.db, user.id, ordered.map((r) => r.id));
90 const items: PickItem[] = [
91 ...ordered.map((r) => ({ id: `${r.ownerName}/${r.name}`, label: `${r.ownerName}/${r.name}` })),
92 { id: TOLD_FORGE, label: "the forge" },
93 ];
94 const lastRepo = ordered.find((r) => r.id === last);
95 const lastId = last === TOLD_FORGE ? TOLD_FORGE : lastRepo && `${lastRepo.ownerName}/${lastRepo.name}`;
96 const first = items.findIndex((i) => i.id === lastId);
97 if (first > 0) items.unshift(...items.splice(first, 1));
98 return items;
99 }
100
101 /** File one confirmed share: a thought for the forge, a tell for a repository. */
102 export async function fileShare(
103 f: Pick<Session, "app" | "db">,
104 user: User,
105 target: string,
106 draft: string,
107 ): Promise<{ text: string; tone: "success" | "danger" }> {
108 if (target === TOLD_FORGE) {
109 const kept = keepThought(f.app, user, draft, "composer");
110 return kept.ok ? { tone: "success", text: "Received." } : { tone: "danger", text: "That could not be kept." };
111 }
112 const [owner = "", name = "", ...extra] = target.split("/");
113 const repo = extra.length === 0 ? listReposWritableTo(f.db, user, { limit: 200, withoutArchived: true }).find((r) => r.ownerName === owner && r.name === name) : undefined;
114 if (repo === undefined) return { tone: "danger", text: "That app is not one you can tell." };
115 return tellAnswer(await tell(f.app, user, repo, draft));
116 }
src/web/share_target.ts +26 −6
@@ -13,22 +13,42 @@ import type { URLSearchParams as Params } from "node:url";
1313 export const SHARE_PARAMS = { title: "title", text: "text", url: "url" } as const;
1414
1515 /** What the manifest declares. */
16 export const SHARE_TARGET = { action: "/", method: "GET", params: SHARE_PARAMS } as const;
17
18 /** The composer form's field names. */
19 export const COMPOSER_FIELDS = { text: "text", app: "app", back: "back", once: "once" } as const;
16 export const SHARE_TARGET = { action: "/tell", method: "GET", params: SHARE_PARAMS } as const;
2017
2118 /** The longest tell the composer takes (its textarea's `maxlength`). */
2219 export const TELL_MAX = 2000;
2320
24 /** Title, text and link joined into one draft; nothing is said twice. Never sent until Post. */
21 const NOT_WEB = "That share's link is not a web address (http or https), so it was not kept.";
22
23 /**
24 * Why a share cannot be a tell, in a sentence, or undefined when it can: nothing
25 * shared, too long, or a link that is not http(s). Whitespace-only counts as nothing.
26 */
27 export function refuseShare(params: URLSearchParams | Params): string | undefined {
28 const get = (k: string) => (params.get(k) ?? "").trim();
29 const title = get(SHARE_PARAMS.title), text = get(SHARE_PARAMS.text), url = get(SHARE_PARAMS.url);
30 if (title === "" && text === "" && url === "") return "Nothing came through with that share: no title, text or link.";
31 if (url !== "" && !URL.canParse(url)) return NOT_WEB;
32 if (url !== "") {
33 const protocol = new URL(url).protocol;
34 if (protocol !== "http:" && protocol !== "https:") return NOT_WEB;
35 }
36 if (sharedDraft(params).length > TELL_MAX)
37 return `That share is too long: a tell takes at most ${TELL_MAX.toLocaleString("en-US")} characters, and this one is longer, so none of it was kept.`;
38 return undefined;
39 }
40
41 /** The composer form's field names. */
42 export const COMPOSER_FIELDS = { text: "text", app: "app", back: "back", once: "once" } as const;
43
44 /** Title, text and link joined into one draft, whole; nothing is said twice. Never sent unasked. */
2545 export function sharedDraft(params: URLSearchParams | Params): string {
2646 const parts: string[] = [];
2747 for (const key of Object.values(SHARE_PARAMS)) {
2848 const part = (params.get(key) ?? "").trim();
2949 if (part !== "" && !parts.some((p) => p.includes(part))) parts.push(part);
3050 }
31 return parts.join("\n").slice(0, TELL_MAX);
51 return parts.join("\n");
3252 }
3353
3454 /** A token naming one shared draft for one person: sending it twice tells once. */
src/web/studio/feed.ts +1 −1
@@ -253,7 +253,7 @@ export function composer(ctx: Ctx, user: User): Html {
253253 // The forge's own copy is never the default target: a person with no other app asks for a new one,
254254 // and a person with apps sees theirs first.
255255 const { ordered, onlyForgeCopy } = appsFirst(ctx.app, repos);
256 const shared = sharedDraft(ctx.url.searchParams);
256 const shared = sharedDraft(ctx.url.searchParams).slice(0, TELL_MAX);
257257 const form = html`<form id="composer" class="stack" method="post" action="/tell">
258258 <input type="hidden" name="${COMPOSER_FIELDS.back}" value="/feed" />
259259 ${shared === "" ? "" : html`<input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, shared)}" />`}
src/web/studio/living.ts +1 −1
@@ -35,7 +35,7 @@ export function pipelineNow(db: Database, at = Date.now()): Now {
3535 export function tellBox(ctx: Ctx, user: User): Html {
3636 const { repos } = writableAppsOf(ctx, user);
3737 const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`;
38 const shared = sharedDraft(ctx.url.searchParams);
38 const shared = sharedDraft(ctx.url.searchParams).slice(0, TELL_MAX);
3939 const { ordered, onlyForgeCopy } = appsFirst(ctx.app, repos);
4040 return html`<form id="tell-box" class="bar tell-box" method="post" action="/tell">
4141 ${shared === "" ? "" : html`<input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, shared)}" />`}
src/web/studio/share_tell.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * GET /tell?title=…&text=…&url=… — where the phone's share sheet lands (the
3 * manifest's `share_target`, share_target.ts). It files nothing: it shows
4 * the shared words in a form, the repository the person told last already
5 * chosen, and Send posts `POST /tell` (same-origin checked there). A share
6 * that cannot be a tell is answered with a sentence and a way back.
7 */
8 import { appsFirst } from "../../actions/first_ask.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
11 import { signInFirst } from "../chrome.ts";
12 import { html, type Html } from "../html.ts";
13 import { page } from "../layout.ts";
14 import { btn } from "../ui.ts";
15 import { COMPOSER_FIELDS, TELL_MAX, refuseShare, sharedDraft, shareToken } from "../share_target.ts";
16 import { writableAppsOf } from "./asks.ts";
17 import { FORGE_TARGET, NEW_APP_TARGET } from "./feed.ts";
18 import { lastTold, TOLD_FORGE } from "../../core/last_told.ts";
19
20 export function shareTellPage(ctx: Ctx): Response {
21 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
22 const rendering = performance.now();
23 const done = (response: Response): Response => {
outdated

Last-used comes from issue.author_id, but a tell is a thought, not an issue. This preselects the wrong repo for anyone who tells without filing issues. Read from the tell record and move the SQL into core.

24 notePhase(ctx.request, "render", performance.now() - rendering);
25 return response;
26 };
27 if (!ctx.auth.user) return signInFirst(ctx);
28 const user = ctx.auth.user;
29 const params = ctx.url.searchParams;
30 const refusal = refuseShare(params);
31 if (refusal !== undefined) {
32 return done(page(
33 {
34 ctx,
35 title: "Share not sent",
outdated

Both doors: this page has no src/tui/ screen and no docs/features/<name>.md. Add both.

36 body: html`<h1>That share was not kept</h1><p role="alert">${refusal}</p><p>${btn("Back to the forge", { href: "/", variant: "primary" })}</p>`,
37 },
38 400,
39 ));
40 }
41 const draft = sharedDraft(params);
42 const { repos } = writableAppsOf(ctx, user);
43 const { ordered, onlyForgeCopy } = appsFirst(ctx.app, repos);
44 const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`;
45 const last = lastTold(ctx.app.db, user.id, ordered.map((r) => r.id));
46 const chosen = last === TOLD_FORGE ? TOLD_FORGE : ordered.find((r) => r.id === last);
47 const chosenSlug = typeof chosen === "object" ? slug(chosen) : chosen;
48 const target: Html =
49 repos.length === 0
50 ? html`<input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>`
51 : onlyForgeCopy
52 ? html`<input type="hidden" name="${COMPOSER_FIELDS.app}" value="${NEW_APP_TARGET}" /><span class="muted small">a new app</span>`
53 : html`<select name="${COMPOSER_FIELDS.app}" class="input" aria-label="Send to">
54 ${ordered.map((r) => html`<option value="${slug(r)}" ${slug(r) === chosenSlug ? "selected" : ""}>${slug(r)}</option>`)}
55 <option value="${FORGE_TARGET}" ${chosenSlug === TOLD_FORGE ? "selected" : ""}>the forge</option>
56 </select>`;
57 return done(page({
58 ctx,
59 title: "Tell the crew",
60 body: html`<h1>Tell the crew</h1>
61 <p class="muted small">Shared from your phone. Nothing is sent until you tap Send.</p>
62 <form class="stack" method="post" action="/tell">
63 <input type="hidden" name="${COMPOSER_FIELDS.back}" value="/feed" />
64 <input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, draft)}" />
65 <textarea class="input" name="${COMPOSER_FIELDS.text}" rows="6" maxlength="${String(TELL_MAX)}" required aria-label="Tell the crew">${draft}</textarea>
66 <div class="bar">${target}<span class="spacer"></span>${btn("Send", { variant: "primary" })}</div>
67 </form>`,
68 }));
69 }
test/home-feed.test.ts +1 −1
@@ -231,7 +231,7 @@ describe("home is the feed", () => {
231231 r.json(),
232232 )) as { share_target: { action: string; method: string; params: Record<string, string> } };
233233 expect(manifest.share_target).toEqual({
234 action: "/",
234 action: "/tell",
235235 method: "GET",
236236 params: { title: "title", text: "text", url: "url" },
237237 });
test/palette-roster.test.ts +4
@@ -52,6 +52,7 @@ test("a site-admin person is offered the whole roster", () => {
5252 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5353 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5454 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
55 ["share-tell", "Share a link or text with the crew", "a draft, then where; nothing is sent until you confirm"],
5556 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
5657 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
5758 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
@@ -217,6 +218,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
217218 ["explore-feed", "Explore feed", "Atom address"],
218219 ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"],
219220 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
221 ["share-tell", "Share a link or text with the crew", "a draft, then where; nothing is sent until you confirm"],
220222 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
221223 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
222224 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
@@ -308,6 +310,7 @@ test("a member person is offered the signed-in roster without the admin entries"
308310 ["explore-feed", "Explore feed", "Atom address"],
309311 ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"],
310312 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
313 ["share-tell", "Share a link or text with the crew", "a draft, then where; nothing is sent until you confirm"],
311314 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
312315 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
313316 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
@@ -393,6 +396,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
393396 ["explore", "Explore", "on the web"],
394397 ["explore-feed", "Explore feed", "Atom address"],
395398 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
399 ["share-tell", "Share a link or text with the crew", "a draft, then where; nothing is sent until you confirm"],
396400 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
397401 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
398402 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
test/share-target-tell.test.ts added +143
@@ -0,0 +1,143 @@
1 /** The share sheet lands on /tell: prefilled, last repository chosen, filed only on Send
2 * (#12327). */
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp as insertApp } from "../src/core/apps.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let cookie = "";
20
21 beforeAll(async () => {
22 dataDir = await mkdtemp(join(tmpdir(), "forge-share-tell-"));
23 app = createApp({ dataDir, port: 0, host: "forge.test" });
24 servers = await serve(app);
25 const made = await createUser(app.db, {
26 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
27 password: "a-good-passphrase", adminIfFirst: true,
28 });
29 if (!made.ok) throw new Error(made.error);
30 const res = await fetch(`${servers.url}/signin`, {
31 method: "POST",
32 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
33 redirect: "manual",
34 });
35 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
36 }, 120_000);
37
38 afterAll(async () => {
39 servers.stop();
40 app.close();
41 await rm(dataDir, { recursive: true, force: true });
42 });
43
44 const thoughts = () => app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM thought").get()!.n;
45
46 /** The share exactly as the manifest declares it: its action, its method, its parameter names. */
47 async function share(fields: Record<string, string>, headers: Record<string, string> = { cookie }) {
48 const m = (await (await fetch(`${servers.url}/manifest.webmanifest`)).json()) as {
49 share_target: { action: string; method: string; params: Record<string, string> };
50 };
51 const q = new URLSearchParams();
52 for (const [k, v] of Object.entries(fields)) q.set(m.share_target.params[k]!, v);
53 expect(m.share_target.method).toBe("GET");
54 return fetch(`${servers.url}${m.share_target.action}?${q}`, { headers, redirect: "manual" });
55 }
56
57 test("a shared link opens a prefilled composer and files nothing", async () => {
58 const before = thoughts();
59 const res = await share({ title: "A <b>page</b>", url: "https://example.com/a?x=1&y=2" });
60 expect(res.status).toBe(200);
61 const out = await res.text();
62 expect(/<textarea[^>]*name="text"[^>]*>([^<]*)<\/textarea>/.exec(out)?.[1]).toBe(
63 "A &lt;b&gt;page&lt;/b&gt;\nhttps://example.com/a?x=1&amp;y=2",
64 );
65 expect(out).toContain('action="/tell"');
66 expect(/name="once" value="[0-9a-f]{24}"/.test(out)).toBe(true);
67 expect(thoughts()).toBe(before);
68 });
69
70 test("the last repository told is the one chosen", async () => {
71 const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!;
72 const user = (await import("../src/core/users.ts")).getUserById(app.db, alice.id)!;
73 const ids: string[] = [];
74 for (const name of ["one", "two"]) {
75 const r = await createRepo(app, user, { name, visibility: "private" });
76 if (!r.ok) throw new Error("repo");
77 insertApp(app.db, { repoId: r.value.id, slug: name, access: "private" });
78 ids.push(r.value.id);
79 }
80 const at = ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"];
81 ids.forEach((rid, i) =>
82 app.db.run("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES (?,?,1,'t',?,?)", [`i${i}`, rid, alice.id, at[i]!]),
83 );
84 const out = await (await share({ text: "hello" })).text();
85 expect(out).toContain('<option value="alice/two" selected>');
86 expect(out).not.toContain('<option value="alice/one" selected>');
87 });
88
89 test("a share that cannot be a tell is refused with a sentence", async () => {
90 const cases: [Record<string, string>, RegExp][] = [
91 [{ title: " " }, /Nothing came through/],
92 [{ text: "x".repeat(2001) }, /too long/],
93 [{ title: "t", url: "javascript:alert(1)" }, /not a web address/],
94 [{ title: "t", url: "ftp://example.com/" }, /not a web address/],
95 [{ title: "t", url: "not a url" }, /not a web address/],
96 ];
97 for (const [fields, words] of cases) {
98 const res = await share(fields);
99 expect(res.status).toBe(400);
100 const out = await res.text();
101 expect(out).toMatch(words);
102 expect(out).not.toContain("at /");
103 }
104 });
105
106 test("a signed-out share is sent to sign in and back", async () => {
107 const res = await share({ text: "hi" }, {});
108 expect(res.status).toBe(303);
109 expect(res.headers.get("location")).toContain("/signin?next=" + encodeURIComponent("/tell?text=hi"));
110 });
111
112 test("Send files the tell; a cross-origin post files nothing", async () => {
113 const body = () => new URLSearchParams({ text: "from my phone", app: "forge" });
114 const before = thoughts();
115 const forged = await fetch(`${servers.url}/tell`, {
116 method: "POST", headers: { cookie, origin: "https://evil.example" }, body: body(), redirect: "manual",
117 });
118 expect(forged.status).toBe(403);
119 expect(thoughts()).toBe(before);
120 await fetch(`${servers.url}/tell`, { method: "POST", headers: { cookie, origin: servers.url }, body: body(), redirect: "manual" });
121 expect(thoughts()).toBe(before + 1);
122 });
123
124 test("where the last tell went decides: a tell to the forge after an issue chooses the forge", async () => {
125 const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!;
126 const { lastTold, TOLD_FORGE } = await import("../src/core/last_told.ts");
127 const repos = app.db.query<{ id: string }, []>("SELECT id FROM repo").all().map((r) => r.id);
128 app.db.run("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES ('i9', ?, 9, 't', ?, '2028-01-01T00:00:00Z')", [repos[0]!, alice.id]);
129 expect(lastTold(app.db, alice.id, repos)).toBe(repos[0]!);
130 app.db.run("INSERT INTO thought (id, author, text, source, received_at) VALUES ('t1', ?, 'x', 'composer', '2029-01-01T00:00:00Z')", [alice.id]);
131 expect(lastTold(app.db, alice.id, repos)).toBe(TOLD_FORGE);
132 const out = await (await share({ text: "hello" })).text();
133 expect(out).toContain('<option value="forge" selected>');
134 expect(out).not.toContain('<option value="alice/two" selected>');
135 });
136
137 test("a share the composer cannot hold whole is refused, not cut", async () => {
138 const res = await share({ text: "y".repeat(2001) });
139 expect(res.status).toBe(400);
140 expect(await res.text()).toContain("none of it was kept");
141 const ok = await (await share({ text: "y".repeat(2000) })).text();
142 expect(ok).toContain("y".repeat(2000));
143 });
test/share-target.test.ts +1 −1
@@ -44,7 +44,7 @@ const thoughts = () => app.db.query<{ n: number }, []>("SELECT count(*) AS n FRO
4444
4545 test("the manifest declares the same names the composer uses", async () => {
4646 const m = (await (await fetch(`${servers.url}/manifest.webmanifest`)).json()) as { share_target: { action: string; method: string; params: Record<string, string> } };
47 expect(m.share_target).toEqual({ action: "/", method: "GET", params: { ...SHARE_PARAMS } });
47 expect(m.share_target).toEqual({ action: "/tell", method: "GET", params: { ...SHARE_PARAMS } });
4848 });
4949
5050 test("a share opens the composer with title, text and url joined, escaped", async () => {
test/share-tell-tui.test.ts added +110
@@ -0,0 +1,110 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { setRole } from "../src/actions/roles.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { fileShare, parseShare, shareTargets } from "../src/tui/screens/share_tell.ts";
11 import { refuseShare } from "../src/web/share_target.ts";
12 import { quietLogs } from "./helpers/globals.ts";
outdated

Add tests for fileShare in src/tui/screens/share_tell.ts: filing to the forge keeps one thought, filing to a writable owner/name files a tell, and filing to an unwritable or malformed target files nothing and returns the danger answer. The terminal write path is currently unproven.

13
14 quietLogs();
15
16 test("a typed share: a trailing link is the link, the rest the text", () => {
17 const p = parseShare("look at this https://example.com/a");
18 expect(p.get("url")).toBe("https://example.com/a");
19 expect(p.get("text")).toBe("look at this");
20 expect(refuseShare(parseShare(" "))).toMatch(/Nothing came through/);
21 expect(refuseShare(parseShare("go javascript:alert(1)"))).toMatch(/not a web address/);
22 expect(refuseShare(parseShare("x ".repeat(1500)))).toMatch(/too long/);
23 });
24
25 let dataDir: string;
26 let app: App;
27 let alice: User;
28 let otherId: string;
29
30 async function person(username: string, adminIfFirst = false): Promise<User> {
31 const made = await createUser(app.db, {
32 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
33 password: "a-good-passphrase", adminIfFirst,
34 });
35 if (!made.ok) throw new Error(made.error);
36 return made.value;
37 }
38
39 async function repoOf(owner: User, name: string): Promise<string> {
40 const r = await createRepo(app, owner, { name, visibility: "private" });
41 if (!r.ok) throw new Error(`repo ${owner.username}/${name}: ${JSON.stringify(r.error)}`);
42 insertApp(app.db, { repoId: r.value.id, slug: name, access: "private" });
43 return r.value.id;
44 }
45
46 beforeAll(async () => {
47 dataDir = await mkdtemp(join(tmpdir(), "forge-share-tell-tui-"));
48 app = createApp({ dataDir, port: 0, host: "forge.test" });
49 // bob is the first person, so the admin; alice is a member who cannot write to bob's app.
50 const bob = await person("bob", true);
51 alice = await person("alice");
52 setRole(app, bob, "alice", "member");
53 await repoOf(alice, "mine");
54 otherId = await repoOf(alice, "other");
55 await repoOf(bob, "theirs");
56 }, 120_000);
57
58 afterAll(async () => {
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 const f = () => ({ app, db: app.db });
64 const thoughts = () => app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM thought WHERE author = ?").get(alice.id)!.n;
65 const issues = () => app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM issue WHERE author_id = ?").get(alice.id)!.n;
66
67 test("a share to the forge keeps one thought and opens no issue", async () => {
68 const [t, i] = [thoughts(), issues()];
69 const said = await fileShare(f(), alice, "forge", "a thought from the terminal");
70 expect(said.tone).toBe("success");
71 expect(thoughts()).toBe(t + 1);
72 expect(issues()).toBe(i);
73 });
74
75 test("a share to an app the person can write to files one tell there", async () => {
76 const [t, i] = [thoughts(), issues()];
77 const said = await fileShare(f(), alice, "alice/other", "a tell from the terminal");
78 expect(said.text).not.toBe("That app is not one you can tell.");
79 const row = app.db
80 .query<{ repo_id: string; body: string }, [string]>(
81 "SELECT repo_id, body FROM issue WHERE author_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
82 )
83 .get(alice.id);
84 expect(issues()).toBe(i + 1);
85 expect(row?.repo_id).toBe(otherId);
86 expect(thoughts()).toBe(t);
87 });
88
89 test("a share to an app the person cannot write to, or a malformed one, files nothing", async () => {
90 for (const target of ["bob/theirs", "alice/nope", "alice/mine/extra", "", "/", "alice"]) {
91 const [t, i] = [thoughts(), issues()];
92 const said = await fileShare(f(), alice, target, "should not land");
93 expect(said).toEqual({ tone: "danger", text: "That app is not one you can tell." });
94 expect(thoughts()).toBe(t);
95 expect(issues()).toBe(i);
96 }
97 });
98
99 test("the picker offers only writable apps and the forge, the one told last first", async () => {
100 await fileShare(f(), alice, "alice/other", "told other last");
101 let ids = shareTargets(f(), alice).map((x) => x.id);
102 expect(ids[0]).toBe("alice/other");
103 expect(ids).toContain("alice/mine");
104 expect(ids).toContain("forge");
105 expect(ids).not.toContain("bob/theirs");
106 // A later thought makes the forge the last told.
107 app.db.run("INSERT INTO thought (id, author, text, source, received_at) VALUES ('later', ?, 'x', 'composer', '2999-01-01T00:00:00Z')", [alice.id]);
108 ids = shareTargets(f(), alice).map((x) => x.id);
109 expect(ids[0]).toBe("forge");
110 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs