Public: anyone can read this; the crew can change it.
The Subscriptions page's refusal words live in one module both doors read, and the page is unchanged #5471
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5466-the-subscriptions-page-s-refusal-words-l → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +88
−17
src/actions/model_grant.ts +8 −5
| @@ -15,15 +15,18 @@ import { getUserByName, type User } from "../core/users.ts"; | ||
| 15 | 15 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 16 | 16 | import { requireUser } from "./access.ts"; |
| 17 | 17 | import type { SubscriptionError } from "../core/subscriptions.ts"; |
| 18 | import { SUBSCRIPTION_WORDS } from "../core/subscriptions_words.ts"; | |
| 18 | 19 | |
| 19 | /** What a refused add says, on every surface that adds one. */ | |
| 20 | /** | |
| 21 | * What a refused add says, on every surface that adds one. The token and | |
| 22 | * API base sentences are the Subscriptions page's own; the name sentences | |
| 23 | * differ on purpose, since a person has one subscription of their own. | |
| 24 | */ | |
| 20 | 25 | export const ADD_MESSAGES = { |
| 21 | 26 | invalid_name: "That name will not do.", |
| 22 | 27 | name_taken: "You already added your own subscription.", |
| 23 | invalid_token: | |
| 24 | "That is not a model token: one line, no spaces. One from `claude setup-token` begins sk-ant-oat; anything else is taken as an API key.", | |
| 25 | invalid_api_base: | |
| 26 | "The API base URL must be https, with no credentials, query, or hash (a Claude subscription token takes none).", | |
| 28 | invalid_token: SUBSCRIPTION_WORDS.invalid_token, | |
| 29 | invalid_api_base: SUBSCRIPTION_WORDS.invalid_api_base, | |
| 27 | 30 | not_allowed: "Only a person adds their own subscription.", |
| 28 | 31 | }; |
| 29 | 32 |
src/core/subscriptions_words.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * Subscriptions' refusal words — the sentences an admin reads when adding, | |
| 3 | * removing or tuning an account is refused. One place, so every door says | |
| 4 | * the same thing: the Subscriptions page reads them all, and a person's own | |
| 5 | * add (`actions/model_grant.ts`, on /settings/model and in the terminal) | |
No MCP tool reads this yet; drop the sentence so the comment names only real readers. |
||
| 6 | * reads the token and API base sentences. An MCP tool for these reads here. | |
| 7 | */ | |
| 8 | ||
| 9 | import { HEADROOM_MAX } from "./standing.ts"; | |
| 10 | import { NAME_MAX, type SubscriptionError } from "./subscriptions.ts"; | |
| 11 | ||
| 12 | export const SUBSCRIPTION_WORDS = { | |
| 13 | invalid_name: `A name is a word or two, up to ${NAME_MAX} characters.`, | |
| 14 | name_taken: (name: string): string => | |
| 15 | `There is already a subscription called ${name.trim()}.`, | |
| 16 | invalid_token: | |
| 17 | "That is not a model token: one line, no spaces. One from `claude setup-token` begins sk-ant-oat; anything else is taken as an API key (sk-ant-api…, sk-ant-usr…, sk-or-v1-…, or a gateway's own with its base URL).", | |
| 18 | invalid_api_base: | |
| 19 | "The API base URL must be https, with no credentials, query, or hash. For OpenRouter use https://openrouter.ai/api (a Claude subscription token cannot use a gateway).", | |
| 20 | headroom_range: `Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`, | |
| 21 | not_found: "No such subscription.", | |
| 22 | } as const; | |
| 23 | ||
| 24 | /** The sentence for an add refused with `error`; `name` is what was typed. */ | |
| 25 | export function addRefusal(error: SubscriptionError, name: string): string { | |
| 26 | return error === "name_taken" | |
| 27 | ? SUBSCRIPTION_WORDS.name_taken(name) | |
| 28 | : SUBSCRIPTION_WORDS[error]; | |
| 29 | } |
src/web/pages/subscriptions.ts +8 −12
| @@ -24,6 +24,10 @@ import { | ||
| 24 | 24 | wakeSubscription, |
| 25 | 25 | type Subscription, |
| 26 | 26 | } from "../../core/subscriptions.ts"; |
| 27 | import { | |
| 28 | addRefusal, | |
| 29 | SUBSCRIPTION_WORDS, | |
| 30 | } from "../../core/subscriptions_words.ts"; | |
| 27 | 31 | import { |
| 28 | 32 | HEADROOM_MAX, |
| 29 | 33 | headroomPct, |
| @@ -417,17 +421,9 @@ export async function subscriptionAdd(ctx: Ctx): Promise<Response> { | ||
| 417 | 421 | createdBy: ctx.auth.user!.id, |
| 418 | 422 | }); |
| 419 | 423 | if (!added.ok) { |
| 420 | const messages = { | |
| 421 | invalid_name: `A name is a word or two, up to ${NAME_MAX} characters.`, | |
| 422 | name_taken: `There is already a subscription called ${name.trim()}.`, | |
| 423 | invalid_token: | |
| 424 | "That is not a model token: one line, no spaces. One from `claude setup-token` begins sk-ant-oat; anything else is taken as an API key (sk-ant-api…, sk-ant-usr…, sk-or-v1-…, or a gateway's own with its base URL).", | |
| 425 | invalid_api_base: | |
| 426 | "The API base URL must be https, with no credentials, query, or hash. For OpenRouter use https://openrouter.ai/api (a Claude subscription token cannot use a gateway).", | |
| 427 | }; | |
| 428 | 424 | return flashTo(ctx, POOL, { |
| 429 | 425 | tone: "danger", |
| 430 | message: messages[added.error], | |
| 426 | message: addRefusal(added.error, name), | |
| 431 | 427 | }); |
| 432 | 428 | } |
| 433 | 429 | return flashTo(ctx, POOL, { |
| @@ -450,7 +446,7 @@ export async function subscriptionDelete(ctx: Ctx): Promise<Response> { | ||
| 450 | 446 | message: |
| 451 | 447 | "Removed, with its grants. Runs it paid for keep it in their history.", |
| 452 | 448 | } |
| 453 | : { tone: "danger", message: "No such subscription." }, | |
| 449 | : { tone: "danger", message: SUBSCRIPTION_WORDS.not_found }, | |
| 454 | 450 | ); |
| 455 | 451 | } |
| 456 | 452 | |
| @@ -502,7 +498,7 @@ export async function subscriptionHeadroom(ctx: Ctx): Promise<Response> { | ||
| 502 | 498 | if (pct === undefined) |
| 503 | 499 | return flashTo(ctx, POOL, { |
| 504 | 500 | tone: "danger", |
| 505 | message: `Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`, | |
| 501 | message: SUBSCRIPTION_WORDS.headroom_range, | |
| 506 | 502 | }); |
| 507 | 503 | const saved = setHeadroom(ctx.app.db, ctx.params.id ?? "", pct); |
| 508 | 504 | return flashTo( |
| @@ -512,7 +508,7 @@ export async function subscriptionHeadroom(ctx: Ctx): Promise<Response> { | ||
| 512 | 508 | ? { |
| 513 | 509 | message: `The crew now leaves ${pct}% of each window for people.`, |
| 514 | 510 | } |
| 515 | : { tone: "danger", message: "No such subscription." }, | |
| 511 | : { tone: "danger", message: SUBSCRIPTION_WORDS.not_found }, | |
| 516 | 512 | ); |
| 517 | 513 | } |
| 518 | 514 |
test/subscriptions-words.test.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { ADD_MESSAGES } from "../src/actions/model_grant.ts"; | |
| 3 | import { HEADROOM_MAX } from "../src/core/standing.ts"; | |
| 4 | import { NAME_MAX } from "../src/core/subscriptions.ts"; | |
| 5 | import { | |
| 6 | addRefusal, | |
| 7 | SUBSCRIPTION_WORDS, | |
| 8 | } from "../src/core/subscriptions_words.ts"; | |
| 9 | ||
| 10 | describe("subscriptions' refusal words", () => { | |
| 11 | test("every key is a non-empty sentence ending in a full stop", () => { | |
| 12 | for (const words of Object.values(SUBSCRIPTION_WORDS)) { | |
| 13 | const sentence = typeof words === "function" ? words("Team") : words; | |
| 14 | expect(sentence.trim().length).toBeGreaterThan(1); | |
| 15 | expect(sentence.endsWith(".")).toBe(true); | |
| 16 | } | |
| 17 | }); | |
| 18 | ||
| 19 | test("the limits are the code's own", () => { | |
| 20 | expect(SUBSCRIPTION_WORDS.invalid_name).toContain( | |
| 21 | `up to ${NAME_MAX} characters`, | |
| 22 | ); | |
| 23 | expect(SUBSCRIPTION_WORDS.headroom_range).toContain( | |
| 24 | `0 to ${HEADROOM_MAX}.`, | |
| 25 | ); | |
| 26 | }); | |
| 27 | ||
| 28 | test("an add refusal names the taken name, trimmed", () => { | |
| 29 | expect(addRefusal("name_taken", " Team ")).toBe( | |
| 30 | "There is already a subscription called Team.", | |
| 31 | ); | |
| 32 | expect(addRefusal("invalid_token", "Team")).toBe( | |
| 33 | SUBSCRIPTION_WORDS.invalid_token, | |
| 34 | ); | |
| 35 | }); | |
| 36 | ||
| 37 | test("a person's own add says the page's token and API base words", () => { | |
| 38 | expect(ADD_MESSAGES.invalid_token).toBe(SUBSCRIPTION_WORDS.invalid_token); | |
| 39 | expect(ADD_MESSAGES.invalid_api_base).toBe( | |
| 40 | SUBSCRIPTION_WORDS.invalid_api_base, | |
| 41 | ); | |
| 42 | }); | |
| 43 | }); |
new above
offline
The doc says the settings page and the MCP tools share these, but no MCP tool imports it yet; src/actions/model_grant.ts ADD_MESSAGES still has its own copies. Wire that door to this module.