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

A phone share sheet reaches the crew in one tap: the share target is tested from a shared URL to a queued thought, and a signed-out sharer is brought back to it #10417

Merged opened this

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

Merged

scout/forge:scout/10390-a-phone-share-sheet-reaches-the-crew-in → main 5 commits fork 5 threads · 5 unresolved

  • Merged. scout/10390-a-phone-share-sheet-reaches-the-crew-in was merged into main as 3d3f4d1.

Review

Sign in to review this change.
8 files changed +290 −39
docs/features/share-to-the-forge.md added +15
@@ -0,0 +1,15 @@
1 # Share to the forge from a phone
2
3 Install the forge as an app (browser menu → Add to Home screen). It then
4 appears in the phone's share sheet. Share a page or some text to it and the
5 forge opens with the title, text and link joined into one draft in the tell
6 box — nothing is sent yet. Press Tell (or Enter) and the draft is queued as a
7 thought, or as a tell to the app you pick.
8
9 - Signed out? You are taken to sign in and brought back to the same draft.
10 - Sent twice (a double tap, a reload and resubmit)? It is told once; the second
11 answers "Already sent." A post that fails (empty, too long) can be retried.
12 - The same draft works on `/` (tell box) and `/feed` (composer).
13
14 The field names (`title`, `text`, `url`; `text`, `app`, `back`, `once`) live in
15 `src/web/share_target.ts`; the manifest and both composers import them.
scripts/unreached-allow.json +1
@@ -15,6 +15,7 @@
1515 "src/flow/review_rounds.ts",
1616 "src/flow/shared_red.ts",
1717 "src/flow/stale_fold.ts",
18 "src/kernel/why_now.ts",
1819 "src/spawn/pod.ts",
1920 "src/tui/gallery.ts",
2021 "src/tui/ui/qr.ts",
src/web/share_target.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The phone share sheet's contract, in one place: the web manifest's
3 * `share_target` (standards.ts) and the composer that receives it
4 * (studio/feed.ts, studio/actions.ts) both import these names, so they
5 * cannot drift apart.
6 *
7 * A share arrives as `GET /?title=…&text=…&url=…`; the composer opens with
8 * those words as a draft; Post is `POST /tell` with the fields below.
9 */
10 import type { URLSearchParams as Params } from "node:url";
11
12 /** The query parameters a share carries, in the order they are joined. */
13 export const SHARE_PARAMS = { title: "title", text: "text", url: "url" } as const;
14
15 /** 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;

back is declared here, but feed.ts still writes name="back" literally and actions reads it by string, so the field names are not defined in one place (box 4). Use COMPOSER_FIELDS.back in both.

20
21 /** The longest tell the composer takes (its textarea's `maxlength`). */
22 export const TELL_MAX = 2000;
23
24 /** Title, text and link joined into one draft; nothing is said twice. Never sent until Post. */
25 export function sharedDraft(params: URLSearchParams | Params): string {
26 const parts: string[] = [];
27 for (const key of Object.values(SHARE_PARAMS)) {
28 const part = (params.get(key) ?? "").trim();
29 if (part !== "" && !parts.some((p) => p.includes(part))) parts.push(part);
30 }
31 return parts.join("\n").slice(0, TELL_MAX);
32 }
33
34 /** A token naming one shared draft for one person: sending it twice tells once. */
35 export function shareToken(userId: string, draft: string): string {
36 return new Bun.CryptoHasher("sha256").update(`${userId}\n${draft}`).digest("hex").slice(0, 24);
37 }
38
39 const SENT = new Map<string, number>();
40 const SENT_FOR_MS = 10 * 60 * 1000;
41 const SENT_KEEP = 2000;
42 const TOKEN = /^[0-9a-f]{24}$/;
43
44 /**
45 * "fresh" the first time a person claims a token within ten minutes (it is now
outdated

A malformed or absent token returns true, so removing the field bypasses the guard. Treat the share as unguarded only when no token was sent at all, and reject a malformed one.

46 * held), "seen" after, "bad" when the token is not one we issued. A share sent
47 * with no token at all is not asked about here: the caller does not call.
48 */
49 export function claimShare(userId: string, token: string, now = Date.now()): "fresh" | "seen" | "bad" {
outdated

Once the map is full, this deletes every entry (the second condition is always true when full), forgetting all recent shares. Evict the oldest until under the limit.

50 if (!TOKEN.test(token)) return "bad";
51 const key = `${userId}:${token}`;
52 const at = SENT.get(key);
53 if (at !== undefined && now - at < SENT_FOR_MS) return "seen";
54 SENT.delete(key);
55 // Oldest first: a Map iterates in insertion order.
56 for (const [k] of SENT) {
57 if (SENT.size < SENT_KEEP) break;
58 SENT.delete(k);
59 }
60 SENT.set(key, now);
61 return "fresh";
62 }
63
64 /** Give a token back: the tell it guarded was not made, so the retry must go through. */
65 export function releaseShare(userId: string, token: string): void {
66 SENT.delete(`${userId}:${token}`);
67 }
68
69 /** Forget what was sent (tests). */
70 export function forgetShares(): void {
71 SENT.clear();
72 }
src/web/standards.ts +3 −6
@@ -37,6 +37,7 @@ import { canRead, getRepo, type Repo } from "../core/repos.ts";
3737 import { publicForgeRepo } from "../core/forge_repo.ts";
3838 import type { Ctx } from "../http/context.ts";
3939 import { notFound } from "../http/respond.ts";
40 import { SHARE_TARGET } from "./share_target.ts";
4041 import { SITE_LINE } from "./site_line.ts";
4142 import { mcpToolsSection } from "./llms_tools.ts";
4243 import { HARNESS_PLUGINS, mcpCard, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
@@ -254,12 +255,8 @@ export function webManifest(ctx: Ctx): Response {
254255 id: "/",
255256 display: "standalone",
256257 // Sharing a link or text to the installed forge opens the home's
257 // composer with it filled in (`sharedDraft`, studio/feed.ts).
258 share_target: {
259 action: "/",
260 method: "GET",
261 params: { title: "title", text: "text", url: "url" },
262 },
258 // composer with it filled in (`sharedDraft`, share_target.ts).
259 share_target: SHARE_TARGET,
263260 // A long-press on the installed icon: what is on the owner, or a
264261 // word with the forge's agents, one tap from the home screen.
265262 shortcuts: [
src/web/studio/actions.ts +34 −7
@@ -8,6 +8,7 @@
88 * page that sent them, or JSON when the asks panel asked in place.
99 */
1010
11 import { COMPOSER_FIELDS, claimShare, releaseShare } from "../share_target.ts";
1112 import { mayView } from "../../core/sealed.ts";
1213 import { holdPull, releasePull } from "../../actions/holds.ts";
1314 import { addComment } from "../../actions/issues.ts";
@@ -29,6 +30,7 @@ import { teamHoldRefusal } from "../../core/teams_words.ts";
2930 import { guardedRefusal } from "../../crew/stranger.ts";
3031 import type { Guarded } from "../../core/pulls.ts";
3132 import { backTo, field, readForm, type Form } from "../form.ts";
33 import { isLocalPath } from "../flash.ts";
3234 import { html } from "../html.ts";
3335 import { flashOrJson, flashTo, page } from "../layout.ts";
3436 import { FORGE_TARGET } from "./feed.ts";
@@ -58,8 +60,9 @@ async function tellFrom(
5860 repo: Repo,
5961 form: Form,
6062 back: string,
63 onTold: () => void = () => {},
6164 ): Promise<Response> {
62 const text = field(form, "text").trim();
65 const text = field(form, COMPOSER_FIELDS.text).trim();
6366 // The page the tell was said on; a path that is not ours is no `from`,
6467 // and so is never somewhere to send the person back to.
6568 const from = parseFrom(field(form, "from") || undefined) ?? null;
@@ -85,6 +88,7 @@ async function tellFrom(
8588 ? "Verify your email first."
8689 : NOT_TOLD_WORDS,
8790 });
91 onTold();
8892 const { routed } = told.value;
8993 // A client's "Ask for something" (named by the form, not guessed from `/`):
9094 // they asked for something and want nothing more than to know it went.
@@ -177,14 +181,38 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
177181 if (!ctx.auth.user) return signInFirst(ctx);
178182 const form = await readForm(ctx, "rw");
179183 if (form instanceof Response) return form;
180 const target = field(form, "app");
184 const target = field(form, COMPOSER_FIELDS.app);
outdated

The token is claimed before the text and app are validated and before the tell exists. If the post then fails, the retry is told 'Already sent.' and nothing is queued. Claim after the tell is created, and release on failure.

181185 // The feed's composer asks to come back to the feed; any other box, to home.
182 const home = backTo(form, "/");
186 const asked = field(form, COMPOSER_FIELDS.back);
187 const home = isLocalPath(asked) ? asked : "/";
188 // A shared draft carries a token: the same share sent twice tells once. No
189 // token is an ordinary tell; a token we did not issue is refused. The token is
190 // held while the tell is made (so two at once cannot both go through) and
191 // given back unless the tell was made (so a failed post can be retried).
192 const once = field(form, COMPOSER_FIELDS.once);
193 const userId = ctx.auth.user.id;
194 if (once !== "") {
195 const claim = claimShare(userId, once);
196 if (claim === "bad") return flashOrJson(ctx, home, { tone: "danger", message: "That share could not be sent." }, { href: home });
197 if (claim === "seen") return flashOrJson(ctx, home, { message: "Already sent." }, { href: home });
198 }
199 let told = false;
200 try {
201 const res = await composerTell(ctx, form, target, home, () => (told = true));
202 return res;
203 } finally {
204 if (once !== "" && !told) releaseShare(userId, once);
205 }
206 }
207
208 async function composerTell(ctx: Ctx, form: Form, target: string, home: string, onTold: () => void): Promise<Response> {
209 if (!ctx.auth.user) return signInFirst(ctx);
183210 // "the forge" is no app: the words are kept as a thought, exactly as typed.
184211 if (target === FORGE_TARGET) {
185 if (field(form, "text").trim() === "")
212 if (field(form, COMPOSER_FIELDS.text).trim() === "")
186213 return flashOrJson(ctx, home, { tone: "danger", message: SAY_SOMETHING_WORDS });
187 const kept = keepThought(ctx.app, ctx.auth.user, field(form, "text"), "composer");
214 const kept = keepThought(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), "composer");
215 if (kept.ok) onTold();
188216 return flashOrJson(
189217 ctx,
190218 home,
@@ -201,10 +229,9 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
201229 const [owner = "", name = "", ...extra] = target.split("/");
202230 const repo = extra.length === 0 ? getRepo(ctx.app.db, owner, name) : undefined;
203231 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx);
204 return tellFrom(ctx, repo, form, home);
232 return tellFrom(ctx, repo, form, home, onTold);
205233 }
206234
207
208235 /** POST /:owner/:name/pulls/:number/ship — land a green change. */
209236 export async function shipSubmit(ctx: Ctx): Promise<Response> {
210237 const repo = loadRepo(ctx);
src/web/studio/feed.ts +7 −22
@@ -8,6 +8,7 @@
88 * Every string on a card is plain text and is escaped here, once.
99 */
1010
11 import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts";
1112 import { chipCounts } from "../../feed/chips.ts";
1213 import { chipsOf, KIND_CHIPS, type KindChip } from "../../core/feed_chips.ts";
1314 import { marksByName, type Mark } from "../../core/marks.ts";
@@ -223,24 +224,6 @@ export function replyUnder(placement: Placement): Html {
223224 return html`<p class="small break" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`;
224225 }
225226
outdated

export { sharedDraft } is a re-export shim kept only for old importers. Point them at share_target.ts and delete this.

226 /** The longest tell the composer takes (its textarea's `maxlength`). */
227 const TELL_MAX = 2000;
228
229 /**
230 * What a phone's share sheet handed the composer (`share_target` in the web
231 * manifest, standards.ts): the title, text and link it sent as query
232 * parameters, as one draft. A page's title often repeats in the text, and
233 * some apps put the link in the text as well, so nothing is said twice. It
234 * is only ever a draft — nothing is sent until the person taps Post.
235 */
236 export function sharedDraft(params: URLSearchParams): string {
237 const parts: string[] = [];
238 for (const key of ["title", "text", "url"]) {
239 const part = (params.get(key) ?? "").trim();
240 if (part !== "" && !parts.some((p) => p.includes(part))) parts.push(part);
241 }
242 return parts.join("\n").slice(0, TELL_MAX);
243 }
244227
245228 /**
246229 * The composer: say something to an app's crew, which opens a tell — the
@@ -259,13 +242,15 @@ export function composer(ctx: Ctx, user: User): Html {
259242 action: html`${btn("New app", { href: "/apps/new", variant: "primary" })}
260243 ${btn("Repositories", { href: `/${user.username}` })}`,
261244 })}`;
245 const shared = sharedDraft(ctx.url.searchParams);
262246 const form = html`<form id="composer" class="stack" method="post" action="/tell">
263 <input type="hidden" name="back" value="/feed" />
264 <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea>
247 <input type="hidden" name="${COMPOSER_FIELDS.back}" value="/feed" />
248 ${shared === "" ? "" : html`<input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, shared)}" />`}
249 <textarea class="input" name="${COMPOSER_FIELDS.text}" rows="2" maxlength="${String(TELL_MAX)}" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${shared}</textarea>
265250 <div class="bar">
266251 ${repos.length === 0
267 ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>`
268 : html`<select name="app" class="input" aria-label="Send to">
252 ? html`<input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>`
253 : html`<select name="${COMPOSER_FIELDS.app}" class="input" aria-label="Send to">
269254 ${repos.map((r) => html`<option value="${slug(r)}">${slug(r)}</option>`)}
270255 <option value="${FORGE_TARGET}">the forge</option>
271256 </select>`}
src/web/studio/living.ts +7 −4
@@ -19,7 +19,8 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t
1919 import type { NowStage } from "../../flow/now_rows.ts";
2020 import { nowHref } from "../pages/now.ts";
2121 import { writableAppsOf } from "./asks.ts";
22 import { FORGE_TARGET, sharedDraft } from "./feed.ts";
22 import { FORGE_TARGET } from "./feed.ts";
23 import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts";
2324 import { repoBase } from "../chrome.ts";
2425
2526 /** The events that move a build or a shipment. */
@@ -106,11 +107,13 @@ export function liveMovingFragment(ctx: Ctx): Response {
106107 export function tellBox(ctx: Ctx, user: User): Html {
107108 const { repos } = writableAppsOf(ctx, user);
108109 const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`;
110 const shared = sharedDraft(ctx.url.searchParams);
109111 return html`<form id="tell-box" class="bar tell-box" method="post" action="/tell">
110 <input class="input" name="text" maxlength="2000" required placeholder="Tell the crew…" aria-label="Tell the crew" data-draft="${user.id}" value="${sharedDraft(ctx.url.searchParams).replace(/\s+/g, " ")}" />
112 ${shared === "" ? "" : html`<input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, shared)}" />`}
113 <input class="input" name="${COMPOSER_FIELDS.text}" maxlength="${String(TELL_MAX)}" required placeholder="Tell the crew…" aria-label="Tell the crew" data-draft="${user.id}" value="${shared.replace(/\s+/g, " ")}" />
111114 ${repos.length === 0
112 ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" />`
113 : html`<select name="app" class="input" aria-label="Send to">
115 ? html`<input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" />`
116 : html`<select name="${COMPOSER_FIELDS.app}" class="input" aria-label="Send to">
114117 ${repos.map((r) => html`<option value="${slug(r)}">${slug(r)}</option>`)}
115118 <option value="${FORGE_TARGET}">the forge</option>
116119 </select>`}
test/share-target.test.ts added +151
@@ -0,0 +1,151 @@
1 /** A phone share sheet reaches the crew: manifest → composer → one tell (#10390). */
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 { createUser } from "../src/core/users.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { COMPOSER_FIELDS, SHARE_PARAMS, claimShare, forgetShares, releaseShare } from "../src/web/share_target.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 let cookie = "";
18
19 beforeAll(async () => {
20 dataDir = await mkdtemp(join(tmpdir(), "forge-share-target-"));
21 app = createApp({ dataDir, port: 0, host: "forge.test" });
22 servers = await serve(app);
23 const made = await createUser(app.db, {
24 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
25 password: "a-good-passphrase", adminIfFirst: true,
26 });
27 if (!made.ok) throw new Error(made.error);
28 const res = await fetch(`${servers.url}/signin`, {
29 method: "POST",
30 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
31 redirect: "manual",
32 });
33 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
34 }, 120_000);
35
36 afterAll(async () => {
37 servers.stop();
38 app.close();
39 await rm(dataDir, { recursive: true, force: true });
40 });
41
42 const shareQuery = (o: Record<string, string>) => new URLSearchParams(o).toString();
43 const thoughts = () => app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM thought").get()!.n;
44
45 test("the manifest declares the same names the composer uses", async () => {
46 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 } });
48 });
49
50 test("a share opens the composer with title, text and url joined, escaped", async () => {
51 const q = shareQuery({ title: "Nice <b>page</b>", text: "look at this", url: "https://example.com/a?b=1&c=<x>" });
52 const out = await (await fetch(`${servers.url}/?${q}`, { headers: { cookie } })).text();
53 const area = /<input[^>]*name="text"[^>]*value="([^"]*)"/.exec(out)?.[1] ?? "";
54 expect(area).toBe("Nice &lt;b&gt;page&lt;/b&gt; look at this https://example.com/a?b=1&amp;c=&lt;x&gt;");
55 expect(out).not.toContain("<b>page</b>");
56 });
57
58 test("an enormous share is cut to what the composer takes", async () => {
59 const out = await (await fetch(`${servers.url}/?${shareQuery({ text: "x".repeat(6_000) })}`, { headers: { cookie } })).text();
60 const area = /<input[^>]*name="text"[^>]*value="([^"]*)"/.exec(out)?.[1] ?? "";
61 expect(area.length).toBe(2000);
62 });
63
64 test("a signed-out sharer is sent to sign in with the way back, same-origin only", async () => {
65 const q = shareQuery({ text: "hello", url: "https://example.com/" });
66 const out = await (await fetch(`${servers.url}/?${q}`)).text();
67 expect(out).toContain(`/signin?next=${encodeURIComponent("/?" + q)}`);
68 // The sign-in page only ever returns to a path on this origin.
69 for (const evil of ["//evil.example/", "https://evil.example/", "/\\evil.example"]) {
70 const res = await fetch(`${servers.url}/signin?next=${encodeURIComponent(evil)}`, {
71 method: "POST",
72 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
73 redirect: "manual",
74 });
75 expect(res.headers.get("location") ?? "/").not.toContain("evil.example");
76 }
77 const ok = await fetch(`${servers.url}/signin?next=${encodeURIComponent("/?" + q)}`, {
78 method: "POST",
79 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
80 redirect: "manual",
81 });
82 expect(ok.headers.get("location")).toBe("/?" + q);
83 });
84
85 test("the shared composer, posted twice, queues one thought", async () => {
86 const q = shareQuery({ text: "make the header blue", url: "https://example.com/h" });
87 const page = await (await fetch(`${servers.url}/?${q}`, { headers: { cookie } })).text();
88 const once = /name="once" value="([0-9a-f]+)"/.exec(page)?.[1];
89 expect(once).toBeDefined();
90 const before = thoughts();
91 const post = () =>
92 fetch(`${servers.url}/tell`, {
93 method: "POST",
94 headers: { cookie, origin: servers.url },
95 body: new URLSearchParams({
96 [COMPOSER_FIELDS.text]: "make the header blue\nhttps://example.com/h",
97 [COMPOSER_FIELDS.app]: "forge",
98 [COMPOSER_FIELDS.once]: once!,
99 }),
100 redirect: "manual",
101 });
102 await post();
103 await post();
104 expect(thoughts()).toBe(before + 1);
105 });
106
107 const postTell = (fields: Record<string, string>) =>
108 fetch(`${servers.url}/tell`, { method: "POST", headers: { cookie, origin: servers.url }, body: new URLSearchParams(fields), redirect: "manual" });
109
110 test("the feed composer carries the draft in its textarea, escaped, with a once token", async () => {
111 const q = shareQuery({ text: "fix <script>x</script>", url: "https://example.com/f" });
112 const out = await (await fetch(`${servers.url}/feed?${q}`, { headers: { cookie } })).text();
113 const area = /<textarea[^>]*name="text"[^>]*>([^<]*)<\/textarea>/.exec(out)?.[1] ?? "";
114 expect(area).toBe("fix &lt;script&gt;x&lt;/script&gt;\nhttps://example.com/f");
115 expect(out).not.toContain("<script>x</script>");
116 expect(/name="once" value="[0-9a-f]{24}"/.test(out)).toBe(true);
117 expect(out).toContain(`name="${COMPOSER_FIELDS.back}" value="/feed"`);
118 });
119
120 test("a failed post does not burn the token: the retry is queued", async () => {
121 const once = "a".repeat(24);
122 const before = thoughts();
123 await postTell({ [COMPOSER_FIELDS.text]: " ", [COMPOSER_FIELDS.app]: "forge", [COMPOSER_FIELDS.once]: once });
124 expect(thoughts()).toBe(before);
125 await postTell({ [COMPOSER_FIELDS.text]: "second try", [COMPOSER_FIELDS.app]: "forge", [COMPOSER_FIELDS.once]: once });
126 expect(thoughts()).toBe(before + 1);
127 await postTell({ [COMPOSER_FIELDS.text]: "second try", [COMPOSER_FIELDS.app]: "forge", [COMPOSER_FIELDS.once]: once });
128 expect(thoughts()).toBe(before + 1);
129 });
130
131 test("a malformed token is refused; no token is an ordinary tell", async () => {
132 const before = thoughts();
133 await postTell({ [COMPOSER_FIELDS.text]: "bad token", [COMPOSER_FIELDS.app]: "forge", [COMPOSER_FIELDS.once]: "nope" });
134 expect(thoughts()).toBe(before);
135 await postTell({ [COMPOSER_FIELDS.text]: "no token", [COMPOSER_FIELDS.app]: "forge" });
136 await postTell({ [COMPOSER_FIELDS.text]: "no token", [COMPOSER_FIELDS.app]: "forge" });
137 expect(thoughts()).toBe(before + 2);
138 });
139
140 test("claimShare evicts only the oldest when full, and release gives a token back", () => {
141 forgetShares();
142 const hex = (n: number) => n.toString(16).padStart(24, "0");
143 for (let i = 0; i < 2000; i++) expect(claimShare("u", hex(i), 1000 + i)).toBe("fresh");
144 expect(claimShare("u", hex(5000), 4000)).toBe("fresh");
145 expect(claimShare("u", hex(1999), 4001)).toBe("seen"); // recent ones survive
146 expect(claimShare("u", hex(0), 4002)).toBe("fresh"); // the oldest went
147 expect(claimShare("u", "zz", 4003)).toBe("bad");
148 releaseShare("u", hex(1999));
149 expect(claimShare("u", hex(1999), 4004)).toBe("fresh");
150 forgetShares();
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs