From a1ea50f816f6a6d1c69ae3f34c802fce515d1101 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:15:17 +0000 Subject: [PATCH] Taskmaster reroll: haggle to a real Whop checkout, then coach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `taskmaster/`: the factory's Whop coaching app rebuilt so the money is real. The old version switched to coaching when the member typed "Paid $X via ..." and its webhook handler only logged invoices. The character haggles; the deal desk decides. The character never sets a price: it calls one tool, quote(tier, price, reason), and dealdesk.js answers accept, counter or refuse. Four rules — the opening is the list price and the desk never quotes above it or walks a price back up; a quote under the floor is countered at the floor whatever reason came with it; a step down needs one of four reasons the member gave (scope, urgency, commitment, pay_now), each worth a slice of the anchor and each spendable once; three rounds, the opening included. Two refusals are about honesty and spend no round: a reason that reaches for the member's profile, following, employer, means or country, and invented scarcity or a fake deadline. The app decides the mode, never the model. One sheet, two prompts, and the server picks from whether Whop says the member has paid — a valid membership on prod_EZ7UkE4pkW04U or a place on a valid pack's seat list. Typing "Paid" changes nothing and a test proves it. Also: the sealed quote card the model can't edit, the share card, the deal log (openings, counters, final price, rounds, reasons, outcome, fee and net; never a word the member wrote, validated so prose can't get in), seat packs in membership metadata with rate-limited swaps and a buyer view of seats and usage but no chats, the signed payment webhook, the three experiments bucketed by user, and the Venice voice with one tool, a per-member limit and no uncensored default. Nothing created on Whop, as the card requires: the Whop client refuses every write unless constructed with live: true, and the 75 tests run against an injected fetch with simulated signed webhooks. What is missing, and why: - teahub.xyz is blocked by this container's network policy, so teahubxyz/dealdesk and its SKILL.md could not be read. The rules are implemented in taskmaster/src/dealdesk.js behind the same seam (openDeal / quote / agree / walkaway), so swapping the import in is a one-file change. Someone with network access to Teahub should reconcile the two. - docs.whop.com is blocked too, so four things stay unconfirmed and each is one line to correct: daily billing for the daily and checkins tiers (checkout() refuses to create a renewal tier until it is passed dailyBilling: "confirmed", so nothing is built on the assumption), the field that keeps an inline plan off the product's public page (HIDDEN in whop.js), the webhook's header names and whether the signed string is the body alone or a timestamp joined to it (both shapes verify; the names are constants), and how Whop's iOS rules treat a one-off checkout, which would change what these tiers may be priced at on a phone. - Where the code lives was the operator's call. It is a top-level taskmaster/ here, beside havre/, since GitHub access for this session is scoped to teahubxyz/time alone. Moving it to teahubxyz/taskmaster or a shared Whop-apps repo is a directory move; nothing outside taskmaster/ depends on it but the one test hook. - No workflow change is needed: CI's `python3 .github/time/test_time.py` step now runs the suite, as it already does for havre/. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014KUi5SZq4GBkdHQ2TdhZ3r Closes #66 --- .github/time/test_time.py | 10 ++ .gitignore | 1 + CHANGELOG.md | 1 + taskmaster/README.md | 121 ++++++++++++++++ taskmaster/package.json | 10 ++ taskmaster/src/cards.js | 65 +++++++++ taskmaster/src/dealdesk.js | 230 +++++++++++++++++++++++++++++++ taskmaster/src/deallog.js | 112 +++++++++++++++ taskmaster/src/experiments.js | 55 ++++++++ taskmaster/src/index.js | 42 ++++++ taskmaster/src/mode.js | 88 ++++++++++++ taskmaster/src/seats.js | 120 ++++++++++++++++ taskmaster/src/server.js | 217 +++++++++++++++++++++++++++++ taskmaster/src/sign.js | 29 ++++ taskmaster/src/tiers.js | 68 +++++++++ taskmaster/src/voice.js | 110 +++++++++++++++ taskmaster/src/webhook.js | 80 +++++++++++ taskmaster/src/whop.js | 117 ++++++++++++++++ taskmaster/test/cards.test.js | 74 ++++++++++ taskmaster/test/dealdesk.test.js | 218 +++++++++++++++++++++++++++++ taskmaster/test/deallog.test.js | 86 ++++++++++++ taskmaster/test/flow.test.js | 221 +++++++++++++++++++++++++++++ taskmaster/test/mode.test.js | 73 ++++++++++ taskmaster/test/seats.test.js | 73 ++++++++++ taskmaster/test/voice.test.js | 105 ++++++++++++++ taskmaster/test/webhook.test.js | 137 ++++++++++++++++++ taskmaster/test/whop.test.js | 109 +++++++++++++++ 27 files changed, 2572 insertions(+) create mode 100644 taskmaster/README.md create mode 100644 taskmaster/package.json create mode 100644 taskmaster/src/cards.js create mode 100644 taskmaster/src/dealdesk.js create mode 100644 taskmaster/src/deallog.js create mode 100644 taskmaster/src/experiments.js create mode 100644 taskmaster/src/index.js create mode 100644 taskmaster/src/mode.js create mode 100644 taskmaster/src/seats.js create mode 100644 taskmaster/src/server.js create mode 100644 taskmaster/src/sign.js create mode 100644 taskmaster/src/tiers.js create mode 100644 taskmaster/src/voice.js create mode 100644 taskmaster/src/webhook.js create mode 100644 taskmaster/src/whop.js create mode 100644 taskmaster/test/cards.test.js create mode 100644 taskmaster/test/dealdesk.test.js create mode 100644 taskmaster/test/deallog.test.js create mode 100644 taskmaster/test/flow.test.js create mode 100644 taskmaster/test/mode.test.js create mode 100644 taskmaster/test/seats.test.js create mode 100644 taskmaster/test/voice.test.js create mode 100644 taskmaster/test/webhook.test.js create mode 100644 taskmaster/test/whop.test.js diff --git a/.github/time/test_time.py b/.github/time/test_time.py index a80947f..ecae9d6 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -880,6 +880,16 @@ if _sh.which("npm"): else: print("skipped the havre test: no npm") +# Taskmaster on Whop (taskmaster/): the deal desk's rules, the sealed quote card, the signed webhook, and the flow +# from greet to coach mode. No dependencies, so this runs the suite without an install; nothing reaches Whop. +if _sh.which("node"): + tm_dir = str(Path(__file__).resolve().parents[2] / "taskmaster") + res = _sp.run(["node", "--test"], cwd=tm_dir, capture_output=True, text=True, timeout=120) + assert res.returncode == 0 and "# fail 0" in res.stdout, res.stdout[-3000:] + res.stderr[-2000:] + print("ALL OK taskmaster") +else: + print("skipped the taskmaster test: no node") + # 78 Time retries its own parked cards (error / stuck), within TIME_AUTO_RETRY; a human only hears once it has given up K78 = {**K, "TIME_WORKER": "cursor"} meta78 = lambda w: [json.loads(re.search(r"", t).group(1)) for _, t in w.posted] diff --git a/.gitignore b/.gitignore index e63eea1..e22e9ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ node_modules/ +taskmaster/deals.jsonl diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2da1d..f09b7cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it. ### Added +- Taskmaster, rebuilt (#66): `taskmaster/`, the factory's Whop coaching app, where you haggle for your price, pay through a real Whop checkout, and only then get coached. The old version never took money — the character switched to coaching when the member typed "Paid $X via …" and the webhook handler only logged invoices. Two rules carry the rebuild. **The character haggles; the deal desk decides**: the character never sets a price, it calls one tool, `quote(tier, price, reason)`, and `dealdesk.js` answers accept, counter or refuse against the tier's anchor, its floor and the concession rules (OpenPoke's split between the voice and the workers, applied to price). **The app decides the mode, never the model**: one character sheet, two prompts, and the server picks which to send from one fact — whether Whop says the member has paid. A valid membership on `prod_EZ7UkE4pkW04U`, or a place on a valid pack's seat list, is the receipt; typing "Paid" changes nothing and a test proves it. The haggle is Poke's beta bouncer with four rules: the opening is the list price and the desk never quotes above it or walks a price back up; a quote under the floor is countered at the floor whatever reason came with it; a step down needs one of four reasons the member gave (`scope`, `urgency`, `commitment`, `pay_now`), each worth a slice of the anchor and each spendable once; and three rounds, the opening included, then the standing price is the price. Two more refusals are about honesty and spend no round: a reason that reaches for the member's profile, following, employer, means or country is refused (price the project, never the person — the one place this deviates from Poke, which sized you up before it priced you), and so is invented scarcity or a fake deadline. The agreed price becomes a quote card the model cannot edit, sealed with an HMAC over its fields, and a share card follows the deal ("I got Taskmaster down to $X. Anyone get it lower?"), which was Poke's loop. Every deal is logged, closed or walked away from — opening, counters, final price, rounds, reasons, outcome, and the fee and net, never a word the member wrote: one JSON line per deal through an injected sink, validated so prose cannot get in, which is the price discovery that replaces the haggle with fixed tiers later. Orgs buy a seat pack: a Whop checkout has no quantity, so a pack is one membership and the seat list lives in its metadata (no database), swaps are rate-limited, and the buyer sees seats and usage but never anyone's chats. The tiers start from the old prompt's prices ($100 sprint with an $80 floor, $50 lite, $300/$250 for three, $10 and $5 a day, $100-a-seat packs), and the 3-sprint pack saves 17%, not the 15% the old prompt claimed. The voice is Venice with the key in Whop app secrets, one tool, short replies and a limit per member; there is no uncensored default (the old code defaulted to `venice-uncensored`) and the sheet is plain text rather than a Venice character, so it is portable. The persona and the two prompts stay with the operator. Three experiments are bucketed by user through Whop's Experiments API with a local fallback: haggling against a fixed price, a high anchor against a modest one, and one free push against none. **Nothing is created on Whop**: the Whop client refuses every write unless constructed with `live: true`, a daily-billing tier refuses to be created until the operator confirms Whop supports it, and the 75 tests run against an injected `fetch` with simulated signed webhooks. `test_time.py` runs them. - The router ranks the spine's cards too (#173). Astra reads the open issues of `TIME_SPINE` (`teahubxyz/teahub`: Tea MCP, the Pages server, the Gitea look) through the same API, and every one with a `DONE:` line joins the ranked table as `teahub#N`, judged by the seat like any candidate. Those rows are marked "spine: not queued (Time builds in this repo only)": a worker here builds in this repo only, so they are never picked, never sent to the cloud lane, never in the promotion order and never labelled from here — their value tells the owner (or the CEO) what the platform needs next, and platform work no longer competes with Time's own cards by being invisible. The spine's rows get the same label check as this repo's (Gitea answers a filter on a label a repo does not have with every issue), and PRs are left out. The spine stays context, not a dependency: a failed read is a log line and the day's ranking goes up without it. - The router records what each pick turned into (#172), so a learning policy (a restless bandit) has rewards to read. Housekeeping's daily sweep settles every card the router picked in live mode into one line of `time/ledger/router-outcomes.jsonl` — `{"n", "kind", "picked", "settled", "merged", "pr", "days", "usd", "signups"}` — once the card closes: merged or not and its PR from the card's own beats, the days from pick to close, the dollars the ledger booked while the card's worker ran (each `dispatched` beat until the next beat on the card: the ledger has no card field, so this is the runs'-window proxy, not the card's exact bill), and, for a venture pick, the venture's signups at day 7 (that pick waits until day 7 has passed). A pick whose card is still open is in flight and produces nothing; one already in the file is never written twice. Over the ranked table Astra's log now shows one line per kind — picks, merged share, median days, median $ — so the owner can see what a kind returns before changing its weight. The file sits under `time/ledger/`, the one path CI ignores, since the sweep appends to it daily. No policy change: `router.score` still makes every pick. - A cloud PR's revision comes from the cloud, and Time reviews it again. The branch of a cloud-lane PR is the copy's to write, so `/time retry` (the API worker pushing to it) would be overwritten by the next sync. When the reviewer objects instead, the launcher on hb-5 starts one more cloud session on the same branch with the review in its prompt (once; then a human). When the copy's branch moves under a PR that is open and already reviewed, sync.py posts a `dispatched` beat marked `revised` on each card the PR closes and takes `needs-human` off the PR, so the heartbeat reviews the new head and a verdict from before the push no longer counts. A PR not reviewed yet gets nothing; its first review sees the new head. diff --git a/taskmaster/README.md b/taskmaster/README.md new file mode 100644 index 0000000..62d08f2 --- /dev/null +++ b/taskmaster/README.md @@ -0,0 +1,121 @@ +# taskmaster + +Taskmaster is the factory's coaching character on Whop (`app_M7BBH1Evw8sjL4`, product `prod_EZ7UkE4pkW04U`). You +haggle for your price, pay through a real Whop checkout, and only then does it coach you. + +The old version never actually took money: the character switched to coaching when the member typed "Paid $X via …", +and the webhook handler only logged invoices. This is the rebuild. Two things carry it: + +**The character haggles; the deal desk decides.** The character never sets a price. It calls one tool, `quote(tier, +price, reason)`, and `src/dealdesk.js` answers accept, counter or refuse against that tier's anchor, its floor and +the concession rules. This is OpenPoke's split between the voice and the workers, applied to price. + +**The app decides the mode, never the model.** One character sheet, two prompts, and the server picks which to send +from one fact: does Whop say this member has paid? Typing "Paid" changes nothing, and `test/mode.test.js` proves it. + +## The haggle + +Modelled on Poke's beta "bouncer", which sized you up and then made you negotiate your monthly price — it opened +high, gave ground, and people posted the price they got and asked whether anyone went lower, so the haggle was the +marketing too. Four rules, all in `src/dealdesk.js`: + +| Rule | What it means | +| --- | --- | +| Anchor high | The opening is the tier's list price. The desk never quotes above it, and never walks a price back up. | +| Keep a real floor | A quote under the floor is countered at the floor, whatever reason came with it. | +| Earn every step | A step down needs one of four reasons the member gave — `scope`, `urgency`, `commitment`, `pay_now` — each worth a fixed slice of the anchor and each spendable once. No reason, no step. | +| Three rounds | Every answer that puts a number on the table is a round, the opening included. Then the standing price is the price. | + +Two more, about honesty rather than money, and neither spends a round: + +- **Price on what they tell you, never on who they are.** A reason that reaches for the member's profile, + following, employer, means or country is refused. This is the one place Taskmaster deviates from Poke's bouncer. +- **No invented scarcity and no fake deadlines.** The haggle is a game with real rules, not pressure. + +The agreed price becomes a **quote card** the model cannot edit — sealed with an HMAC over its fields, so a card +that comes back through the model changed is worthless — and after a deal comes a **share card** +("I got Taskmaster down to $X. Anyone get it lower?"). + +**Every deal is logged**, closed or walked away from: the opening, each counter, the final price, the rounds, the +reasons and the outcome, and never a word the member wrote (`src/deallog.js`). That log is the price discovery; +once there are enough deals, the operator replaces the haggle with fixed tiers, as Poke did. + +## Tiers + +Starting points from the old prompt, in `src/tiers.js`, which is the only file that knows a price. + +| Tier | Opens at | Floor | Shape | +| --- | --- | --- | --- | +| `sprint` — 1-hour sprint | $100 | $80 | one-time, 14 days to use | +| `lite` — 30-minute sprint | $50 | $40 | one-time, 14 days | +| `pack3` — 3 sprints | $300 | $250 | one-time, 90 days (17% off, not the 15% the old prompt claimed) | +| `daily` — ongoing | $10/day | $8/day | renewal, 1-day billing | +| `checkins` — email check-ins | $5/day | $4/day | renewal, 1-day billing | +| `seats5` / `seats10` | $100/seat | $80 / $72 per seat | one-time pack, 90 days | + +Only one discount was ever published (the sprint's $100 against its $80 intro, 20% off), so that is the ratio the +other individual floors take. Fees are in there too: a card charge plus orchestration is about $0.30 + 3.5%, which +is 9.5% of a $5 ticket but 3.8% of a $100 one, so every logged deal carries what actually reaches the owner. + +## Orgs + +A team lead haggles for a seat pack within a per-seat floor, then assigns seats by Whop username. A Whop checkout +has no quantity, so a pack is one membership and the seat list lives in that membership's metadata — no database. +Swaps are allowed but rate-limited. **The buyer sees seats and usage, never anyone's chats**: usage is counted in +days a seat was used, and `assertNoChats` in `src/seats.js` is what keeps a future field from adding them. An org +that already runs a whop can install Taskmaster there and buy the pack as the app's plan instead. + +If a creator recommends Taskmaster, Whop's affiliate codes pay them: a checkout configuration takes an +`affiliate_code` and Whop pays the commission. There is no reseller cut here. + +## Layout + +``` +src/tiers.js what is sold, the anchors, the floors, the fees, the variant key +src/dealdesk.js the rules. The only thing that decides a price +src/cards.js the sealed quote card and the share card +src/deallog.js one line per deal, validated so prose cannot get in +src/mode.js haggle or coach, from Whop's answer; the two prompts +src/seats.js seat packs in membership metadata, and the buyer's view +src/whop.js the Whop client: checkouts with variant reuse, memberships, metadata +src/webhook.js the signed payment webhook, the only thing that unlocks coach mode +src/experiments.js the three experiments, bucketed by user +src/voice.js Venice, the one tool, the rate limit, no asterisk narration +src/server.js the routes +src/index.js where app secrets become dependencies +``` + +`npm test` runs it all: no dependencies, no network, no Whop. + +## Secrets + +Set with `whop apps secrets set`. None has a default that would let the app run half-configured: see the list at +the top of `src/index.js`. The persona — the character sheet and the two prompts — belongs to the operator and +stays out of this repo; what is here is a personality-free skeleton so a fresh install and the tests run. + +The voice runs on Venice, as the old app did. The old code defaulted to `venice-uncensored`; there is no uncensored +default here and `model()` refuses one outright. Name the model in `TASKMASTER_MODEL`. The sheet is plain text +rather than a Venice character, so it is portable to another model unchanged. + +v1 leaves out the old image and voice modes. + +## Not done here + +Card #66 touches nothing on Whop: no checkout configuration, no variant, no deploy, no secret, no `base_url` +change, until the operator approves the Whop previews. `src/whop.js` therefore refuses every write unless it is +constructed with `live: true` (`TASKMASTER_WHOP_LIVE=on`), and the tests run against an injected `fetch`. + +Four things need confirming against Whop's docs before this runs for real, and each is one line to correct: + +- **Daily billing** for the `daily` and `checkins` tiers. Whop's membership API mentions daily billing, so it looks + supported, but `checkout()` refuses a renewal tier until it is passed `dailyBilling: "confirmed"`. +- **How an inline plan is kept off the product's public page.** The field is in `HIDDEN` in `src/whop.js`. +- **The webhook's header names and signed string.** Both shapes are verified (a timestamp joined to the body, or + the body alone); the header names are constants at the top of `src/webhook.js`. +- **iOS.** Whop's iOS rules may treat a one-off checkout differently from a subscription, which would change what + these tiers may be priced at on a phone. + +The factory's own deal desk lives at and the card asks for it to be +imported rather than rebuilt. That host is not reachable from the build container, so the rules are implemented in +`src/dealdesk.js` behind the same seam — every caller goes through `openDeal` / `quote` / `agree` / `walkaway`, so +swapping the import is a one-file change. diff --git a/taskmaster/package.json b/taskmaster/package.json new file mode 100644 index 0000000..df5df3e --- /dev/null +++ b/taskmaster/package.json @@ -0,0 +1,10 @@ +{ + "name": "taskmaster", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Taskmaster on Whop: haggle for your price, pay through a real Whop checkout, then get coached. No dependencies.", + "scripts": { + "test": "node --test" + } +} diff --git a/taskmaster/src/cards.js b/taskmaster/src/cards.js new file mode 100644 index 0000000..2c0a53d --- /dev/null +++ b/taskmaster/src/cards.js @@ -0,0 +1,65 @@ +// The two cards the haggle produces. +// +// The quote card is the one the model cannot edit. It is built here from the deal desk's own state — never from +// anything the model wrote — and sealed with an HMAC over its fields. The chat renders the card, so the card's +// text passes through the model's context on the way; `unseal` is what makes a card that came back changed +// worthless. The checkout button is the card's only action. +// +// The share card is Poke's loop: people posted the price they got and asked whether anyone went lower, so the +// haggle was also the marketing. It carries the price, the tier and how many rounds it took — never the chat. + +import { tier, CURRENCY, net } from "./tiers.js"; +import { hmacHex, safeEqual, canonical } from "./sign.js"; + +// Only these fields are sealed, and only these are what a buyer is agreeing to. `seal` itself is not sealed. +const SEALED = ["kind", "deal", "tier", "label", "price", "currency", "seats", "includes", "access", "checkout"]; + +export async function quoteCard(deal, { checkout = null, secret, now } = {}) { + if (deal.price == null) throw new Error(`deal ${deal.id} has no agreed price`); + const t = tier(deal.tier); + const card = { + kind: "quote", + deal: deal.id, + tier: t.id, + label: t.seats ? `${t.label} — ${t.seats} seats` : t.label, + price: deal.price, + currency: CURRENCY, + seats: t.seats, + includes: t.includes, + access: t.access, + checkout: checkout ?? null, // null until the deal desk has a Whop checkout; the button waits on it + issued: new Date(now ?? Date.now()).toISOString(), + }; + card.seal = await seal(card, secret); + return card; +} + +export async function seal(card, secret) { + return hmacHex(secret, canonical(Object.fromEntries(SEALED.map((k) => [k, card[k] ?? null])))); +} + +// True only if every sealed field is exactly as issued. A model that rewrote the price, swapped the tier or pointed +// the button somewhere else fails here, and the server refuses to act on the card. +export async function unseal(card, secret) { + if (!card || typeof card !== "object" || card.kind !== "quote") return false; + return safeEqual(card.seal, await seal(card, secret)); +} + +export function shareCard(deal, { url = null } = {}) { + const t = tier(deal.tier); + return { + kind: "share", + deal: deal.id, + tier: t.id, + headline: `I got Taskmaster down to $${deal.price} for a ${t.label.toLowerCase()}`, + sub: `Opened at $${deal.opening ?? deal.anchor}. ${deal.round} round${deal.round === 1 ? "" : "s"}.`, + price: deal.price, + opening: deal.opening ?? deal.anchor, + rounds: deal.round, + prompt: "Anyone get it lower?", + url, + }; +} + +// What the owner sees on a closed deal, in dollars that actually arrive. +export const takeHome = (deal) => (deal.price == null ? null : net(deal.price)); diff --git a/taskmaster/src/dealdesk.js b/taskmaster/src/dealdesk.js new file mode 100644 index 0000000..44a7afa --- /dev/null +++ b/taskmaster/src/dealdesk.js @@ -0,0 +1,230 @@ +// The deal desk. The character haggles; this decides. +// +// OpenPoke's split, applied to price: the voice talks, the workers act. Taskmaster's character never sets a price. +// It calls `quote(deal, { tier, price, reason, note })` and the desk answers accept, counter or refuse. A price the +// desk did not accept is not a price, and the quote card (cards.js) is built from the desk's state, not from +// anything the model wrote. +// +// The rules, all four of them: +// +// Anchor high. The opening is the tier's list price. The desk never quotes above it — anchoring past the +// published price would be a lie — and never walks a price back up mid-deal. +// Keep a real floor. A quote under the tier's floor is countered at the floor. The floor is the stop; the +// concessions below can never reach past it. +// Earn every step. A step down needs one of four reasons the member actually gave — scope, urgency, +// commitment, pay_now — each worth a fixed slice of the anchor and each spendable once. +// No reason, no step: the desk just repeats its standing price. +// Three rounds. Every answer that puts a number on the table is a round, the opening included, so a haggle +// runs open-concede-concede at most. Then the standing price is the price: a fourth quote is +// refused, and the member can still take what is on the table, or walk. +// +// Two refusals are about honesty rather than money, and neither spends a round, because they are the character +// misbehaving and not a move in the negotiation: +// +// Price on what they tell you, never on who they are. The desk takes a tier, a price and a reason tag. A note +// that reaches for the member's profile, following, employer, means or country is refused outright. This is the +// one place Taskmaster deviates from Poke's bouncer, which sized you up before it priced you. +// No invented scarcity and no fake deadlines. The haggle is a game with real rules, not pressure. +// +// This module is deliberately the only thing in the app that knows a price. The factory's own deal desk lives at +// https://teahub.xyz/teahubxyz/dealdesk and card #66 asks for it to be imported rather than rebuilt; that host is +// not reachable from the build container, so the rules are implemented here behind the same seam — every caller +// goes through `openDeal`/`quote`/`agree`/`walkaway`, so swapping the import is a one-file change and the tests +// come with it. + +import { tier, cents, TIERS } from "./tiers.js"; + +export const MAX_ROUNDS = 3; + +// What each earned reason is worth, as a slice of the anchor. Spendable once per tier: all four together come to +// 30% off, which is deeper than any floor here, so the floor is always what actually stops the slide. +export const REASONS = { + scope: 0.1, // they cut what they are asking for + urgency: 0.05, // they need it now, which is worth something to the owner's calendar + commitment: 0.1, // they are buying more than one, or committing for longer + pay_now: 0.05, // they are paying today rather than thinking about it +}; + +// A note that prices the person instead of the work. Checked case-insensitively as substrings. +export const FORBIDDEN = [ + "follower", "following", "profile", "bio", "avatar", "verified", "big account", "small account", + "income", "salary", "net worth", "wealth", "looks rich", "they can afford", "can clearly afford", + "employer", "job title", "company size", "headcount", "country", "city", "timezone wealth", + "past purchases", "whop history", "spend history", "cheap crowd", +]; + +// Invented pressure. The haggle has real rules; it does not need a fake clock. +export const SCARCITY = [ + "last spot", "last seat", "only today", "today only", "expires tonight", "expires today", "deadline", + "one left", "two left", "spots left", "closing soon", "limited time", "limited spots", "price goes up", + "before midnight", "while it lasts", "act now", "final offer today", +]; + +const iso = (now) => new Date(now ?? Date.now()).toISOString(); +const lower = (s) => String(s ?? "").toLowerCase(); + +// `opening` lets the anchor-height experiment open lower than the list price. It is clamped to the tier's own +// range, so an arm can choose where the haggle starts but never where it stops. +export function openDeal({ id, tier: id0, member, thread, opening, now } = {}) { + const t = tier(id0); + const anchor = opening == null ? t.anchor : Math.min(t.anchor, Math.max(t.floor, cents(opening))); + return { + id: String(id ?? `deal_${Math.random().toString(36).slice(2, 10)}`), + member: member ?? null, + thread: thread ?? null, + tier: t.id, + seats: t.seats, + list: t.anchor, + anchor, + floor: t.floor, + opened: iso(now), + round: 0, + price: null, // the standing price: what is on the table right now + opening: null, // the first price the desk put on the table + quotes: [], // one row per spent round: { round, price, reason, verdict } + spent: [], // reason tags already cashed in on this tier + exhausted: false, + outcome: "open", // open -> agreed -> paid, or walkaway + closed: null, + }; +} + +// The desk's answer carries `tell`: a short line for the character, not for the member. The character writes its +// own words; what it may not do is invent a number. +function answer(deal, { verdict, price, rule, tell, reason = null, spend = true }) { + const out = { + ok: verdict === "accept", + verdict, + price: price == null ? null : cents(price), + standing: deal.price, + anchor: deal.anchor, + floor: deal.floor, + tier: deal.tier, + seats: deal.seats, + round: deal.round, + rounds_left: Math.max(0, MAX_ROUNDS - deal.round), + reason, + rule, + tell, + }; + if (verdict === "refuse" || !spend) return out; + deal.round += 1; + deal.price = out.price; + deal.opening ??= out.price; + deal.quotes.push({ round: deal.round, tier: deal.tier, price: out.price, reason, verdict }); + if (reason) deal.spent.push(reason); // a counter that moved the desk's price spent the reason too + if (deal.round >= MAX_ROUNDS) deal.exhausted = true; + out.round = deal.round; + out.standing = deal.price; + out.rounds_left = Math.max(0, MAX_ROUNDS - deal.round); + return out; +} + +// The character's one tool. `note` is the member's own words behind the reason: it shapes the verdict and is then +// dropped — it never reaches the deal, the log or Whop. +export function quote(deal, { tier: want, price, reason = null, note = "" } = {}) { + if (!deal || deal.outcome !== "open") { + return answer(deal ?? openDeal({ tier: "sprint" }), { verdict: "refuse", rule: "closed", tell: "that deal is closed" }); + } + + const text = lower(note) + " " + lower(reason); + const forbidden = FORBIDDEN.find((w) => text.includes(w)); + if (forbidden) { + return answer(deal, { verdict: "refuse", rule: "who_they_are", + tell: `price the project, not the person — drop "${forbidden}" and quote on scope, urgency, commitment or paying now` }); + } + const pressure = SCARCITY.find((w) => text.includes(w)); + if (pressure) { + return answer(deal, { verdict: "refuse", rule: "scarcity", tell: `no invented deadlines — drop "${pressure}"` }); + } + + // A tier switch is allowed (the character may talk them up or down a tier) but it resets the anchor, the floor + // and the concessions, so a switch is not a way around the floor. + if (want && want !== deal.tier) { + if (!TIERS[want]) return answer(deal, { verdict: "refuse", rule: "tier", tell: `no such tier: ${want}` }); + const t = tier(want); + deal.tier = t.id; + deal.seats = t.seats; + deal.list = t.anchor; + deal.anchor = t.anchor; + deal.floor = t.floor; + deal.spent = []; + deal.price = null; + } + + const p = cents(Number(price)); + if (!Number.isFinite(p) || p <= 0) { + return answer(deal, { verdict: "refuse", rule: "price", tell: "a quote needs a price in dollars" }); + } + if (p > deal.anchor) { + return answer(deal, { verdict: "refuse", rule: "above_anchor", + tell: `${deal.tier} opens at ${deal.anchor} — never quote above the opening` }); + } + + const standing = deal.price ?? deal.anchor; + + if (deal.exhausted) { + return p === standing + ? answer(deal, { verdict: "accept", price: p, rule: "last_price", tell: `done haggling at ${p}`, spend: false }) + : answer(deal, { verdict: "refuse", rule: "rounds", + tell: `${MAX_ROUNDS} rounds is the cap — ${standing} is the price, take it or leave it` }); + } + + const step = cents(standing - p); + if (step < 0) { + return answer(deal, { verdict: "counter", price: standing, rule: "no_walk_up", + tell: `${standing} is already on the table and the desk does not walk a price back up` }); + } + if (step === 0) { + return answer(deal, { verdict: "accept", price: p, rule: "standing", tell: `holding at ${p}` }); + } + + if (!Object.hasOwn(REASONS, reason)) { + return answer(deal, { verdict: "counter", price: standing, rule: "no_reason", + tell: `no step down for nothing — ${standing} stands until they give you scope, urgency, commitment or paying now` }); + } + if (deal.spent.includes(reason)) { + return answer(deal, { verdict: "counter", price: standing, rule: "reason_spent", + tell: `${reason} is already cashed in — ${standing} stands` }); + } + + // The floor first, because it is the hard rule: a reason buys a step, never a price under the floor. + if (p < deal.floor) { + return answer(deal, { verdict: "counter", price: deal.floor, rule: "floor", reason, + tell: `${deal.floor} is the floor on ${deal.tier} — counter at ${deal.floor}` }); + } + const earned = cents(REASONS[reason] * deal.anchor); + if (step > earned) { + const counter = Math.max(deal.floor, cents(standing - earned)); + return answer(deal, { verdict: "counter", price: counter, rule: "over_earned", reason, + tell: `${reason} earns ${earned} off, not ${step} — counter at ${counter}` }); + } + return answer(deal, { verdict: "accept", price: p, rule: "earned", reason, tell: `${reason} earns it — ${p} agreed` }); +} + +// The member takes the standing price. From here the price is fixed and the quote card can be sealed; payment is +// Whop's to confirm. +export function agree(deal, { now } = {}) { + if (deal.outcome !== "open") throw new Error(`deal ${deal.id} is ${deal.outcome}`); + if (deal.price == null) throw new Error(`deal ${deal.id} has no price to agree to`); + deal.outcome = "agreed"; + deal.agreed = iso(now); + return deal; +} + +// They left. Logged like any other deal, because a walkaway is the half of price discovery a payment cannot show. +export function walkaway(deal, { now } = {}) { + if (deal.outcome === "paid") throw new Error(`deal ${deal.id} was paid`); + deal.outcome = "walkaway"; + deal.closed = iso(now); + return deal; +} + +// Whop confirmed the money. Only the webhook (or a membership check on return from checkout) calls this. +export function paid(deal, { now, receipt = null } = {}) { + if (deal.outcome !== "agreed") throw new Error(`deal ${deal.id} is ${deal.outcome}, not agreed`); + deal.outcome = "paid"; + deal.closed = iso(now); + deal.receipt = receipt; + return deal; +} diff --git a/taskmaster/src/deallog.js b/taskmaster/src/deallog.js new file mode 100644 index 0000000..31353c1 --- /dev/null +++ b/taskmaster/src/deallog.js @@ -0,0 +1,112 @@ +// The deal log: every deal, closed or walked away from, with no chat text in it. +// +// This log is the price discovery. The opening price, each counter, the final price, the rounds, the reasons that +// earned the steps, and the outcome — enough to set fixed tiers from later, as Poke did, and nothing a member said. +// +// Where it goes: a closed deal is already recorded in Whop, through the checkout's metadata and the payment, so +// Whop stays the receipt. A walkaway is recorded nowhere by Whop, so both halves are written here too, as one JSON +// line per deal through an injected sink. The default sink appends to a JSONL file, which is how Time keeps its own +// ledger (`time/ledger/*.jsonl`) — no database to run, and the file is the thing the operator reads when it is time +// to replace the haggle with fixed tiers. +// +// `line()` builds from a fixed key list and `validate()` checks every value is a number, an enum member, an id or +// an ISO date. Prose cannot get in by accident: the desk's `note` never leaves dealdesk.js, and anything that did +// leak would fail validation rather than be written. + +import { fee, net, cents } from "./tiers.js"; +import { REASONS, MAX_ROUNDS } from "./dealdesk.js"; + +export const OUTCOMES = ["open", "agreed", "paid", "walkaway"]; +export const VERDICTS = ["accept", "counter", "refuse"]; + +const ID = /^[A-Za-z0-9_:.-]{1,64}$/; +const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; + +export function line(deal, { now } = {}) { + return { + ts: new Date(now ?? Date.now()).toISOString(), + deal: deal.id, + member: deal.member ?? null, + thread: deal.thread ?? null, + tier: deal.tier, + seats: deal.seats ?? null, + list: deal.list ?? deal.anchor, + anchor: deal.anchor, + floor: deal.floor, + opening: deal.opening ?? null, + counters: deal.quotes.map((q) => q.price), + reasons: deal.quotes.map((q) => q.reason).filter(Boolean), + final: deal.price ?? null, + rounds: deal.round, + outcome: deal.outcome, + fee: deal.price == null ? null : fee(deal.price), + net: deal.price == null ? null : net(deal.price), + opened: deal.opened, + closed: deal.closed ?? null, + }; +} + +export const KEYS = Object.keys(line({ id: "d", tier: "sprint", seats: null, anchor: 0, floor: 0, quotes: [], round: 0, + outcome: "open", opened: "1970-01-01T00:00:00.000Z" })); + +// Throws with the offending key. Called on every write, so a field that is not a number, an id, a date or one of +// the known enums never reaches the file. +export function validate(row) { + const bad = (k, why) => { throw new Error(`deal log: ${k} ${why}`); }; + const num = (k) => { const v = row[k]; if (v !== null && !(Number.isFinite(v) && v >= 0)) bad(k, "must be a number or null"); }; + const id = (k) => { const v = row[k]; if (v !== null && !(typeof v === "string" && ID.test(v))) bad(k, "must be an id or null"); }; + const date = (k) => { const v = row[k]; if (v !== null && !(typeof v === "string" && ISO.test(v))) bad(k, "must be an ISO date or null"); }; + + for (const k of Object.keys(row)) { + if (!KEYS.includes(k)) bad(k, "is not a deal-log field"); + } + for (const k of ["list", "anchor", "floor", "opening", "final", "fee", "net", "seats"]) num(k); + if (!Number.isInteger(row.rounds) || row.rounds < 0 || row.rounds > MAX_ROUNDS) bad("rounds", `must be 0..${MAX_ROUNDS}`); + for (const k of ["deal", "member", "thread", "tier"]) id(k); + for (const k of ["ts", "opened", "closed"]) date(k); + if (!OUTCOMES.includes(row.outcome)) bad("outcome", `must be one of ${OUTCOMES.join(", ")}`); + if (!Array.isArray(row.counters) || row.counters.some((p) => !(Number.isFinite(p) && p > 0))) bad("counters", "must be prices"); + if (!Array.isArray(row.reasons) || row.reasons.some((r) => !Object.hasOwn(REASONS, r))) bad("reasons", "must be earned-reason tags"); + return row; +} + +// sink(row) -> void | Promise. `fileSink` is the default: one JSON line appended per deal. +export async function record(deal, { sink, now } = {}) { + const row = validate(line(deal, { now })); + await sink(row); + return row; +} + +export function fileSink(path, { fs } = {}) { + return async (row) => { + const mod = fs ?? (await import("node:fs/promises")); + await mod.appendFile(path, JSON.stringify(row) + "\n", "utf8"); + }; +} + +export function memorySink() { + const rows = []; + const sink = async (row) => { rows.push(row); }; + sink.rows = rows; + return sink; +} + +// What the operator reads to replace the haggle with fixed tiers: per tier, how many deals closed, at what prices +// and how often a haggle ended in a walkaway. +export function summary(rows) { + const out = {}; + for (const row of rows) { + const t = (out[row.tier] ??= { tier: row.tier, deals: 0, paid: 0, walkaways: 0, prices: [], rounds: [], net: 0 }); + t.deals += 1; + if (row.outcome === "paid") { t.paid += 1; t.prices.push(row.final); t.net = cents(t.net + (row.net ?? 0)); } + if (row.outcome === "walkaway") t.walkaways += 1; + t.rounds.push(row.rounds); + } + const mid = (xs) => (xs.length ? [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) / 2)] : null); + return Object.values(out).map((t) => ({ + tier: t.tier, deals: t.deals, paid: t.paid, walkaways: t.walkaways, + median_price: mid(t.prices), low: t.prices.length ? Math.min(...t.prices) : null, + high: t.prices.length ? Math.max(...t.prices) : null, + median_rounds: mid(t.rounds), net: t.net, + })); +} diff --git a/taskmaster/src/experiments.js b/taskmaster/src/experiments.js new file mode 100644 index 0000000..6aa9fdf --- /dev/null +++ b/taskmaster/src/experiments.js @@ -0,0 +1,55 @@ +// The three experiments the card asks for, bucketed by user. +// +// Whop has an Experiments API and that is where these belong, so the bucket is asked for through an injected +// client. It is also a hard dependency on a service that may be down, and an experiment that falls back to "no +// bucket" silently stops measuring, so there is a local assignment too: a stable hash of user + experiment, which +// gives the same person the same arm every time without a round trip. Whop's answer wins when there is one. +// +// Nothing here changes a price. An experiment picks which mechanic a member meets; the deal desk's floors hold in +// every arm. + +import { hmacHex } from "./sign.js"; + +export const EXPERIMENTS = { + // The headline test. Which converts better, and at what average price? + haggle_vs_fixed: { arms: ["haggle", "fixed"], note: "haggling against the tier's fixed price" }, + // Poke opened at $292 for a deal that closed at $29. Is a high anchor worth it, or does it just lose people? + anchor_height: { arms: ["list", "modest"], note: "the tier's list price against a modest opening" }, + // One free push before payment, against nothing free at all. + free_push: { arms: ["none", "one"], note: "nothing free before payment against one free push" }, +}; + +export function arms(key) { + const e = EXPERIMENTS[key]; + if (!e) throw new Error(`no such experiment: ${key}`); + return e.arms; +} + +// Deterministic and even: the first bytes of an HMAC over user + experiment, taken modulo the number of arms. +export async function localArm(key, user, { salt = "taskmaster" } = {}) { + const list = arms(key); + if (!user) return list[0]; + const hex = await hmacHex(salt, `${key}:${user}`); + return list[parseInt(hex.slice(0, 8), 16) % list.length]; +} + +// client: { arm({ experiment, user }) -> string | null }. A client that throws, or answers with an arm this +// experiment does not have, is treated as no answer. +export async function armFor(key, user, { client = null, salt } = {}) { + const list = arms(key); + if (client) { + try { + const got = await client.arm({ experiment: key, user }); + if (got && list.includes(got)) return { arm: got, via: "whop" }; + } catch { /* fall through to the local assignment rather than stop measuring */ } + } + return { arm: await localArm(key, user, salt ? { salt } : {}), via: "local" }; +} + +// The opening an arm asks for, passed to `openDeal` as its anchor. "modest" opens halfway between the list price +// and the floor — low enough to feel different, high enough to leave a round of movement — and the floor itself is +// the same in both arms, so the experiment measures the opening and nothing else. +export function openingFor(t, arm) { + if (arm !== "modest") return t.anchor; + return Math.max(t.floor, Math.round((t.anchor + t.floor) / 2)); +} diff --git a/taskmaster/src/index.js b/taskmaster/src/index.js new file mode 100644 index 0000000..2d410c1 --- /dev/null +++ b/taskmaster/src/index.js @@ -0,0 +1,42 @@ +// The server entry: where app secrets become the app's dependencies, and the only file that reads `env` directly. +// +// Secrets are set with `whop apps secrets set` and arrive as environment variables. None of them has a default +// that would let the app run half-configured and look fine: +// +// VENICE_API_KEY the voice. Without it a turn fails loudly rather than answering as nobody. +// TASKMASTER_MODEL the model id. Unset means GLM 5.2; an uncensored model is refused. +// TASKMASTER_SHEET the character sheet. The operator's; not in this repo. +// TASKMASTER_PROMPT_HAGGLE the haggle prompt. The operator's. +// TASKMASTER_PROMPT_COACH the coach prompt. The operator's. +// TASKMASTER_CARD_SECRET seals the quote card, so a card the model edited is worthless. +// WHOP_API_KEY reads memberships and, once the operator approves, writes checkouts. +// WHOP_WEBHOOK_SECRET verifies the payment webhook. +// TASKMASTER_DEAL_LOG where the deal log is appended. Defaults to ./deals.jsonl. +// TASKMASTER_WHOP_LIVE "on" lets the app create checkout configurations and write membership metadata. +// Card #66 leaves it off: nothing is created on Whop until the previews are approved. +// +// `stores` is left to the host, because the right answer depends on where this runs: the in-memory default is for +// a single process and the tests, and a Whop deployment passes a KV- or D1-backed pair with the same two methods. + +import { app } from "./server.js"; +import { voice } from "./voice.js"; +import { whop } from "./whop.js"; +import { fileSink } from "./deallog.js"; + +export function memoryStore() { + const m = new Map(); + return { get: async (id) => m.get(id) ?? null, save: async (v) => void m.set(v.id, v) }; +} + +export function create(env = process.env, { deals = memoryStore(), threads = memoryStore(), log, experiments = null } = {}) { + return app(env, { + voice: voice({ apiKey: env.VENICE_API_KEY, model: env.TASKMASTER_MODEL }), + whop: whop({ apiKey: env.WHOP_API_KEY, live: env.TASKMASTER_WHOP_LIVE === "on" }), + deals, + threads, + log: log ?? fileSink(env.TASKMASTER_DEAL_LOG ?? "deals.jsonl"), + experiments, + }); +} + +export default { create, memoryStore }; diff --git a/taskmaster/src/mode.js b/taskmaster/src/mode.js new file mode 100644 index 0000000..854c8a1 --- /dev/null +++ b/taskmaster/src/mode.js @@ -0,0 +1,88 @@ +// Which prompt the server sends. The app decides this; the model never does. +// +// The character sheet is shared by two prompts, haggle and coach, and `modeFor` picks between them from one fact: +// does Whop say this member has paid? A valid membership on Taskmaster's product, or a place on a valid seat +// pack's seat list, is the receipt. Nothing a member types changes it — "Paid $100 via Cash App" is just text, and +// `test/mode.test.js` proves it leaves the thread in haggle mode. That was the hole in the old app: the character +// switched to coaching when the member said it had. +// +// The persona and the two prompts belong to the operator and stay out of this repo. They arrive as app secrets +// (TASKMASTER_SHEET, TASKMASTER_PROMPT_HAGGLE, TASKMASTER_PROMPT_COACH); what is here is a personality-free +// skeleton so a fresh install and the tests run, plus the rules that are the app's to enforce either way. + +export const PRODUCT = "prod_EZ7UkE4pkW04U"; +export const MODES = ["haggle", "coach"]; + +const ms = (v) => (v == null ? null : new Date(v).getTime()); + +// A membership counts when Whop says it is valid, it is on Taskmaster's product, and any end date it carries is +// still in the future. `valid` alone is not trusted to have been refreshed. +export function paidMembership(memberships = [], { product = PRODUCT, now } = {}) { + const t = now ?? Date.now(); + return memberships.find((m) => { + if (!m || m.valid !== true) return false; + if ((m.product_id ?? m.product) !== product) return false; + const ends = ms(m.expires_at ?? m.renewal_period_end ?? null); + return ends == null || ends > t; + }) ?? null; +} + +// A seat pack is one membership; the seat list lives in its metadata (see seats.js). A member on a valid pack's +// list is paid for, without a membership of their own. +export function seatOn(packs = [], user, { product = PRODUCT, now } = {}) { + if (!user) return null; + const t = now ?? Date.now(); + const who = String(user).toLowerCase(); + return packs.find((p) => { + if (!p || p.valid !== true) return false; + if ((p.product_id ?? p.product) !== product) return false; + const ends = ms(p.expires_at ?? p.renewal_period_end ?? null); + if (ends != null && ends <= t) return false; + return (p.metadata?.seats ?? []).some((s) => String(s?.user ?? s).toLowerCase() === who); + }) ?? null; +} + +// The whole decision, and the only one. `said` is deliberately not a parameter: there is nothing a member can say +// that belongs in it. +export function modeFor({ memberships = [], packs = [], user, product = PRODUCT, now } = {}) { + const own = paidMembership(memberships, { product, now }); + if (own) return { mode: "coach", via: "membership", membership: own.id ?? null, seats: null }; + const pack = seatOn(packs, user, { product, now }); + if (pack) return { mode: "coach", via: "seat", membership: pack.id ?? null, seats: (pack.metadata?.seats ?? []).length }; + return { mode: "haggle", via: "unpaid", membership: null, seats: null }; +} + +// A personality-free skeleton. The operator's sheet replaces it wholesale; these lines are the app's rules, which +// hold whichever sheet is loaded. +export const SKELETON = [ + "You are Taskmaster, a coaching character on Whop.", + "Write in short lines. No stage directions, no narration between asterisks.", + "Say only what is true: no invented deadlines, no scarcity, no discounts you have not been given.", +].join("\n"); + +const RULES = { + haggle: [ + "You are haggling over the price of coaching. You do not set prices.", + "To put a number on the table, call the quote tool with a tier, a price in dollars and one reason tag:", + "scope, urgency, commitment or pay_now. The deal desk answers accept, counter or refuse; say its number, never your own.", + "Earn every step down with something the member told you about the work. Price the project and the blocker,", + "never the person: their profile, following, employer, country or means are not yours to price on.", + "Open at the desk's anchor. Three rounds at most. Keep every reply to a couple of lines.", + "When the desk accepts, the quote card carries the price and the checkout button. Do not restate or change it.", + ].join("\n"), + coach: [ + "The member has paid; Whop confirmed it. Coach them.", + "Short commands. Track what they commit to and hold them to it by name.", + "Call out stalling. Celebrate a win plainly. Tell real rest from avoidance, and say which you think it is.", + "Do not discuss price, discounts or the deal: that is finished.", + ].join("\n"), +}; + +// The server composes the system prompt. `sheet` and `prompts` come from app secrets; anything missing falls back +// to the skeleton, so the app never silently runs with half a prompt. +export function promptFor(mode, { sheet, prompts = {} } = {}) { + if (!MODES.includes(mode)) throw new Error(`no such mode: ${mode}`); + const head = (sheet ?? "").trim() || SKELETON; + const body = (prompts[mode] ?? "").trim() || RULES[mode]; + return `${head}\n\n${body}`; +} diff --git a/taskmaster/src/seats.js b/taskmaster/src/seats.js new file mode 100644 index 0000000..a42e24c --- /dev/null +++ b/taskmaster/src/seats.js @@ -0,0 +1,120 @@ +// Seat packs, for an org whose lead haggles once and then hands seats out. +// +// A Whop checkout has no quantity, so a pack is one membership and the pack size is the variant (seats5, seats10). +// The seat list lives in that membership's metadata, which Whop's membership API can merge into, so there is still +// no database to run: the membership is both the receipt and the seat list. +// +// Swaps are allowed — people leave teams — but rate-limited, so a pack of five cannot be rented round a company of +// fifty. The limit is a count per rolling window, kept in the same metadata as the seats. +// +// What the buyer sees: seats and usage. Never anyone's chats. `buyerView` is built from a fixed key list and +// `assertNoChats` is what stops a future field from quietly adding them. + +import { tier, TIERS } from "./tiers.js"; + +export const SWAP_LIMIT = 3; // removals per window +export const SWAP_WINDOW_DAYS = 30; +const USER = /^[A-Za-z0-9_.-]{1,32}$/; // a Whop username, not a display name + +const iso = (now) => new Date(now ?? Date.now()).toISOString(); +const norm = (u) => String(u ?? "").trim().replace(/^@/, "").toLowerCase(); + +export const seatsOf = (membership) => membership?.metadata?.seats ?? []; +export const swapsOf = (membership) => membership?.metadata?.seat_swaps ?? []; + +// A pack's size is its tier's, never a number carried in metadata a member could have written. An individual +// membership and an unknown tier are both "not a pack", which is what `assign` refuses on. +export function size(membership) { + const id = membership?.metadata?.tier ?? null; + if (!id || !Object.hasOwn(TIERS, id)) return 0; + return tier(id).seats ?? 0; +} + +function recentSwaps(membership, now) { + const cut = (now ?? Date.now()) - SWAP_WINDOW_DAYS * 86400000; + return swapsOf(membership).filter((ts) => new Date(ts).getTime() > cut); +} + +// Returns the metadata patch to merge into the membership, or throws with what is wrong. Nothing is written here: +// the caller hands the patch to the Whop client, so a refused plan never touches Whop. +export function assign(membership, { add = [], remove = [], now } = {}) { + const seats = seatsOf(membership); + const cap = size(membership); + if (!cap) throw new Error("that membership is not a seat pack"); + + const dropping = remove.map(norm).filter(Boolean); + for (const u of dropping) { + if (!seats.some((s) => norm(s.user) === u)) throw new Error(`${u} does not hold a seat`); + } + const adding = []; + for (const raw of add) { + const u = norm(raw); + if (!USER.test(u)) throw new Error(`${JSON.stringify(raw)} is not a Whop username`); + if (adding.includes(u)) continue; + if (seats.some((s) => norm(s.user) === u) && !dropping.includes(u)) throw new Error(`${u} already holds a seat`); + adding.push(u); + } + + const swaps = recentSwaps(membership, now); + if (dropping.length && swaps.length + dropping.length > SWAP_LIMIT) { + throw new Error(`${SWAP_LIMIT} seat swaps per ${SWAP_WINDOW_DAYS} days; ${swaps.length} used`); + } + + const kept = seats.filter((s) => !dropping.includes(norm(s.user))); + const next = [...kept, ...adding.map((user) => ({ user, assigned: iso(now), used: 0 }))]; + if (next.length > cap) throw new Error(`that pack has ${cap} seats; ${next.length} would be assigned`); + + return { + seats: next, + seat_swaps: [...swaps, ...dropping.map(() => iso(now))], + }; +} + +// Usage, counted in days a seat was used rather than messages sent: it is the number the buyer actually wants, it +// is one metadata write a day instead of one a turn, and it cannot be read back as a message count. Still a +// count, never a transcript. Returns null when there is nothing to write. +export function useSeat(membership, user, { now } = {}) { + const u = norm(user); + const seats = seatsOf(membership); + const seat = seats.find((s) => norm(s.user) === u); + if (!seat) throw new Error(`${u} does not hold a seat`); + const day = iso(now).slice(0, 10); + if (String(seat.last ?? "").slice(0, 10) === day) return null; + return { seats: seats.map((s) => (s === seat ? { ...s, used: (s.used ?? 0) + 1, last: iso(now) } : s)) }; +} + +const VIEW_SEAT = ["user", "assigned", "used", "last"]; + +// Everything the buyer is allowed to see. Nothing in a seat row is free text a member wrote, so there is no chat +// to leak: a seat is a username, two timestamps and a count. +export function buyerView(membership, { now } = {}) { + const cap = size(membership); + const seats = seatsOf(membership).map((s) => Object.fromEntries(VIEW_SEAT.map((k) => [k, s[k] ?? null]))); + const swaps = recentSwaps(membership, now); + return assertNoChats({ + membership: membership?.id ?? null, + tier: membership?.metadata?.tier ?? null, + seats: cap, + assigned: seats.length, + open: Math.max(0, cap - seats.length), + used: seats.reduce((n, s) => n + (s.used ?? 0), 0), + swaps_used: swaps.length, + swaps_left: Math.max(0, SWAP_LIMIT - swaps.length), + swap_window_days: SWAP_WINDOW_DAYS, + holders: seats, + }); +} + +const CHATTY = ["chat", "message", "messages", "transcript", "thread", "text", "reply", "replies", "note", "notes", "prompt"]; + +export function assertNoChats(view) { + const walk = (node, path) => { + if (!node || typeof node !== "object") return; + for (const [k, v] of Object.entries(node)) { + if (CHATTY.includes(k.toLowerCase())) throw new Error(`the buyer view must not carry ${path}${k}`); + walk(v, `${path}${k}.`); + } + }; + walk(view, ""); + return view; +} diff --git a/taskmaster/src/server.js b/taskmaster/src/server.js new file mode 100644 index 0000000..fa1ec49 --- /dev/null +++ b/taskmaster/src/server.js @@ -0,0 +1,217 @@ +// Taskmaster's server. A plain fetch handler, so it runs on Whop's hosting under their template's route or on its +// own; `app(env, deps)` returns `fetch(request)` and holds no module state. +// +// POST /api/chat { thread, text } one turn. The server decides the mode and sends that prompt. +// POST /api/agree { thread, card } the member takes the sealed quote; answers with the checkout. +// POST /api/webhooks/whop signed by Whop; the only thing that unlocks coach mode. +// GET /api/return?thread= back from checkout: ask Whop whether the membership is there yet. +// GET /api/seats?membership= the pack buyer's seats and usage. Never anyone's chats. +// POST /api/seats { membership, add, remove } +// +// The shape of a turn: +// +// 1. The server asks Whop who this member is — never the member. `modeFor` answers haggle or coach. +// 2. In coach mode it sends the coach prompt with no tools, and the deal is over. +// 3. In haggle mode it sends the haggle prompt with one tool, `quote`. If the model calls it, the deal desk +// answers and the desk's verdict goes back for one more turn, so the character says the desk's number. +// 4. On accept it seals a quote card. The card carries the checkout; the share card follows the payment. +// +// What a member types never moves step 1. "Paid $100 via Cash App" goes to the model as text like anything else +// and the mode stays what Whop says it is. + +import { openDeal, quote, agree, walkaway, MAX_ROUNDS } from "./dealdesk.js"; +import { quoteCard, shareCard, unseal } from "./cards.js"; +import { modeFor, promptFor, PRODUCT } from "./mode.js"; +import { record } from "./deallog.js"; +import { assign, buyerView, useSeat, size } from "./seats.js"; +import { handle as handleWebhook } from "./webhook.js"; +import { armFor, openingFor } from "./experiments.js"; +import { tier, TIERS, IDS } from "./tiers.js"; +import { QUOTE_TOOL, HAGGLE_MAX_TOKENS, COACH_MAX_TOKENS, limiter } from "./voice.js"; + +const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } }); +const MAX_TEXT = 2000; + +// deps: { voice, whop, deals, threads, log, experiments?, clock? } +// deals { get(id), save(deal) } +// threads { get(id), save(thread) } a thread is { id, member, deal, turns: [{role, content}] } +// log a deal-log sink (deallog.js) +export function app(env = {}, deps = {}) { + const now = () => (deps.clock ? deps.clock() : Date.now()); + const gate = deps.limit ?? limiter({ clock: now }); + const secret = env.TASKMASTER_CARD_SECRET; + const prompts = { haggle: env.TASKMASTER_PROMPT_HAGGLE, coach: env.TASKMASTER_PROMPT_COACH }; + const sheet = env.TASKMASTER_SHEET; + + // Whop is asked, the member is not. A pack membership is deliberately not an individual membership: a lead who + // buys ten seats and keeps none for themselves is a buyer, not a seat holder, and gets the seats view rather + // than coaching. Putting their own username on the list is all it takes. + async function whoIs(member) { + const held = await deps.whop.membershipsFor(member); + const packs = held.filter((m) => size(m) > 0); + return modeFor({ memberships: held.filter((m) => size(m) === 0), packs, user: member, product: PRODUCT, now: now() }); + } + + async function chat({ thread: id, text }) { + if (typeof text !== "string" || !text.trim()) return json({ error: "nothing to answer" }, 400); + if (text.length > MAX_TEXT) return json({ error: "too long" }, 413); + const thread = (await deps.threads.get(id)) ?? { id, member: null, deal: null, turns: [] }; + if (!thread.member) return json({ error: "unknown thread" }, 404); + + const allowed = gate.take(thread.member); + if (!allowed.ok) return json({ error: "slow down", retry_after_ms: allowed.retry_after_ms }, 429); + + const who = await whoIs(thread.member); + const system = promptFor(who.mode, { sheet, prompts }); + thread.turns.push({ role: "user", content: text }); + + if (who.mode === "coach") { + const said = await deps.voice.say({ system, messages: turns(thread), max_tokens: COACH_MAX_TOKENS }); + thread.turns.push({ role: "assistant", content: said.text }); + if (who.via === "seat" && who.membership) { + const patch = useSeat(await deps.whop.membership(who.membership), thread.member, { now: now() }); + if (patch) await deps.whop.mergeMembershipMetadata(who.membership, patch); + } + await deps.threads.save(thread); + return json({ mode: "coach", via: who.via, reply: said.text, cards: [] }); + } + + const deal = await dealFor(thread); + const said = await deps.voice.say({ system, messages: turns(thread), tools: [QUOTE_TOOL], max_tokens: HAGGLE_MAX_TOKENS }); + const call = said.calls.find((c) => c.name === "quote"); + if (!call) { + thread.turns.push({ role: "assistant", content: said.text }); + await deps.threads.save(thread); + return json({ mode: "haggle", reply: said.text, cards: [], deal: dealView(deal) }); + } + + const verdict = quote(deal, call.args); + await deps.deals.save(deal); + // One more turn, so the character delivers the desk's number in its own words instead of its own number. + thread.turns.push({ role: "assistant", content: said.text || "", tool: call.name }); + const after = await deps.voice.say({ + system, + messages: [...turns(thread), { role: "user", content: `[deal desk] ${verdict.verdict}: ${verdict.tell}` }], + max_tokens: HAGGLE_MAX_TOKENS, + }); + thread.turns.push({ role: "assistant", content: after.text }); + + const cards = []; + if (verdict.ok) { + agree(deal, { now: now() }); + const checkout = await deps.whop.checkout({ + tier: deal.tier, price: deal.price, member: deal.member, thread: thread.id, deal: deal.id, + affiliate_code: thread.affiliate_code ?? null, + }); + deal.checkout = checkout.id ?? null; + await deps.deals.save(deal); + cards.push(await quoteCard(deal, { checkout: checkout.url, secret, now: now() })); + } + await deps.threads.save(thread); + return json({ mode: "haggle", reply: after.text, cards, deal: dealView(deal), verdict: verdict.verdict }); + } + + async function dealFor(thread) { + if (thread.deal) { + const open = await deps.deals.get(thread.deal); + if (open && open.outcome === "open") return open; + } + const id = thread.want && TIERS[thread.want] ? thread.want : "sprint"; + const { arm } = await armFor("anchor_height", thread.member, { client: deps.experiments ?? null }); + const made = openDeal({ tier: id, member: thread.member, thread: thread.id, opening: openingFor(tier(id), arm), now: now() }); + made.arm = arm; + thread.deal = made.id; + await deps.deals.save(made); + return made; + } + + // The member presses the card's button. The card is checked against its seal first: a card the model rewrote on + // the way through does not buy anything. + async function takeIt({ thread: id, card }) { + const thread = await deps.threads.get(id); + if (!thread) return json({ error: "unknown thread" }, 404); + if (!(await unseal(card, secret))) return json({ error: "that quote card is not one I issued" }, 400); + const deal = await deps.deals.get(card.deal); + if (!deal || deal.thread !== id) return json({ error: "unknown deal" }, 404); + if (deal.price !== card.price || deal.tier !== card.tier) return json({ error: "that card is out of date" }, 409); + return json({ ok: true, checkout: card.checkout, price: deal.price, tier: deal.tier }); + } + + // Back from checkout. Whop's webhook is the receipt, but it can be late, so the membership is checked here too: + // either way the switch comes from Whop and not from the member. + async function back(url) { + const id = url.searchParams.get("thread"); + const thread = id ? await deps.threads.get(id) : null; + if (!thread) return json({ error: "unknown thread" }, 404); + const who = await whoIs(thread.member); + const deal = thread.deal ? await deps.deals.get(thread.deal) : null; + const cards = []; + if (who.mode === "coach" && deal?.outcome === "paid") cards.push(shareCard(deal, { url: env.TASKMASTER_SHARE_URL ?? null })); + return json({ mode: who.mode, via: who.via, cards, deal: deal ? dealView(deal) : null }); + } + + // They left. An agreed deal counts too: a member who took a quote card to checkout and never paid is the most + // useful walkaway there is, and Whop has no record of it. + async function gone({ thread: id }) { + const thread = await deps.threads.get(id); + const deal = thread?.deal ? await deps.deals.get(thread.deal) : null; + if (!deal || !["open", "agreed"].includes(deal.outcome)) return json({ ok: true }); + walkaway(deal, { now: now() }); + await deps.deals.save(deal); + await record(deal, { sink: deps.log, now: now() }); + return json({ ok: true, outcome: "walkaway" }); + } + + async function seats(req, url) { + if (req.method === "GET") { + const id = url.searchParams.get("membership"); + const m = id ? await deps.whop.membership(id) : null; + if (!m) return json({ error: "unknown membership" }, 404); + return json(buyerView(m, { now: now() })); + } + const body = await req.json().catch(() => ({})); + const m = body.membership ? await deps.whop.membership(body.membership) : null; + if (!m) return json({ error: "unknown membership" }, 404); + let patch; + try { patch = assign(m, { add: body.add ?? [], remove: body.remove ?? [], now: now() }); } + catch (e) { return json({ error: e.message }, 400); } + await deps.whop.mergeMembershipMetadata(body.membership, patch); + return json(buyerView({ ...m, metadata: { ...m.metadata, ...patch } }, { now: now() })); + } + + return async function fetch(req) { + const url = new URL(req.url); + const path = url.pathname.replace(/\/+$/, "") || "/"; + try { + if (path === "/api/chat" && req.method === "POST") return await chat(await req.json().catch(() => ({}))); + if (path === "/api/agree" && req.method === "POST") return await takeIt(await req.json().catch(() => ({}))); + if (path === "/api/walkaway" && req.method === "POST") return await gone(await req.json().catch(() => ({}))); + if (path === "/api/return" && req.method === "GET") return await back(url); + if (path === "/api/seats") return await seats(req, url); + if (path === "/api/tiers" && req.method === "GET") { + return json({ tiers: IDS.map((id) => ({ id, label: TIERS[id].label, includes: TIERS[id].includes, access: TIERS[id].access })) }); + } + if (path === "/api/webhooks/whop" && req.method === "POST") { + const raw = await req.text(); + const out = await handleWebhook(raw, req.headers, { + secret: env.WHOP_WEBHOOK_SECRET, now: now(), + deps: { deals: deps.deals, log: (deal) => record(deal, { sink: deps.log, now: now() }), seats: deps.onPack ?? null }, + }); + return json(out.body, out.status); + } + return json({ error: "no such route" }, 404); + } catch (e) { + return json({ error: String(e?.message ?? e) }, 500); + } + }; +} + +// What the client may see of a deal: the numbers on the table and how much haggling is left. Not the desk's +// internals, and nothing a member wrote. +const dealView = (deal) => ({ + id: deal.id, tier: deal.tier, seats: deal.seats, price: deal.price, opening: deal.opening, + round: deal.round, rounds_left: Math.max(0, MAX_ROUNDS - deal.round), outcome: deal.outcome, +}); + +// The last turns, trimmed: the owner pays for these tokens. +const turns = (thread, keep = 12) => thread.turns.slice(-keep).map(({ role, content }) => ({ role, content })); diff --git a/taskmaster/src/sign.js b/taskmaster/src/sign.js new file mode 100644 index 0000000..b3c1ee9 --- /dev/null +++ b/taskmaster/src/sign.js @@ -0,0 +1,29 @@ +// One signing primitive for the app: the quote card's seal and the Whop webhook's signature check both use it. +// WebCrypto, so the same code runs on Node and on an edge runtime, and nothing here is synchronous by accident. + +const enc = new TextEncoder(); + +export async function hmacHex(secret, data) { + if (!secret) throw new Error("no signing secret"); + const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const mac = await crypto.subtle.sign("HMAC", key, enc.encode(data)); + return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// Constant time in the length the two strings share, and never short-circuits on an early byte. A length mismatch +// is still a mismatch, which leaks only the length of a value the caller already knows. +export function safeEqual(a, b) { + const x = String(a ?? ""), y = String(b ?? ""), n = Math.max(x.length, y.length); + let diff = x.length ^ y.length; + for (let i = 0; i < n; i++) diff |= (x.charCodeAt(i) | 0) ^ (y.charCodeAt(i) | 0); // past the end: NaN | 0 is 0 + return diff === 0; +} + +// Canonical JSON: keys sorted at every level, so a seal computed here matches a seal computed anywhere else over the +// same values. Objects a model has reordered on the way through still verify; a changed value does not. +export function canonical(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value ?? null); + if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]"; + const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort(); + return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(value[k])).join(",") + "}"; +} diff --git a/taskmaster/src/tiers.js b/taskmaster/src/tiers.js new file mode 100644 index 0000000..baee009 --- /dev/null +++ b/taskmaster/src/tiers.js @@ -0,0 +1,68 @@ +// What Taskmaster sells, what each tier opens at, and where its floor is. +// +// The anchors are the prices the old Taskmaster quoted. Only one discount was ever published — a sprint's $100 list +// against its $80 intro, 20% off — so that is the ratio the other individual floors take; the 3-sprint pack keeps +// its own published price ($250 against $300, which is 17% off, not the 15% the old prompt claimed). Seat packs +// price per seat, and the ten-seat pack's floor takes the pack's 17%. +// +// None of these numbers is the answer. They are where the haggle starts: every deal is logged (price, rounds, +// outcome, never the chat), and once there are enough deals the operator sets fixed tiers from that log, the way +// Poke replaced its bouncer with Free / $19 / $199. Change the numbers here, not in the character. +// +// Fees, because they decide whether a floor is worth having: a card charge plus orchestration costs about +// $0.30 + 3.5% of the ticket, which is the 9.5% of $5 and 3.8% of $100 the card quotes. `net()` puts that on every +// logged deal, so a floor is judged on what reaches the owner rather than on the sticker. + +export const CURRENCY = "usd"; +export const FEE_FIXED = 0.3; +export const FEE_RATE = 0.035; + +export const cents = (n) => Math.round(n * 100) / 100; +export const fee = (price) => cents(FEE_FIXED + FEE_RATE * price); +export const net = (price) => cents(price - fee(price)); + +// kind: "one_time" is a Whop plan with expiration_days; "renewal" is a subscription with a billing period. +// per_seat marks a pack: its anchor and floor are per seat, multiplied by `seats`. +const CATALOG = [ + { id: "sprint", label: "1-hour sprint", kind: "one_time", anchor: 100, floor: 80, expiration_days: 14, + includes: ["One hour on one blocker", "A plan you leave with", "Commitments tracked until the next one"], + access: "14 days to book and run it" }, + { id: "lite", label: "30-minute lite sprint", kind: "one_time", anchor: 50, floor: 40, expiration_days: 14, + includes: ["Thirty minutes on one blocker", "One next step, written down"], access: "14 days to book and run it" }, + { id: "pack3", label: "3 sprints", kind: "one_time", anchor: 300, floor: 250, expiration_days: 90, + includes: ["Three 1-hour sprints", "Commitments carried between them"], access: "90 days to use all three" }, + { id: "daily", label: "Ongoing coaching", kind: "renewal", billing_period: 1, anchor: 10, floor: 8, + includes: ["Daily check-in on what you committed to", "Called out when you stall"], access: "Daily, cancel any day" }, + { id: "checkins", label: "Email check-ins", kind: "renewal", billing_period: 1, anchor: 5, floor: 4, + includes: ["One check-in email a day", "A reply that holds you to it"], access: "Daily, cancel any day" }, + { id: "seats5", label: "5 seats", kind: "one_time", seats: 5, per_seat: { anchor: 100, floor: 80 }, expiration_days: 90, + includes: ["Five 1-hour sprints, one per seat", "Seats you assign by Whop username", "Seats and usage, never anyone's chats"], + access: "90 days for the pack" }, + { id: "seats10", label: "10 seats", kind: "one_time", seats: 10, per_seat: { anchor: 100, floor: 72 }, expiration_days: 90, + includes: ["Ten 1-hour sprints, one per seat", "Seats you assign by Whop username", "Seats and usage, never anyone's chats"], + access: "90 days for the pack" }, +]; + +export const TIERS = Object.fromEntries(CATALOG.map((t) => { + const seats = t.seats ?? null; + const anchor = t.per_seat ? cents(t.per_seat.anchor * seats) : t.anchor; + const floor = t.per_seat ? cents(t.per_seat.floor * seats) : t.floor; + return [t.id, { ...t, seats, anchor, floor }]; +})); + +export const IDS = Object.keys(TIERS); +export const PACKS = IDS.filter((id) => TIERS[id].seats); + +export function tier(id) { + const t = TIERS[id]; + if (!t) throw new Error(`no such tier: ${JSON.stringify(id)}`); + return t; +} + +// A Whop variant is reused rather than created per deal, so matching deals share one inline plan. The key is +// everything that makes two deals the same sale: tier, plan shape, price, currency and term. +export function variantKey(id, price) { + const t = tier(id); + const term = t.kind === "renewal" ? `p${t.billing_period}d` : `x${t.expiration_days}d`; + return [t.id, t.kind, cents(price).toFixed(2), CURRENCY, term, t.seats ? `s${t.seats}` : "s1"].join(":"); +} diff --git a/taskmaster/src/voice.js b/taskmaster/src/voice.js new file mode 100644 index 0000000..cc896e0 --- /dev/null +++ b/taskmaster/src/voice.js @@ -0,0 +1,110 @@ +// The voice: Venice, as the old app used, called from a server route with the key from Whop app secrets. +// +// Three things the old app got wrong and this does not: +// +// * It defaulted to `venice-uncensored`. There is no uncensored default here, and `model()` refuses one outright; +// the operator names the model in TASKMASTER_MODEL (Havre's Taskmaster card runs on GLM 5.2). +// * The character sheet was a Venice character, which tied the persona to one provider. The sheet here is plain +// text in a secret, composed by mode.js, so the same sheet runs on another model unchanged. +// * Nothing bounded what a member could spend of the owner's tokens. The owner pays here, so there is a limit +// per member and haggle replies are kept short. +// +// The voice gets exactly one tool, `quote`, and the deal desk executes it. The model cannot name a price any other +// way, and `strip()` takes out the *asterisk narration* the old persona used, which the card asks to drop. + +export const BASE = "https://api.venice.ai/api/v1"; +export const DEFAULT_MODEL = "zai-org/glm-5.2"; +export const HAGGLE_MAX_TOKENS = 180; // a couple of lines: short replies are the persona and the token budget both +export const COACH_MAX_TOKENS = 400; + +export const PER_MEMBER = 40; // model calls per member per window +export const WINDOW_MS = 60 * 60 * 1000; + +// The one tool the haggle prompt may call. The desk decides what the answer is. +export const QUOTE_TOOL = { + type: "function", + function: { + name: "quote", + description: "Put a price on the table. The deal desk accepts, counters or refuses; say its number, not yours.", + parameters: { + type: "object", + additionalProperties: false, + required: ["tier", "price"], + properties: { + tier: { type: "string", description: "the tier being quoted" }, + price: { type: "number", description: "dollars, for the whole tier (a seat pack is priced as a pack)" }, + reason: { type: "string", enum: ["scope", "urgency", "commitment", "pay_now"], + description: "what the member gave that earns a step down; omit when holding the price" }, + note: { type: "string", maxLength: 200, + description: "what they said that earns it, about the work only — never about who they are" }, + }, + }, + }, +}; + +export function model(name) { + const id = String(name ?? DEFAULT_MODEL).trim(); + if (!id) return DEFAULT_MODEL; + if (/uncensored/i.test(id)) throw new Error(`refusing an uncensored model (${id}): name a model in TASKMASTER_MODEL`); + return id; +} + +export function strip(text) { + return String(text ?? "") + .replace(/\*[^*\n]{1,200}\*/g, "") // *leans back in the chair* — gone + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .split("\n").map((l) => l.trimEnd()).join("\n") + .trim(); +} + +// One counter per member per window. `clock` is injectable so the test does not wait an hour. +export function limiter({ per = PER_MEMBER, windowMs = WINDOW_MS, clock = Date.now } = {}) { + const seen = new Map(); + return { + take(member) { + const now = clock(); + const hits = (seen.get(member) ?? []).filter((t) => now - t < windowMs); + if (hits.length >= per) { + seen.set(member, hits); + return { ok: false, left: 0, retry_after_ms: windowMs - (now - hits[0]) }; + } + hits.push(now); + seen.set(member, hits); + return { ok: true, left: per - hits.length }; + }, + }; +} + +export function voice({ apiKey, model: name, base = BASE, fetch: f = globalThis.fetch } = {}) { + const id = model(name); + return { + model: id, + async say({ system, messages, tools = null, max_tokens = HAGGLE_MAX_TOKENS, temperature = 0.7 }) { + if (!apiKey) throw new Error("no Venice API key: set it with `whop apps secrets set`"); + const res = await f(`${base}/chat/completions`, { + method: "POST", + headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ + model: id, max_tokens, temperature, + messages: [{ role: "system", content: system }, ...messages], + ...(tools ? { tools, tool_choice: "auto" } : {}), + }), + }); + const out = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(`venice answered ${res.status}: ${JSON.stringify(out).slice(0, 200)}`); + const choice = out.choices?.[0]?.message ?? {}; + return { + text: strip(choice.content ?? ""), + calls: (choice.tool_calls ?? []).map((c) => ({ + id: c.id, + name: c.function?.name, + args: parse(c.function?.arguments), + })), + usage: out.usage ?? null, + }; + }, + }; +} + +const parse = (s) => { try { return JSON.parse(s ?? "{}"); } catch { return {}; } }; diff --git a/taskmaster/src/webhook.js b/taskmaster/src/webhook.js new file mode 100644 index 0000000..4fbec8b --- /dev/null +++ b/taskmaster/src/webhook.js @@ -0,0 +1,80 @@ +// Whop's webhook: the one thing that can turn a thread from haggle to coach. +// +// The old app's handler logged invoices and did nothing. Here the signature is checked before anything else, the +// deal named in the payment's metadata is the one that moves, and the thread switches mode because Whop said the +// money arrived — not because a member typed "Paid". +// +// Unconfirmed, and in one place so it is one line to correct: the header names and whether the signed string is +// the raw body alone or a timestamp joined to it. Both shapes are verified — a request carrying a timestamp header +// is checked as `${timestamp}.${body}` and refused if the timestamp is outside the tolerance, and one without is +// checked as the body alone. The body is verified as the bytes that arrived; nothing re-serializes JSON first. + +import { hmacHex, safeEqual } from "./sign.js"; +import { paid } from "./dealdesk.js"; + +export const SIG_HEADER = "x-whop-signature"; +export const TS_HEADER = "x-whop-timestamp"; +export const TOLERANCE_S = 300; + +// The events that mean money arrived. Whop's own names for them are the operator's to confirm; an event not on +// this list is acknowledged and ignored rather than guessed at. +export const PAID_EVENTS = ["payment.succeeded", "payment_succeeded", "membership.went_valid", "membership_went_valid"]; + +const header = (headers, name) => + typeof headers?.get === "function" ? headers.get(name) : (headers?.[name] ?? headers?.[name.toLowerCase()] ?? null); + +export async function sign(raw, secret, { timestamp = null } = {}) { + return hmacHex(secret, timestamp == null ? raw : `${timestamp}.${raw}`); +} + +export async function verify(raw, headers, secret, { now, tolerance = TOLERANCE_S } = {}) { + const got = header(headers, SIG_HEADER); + if (!got) return { ok: false, why: "no signature" }; + const ts = header(headers, TS_HEADER); + if (ts != null) { + const t = Number(ts); + if (!Number.isFinite(t)) return { ok: false, why: "bad timestamp" }; + const age = Math.abs((now ?? Date.now()) / 1000 - t); + if (age > tolerance) return { ok: false, why: `timestamp is ${Math.round(age)}s old` }; + } + const want = await sign(raw, secret, { timestamp: ts }); + return safeEqual(got, want) ? { ok: true } : { ok: false, why: "signature does not match" }; +} + +// deps: { deals: { get(id), save(deal) }, log: (deal) => Promise, seats?: (membership, tier) => Promise } +// Returns what the server should answer with, and never throws on a bad signature: it answers 401 and writes +// nothing, so a forged payment cannot move a thread. +export async function handle(raw, headers, { secret, now, deps }) { + const checked = await verify(raw, headers, secret, { now }); + if (!checked.ok) return { status: 401, body: { error: checked.why } }; + + let event; + try { event = JSON.parse(raw); } catch { return { status: 400, body: { error: "body is not JSON" } }; } + + const kind = event?.action ?? event?.event ?? event?.type ?? null; + if (!PAID_EVENTS.includes(kind)) return { status: 200, body: { ok: true, ignored: kind } }; + + const data = event.data ?? event; + const meta = data.metadata ?? data.checkout_metadata ?? {}; + if (!meta.deal) return { status: 200, body: { ok: true, ignored: "no deal in metadata" } }; + + const deal = await deps.deals.get(meta.deal); + if (!deal) return { status: 200, body: { ok: true, ignored: `unknown deal ${meta.deal}` } }; + if (deal.outcome === "paid") return { status: 200, body: { ok: true, already: deal.id } }; + if (deal.outcome !== "agreed") return { status: 200, body: { ok: true, ignored: `deal ${deal.id} is ${deal.outcome}` } }; + + // The price is the desk's, not the payment's: a payment for less than was agreed is a mismatch to look at, never + // a cheaper deal that quietly closes. + const amount = Number(data.final_amount ?? data.amount ?? meta.price); + if (Number.isFinite(amount) && Math.abs(amount - deal.price) > 0.01) { + return { status: 200, body: { ok: true, mismatch: { deal: deal.id, agreed: deal.price, paid: amount } } }; + } + + paid(deal, { now, receipt: data.id ?? data.membership_id ?? null }); + deal.membership = data.membership_id ?? data.membership ?? null; + await deps.deals.save(deal); + await deps.log(deal); + if (deal.seats && deal.membership && deps.seats) await deps.seats(deal.membership, deal.tier); + + return { status: 200, body: { ok: true, deal: deal.id, mode: "coach" } }; +} diff --git a/taskmaster/src/whop.js b/taskmaster/src/whop.js new file mode 100644 index 0000000..fdf7804 --- /dev/null +++ b/taskmaster/src/whop.js @@ -0,0 +1,117 @@ +// The Whop client, and the only place in the app that knows Whop's shapes. +// +// Nothing in card #66 touches Whop: no checkout configuration, no variant, no deploy, no secret, no base_url. This +// module is therefore written and tested against an injected `fetch` and never called with a real key. The guard +// below is what keeps it that way — `live: true` has to be passed in deliberately, and without it every call that +// would write to Whop throws instead. +// +// What the operator still has to confirm before this runs for real (docs.whop.com is not reachable from the build +// container, so these are the card's own open questions, not guesses dressed up as facts): +// +// * `plan.billing_period: 1` for the daily tiers. Whop's membership API mentions daily billing, so it looks +// supported; `checkout()` refuses a renewal tier until `dailyBilling: "confirmed"` is passed, so a daily tier +// cannot be created on an assumption. +// * How a plan is kept off the product's public page. The old app's note calls it a hidden inline variant; +// `HIDDEN` holds the field this sends, in one place, so correcting it is one line. +// * iOS. Whop's iOS rules may treat a one-off checkout differently from a subscription, which would change what +// these tiers may be priced at on a phone. `checkout()` passes `platform` through untouched for now. +// +// Confirmed from the API reference: POST /checkout_configurations takes an inline `plan` object (`plan_type` is +// `one_time` or `renewal`, with `initial_price`), plus `affiliate_code`, `metadata` and `redirect_url`. + +import { tier, variantKey, CURRENCY, cents } from "./tiers.js"; + +export const BASE = "https://api.whop.com/api/v1"; +export const PRODUCT = "prod_EZ7UkE4pkW04U"; +export const APP = "app_M7BBH1Evw8sjL4"; + +// Unconfirmed: the field that keeps an inline plan off the product's public page. +export const HIDDEN = { visibility: "hidden" }; + +export class WhopError extends Error { + constructor(status, body) { + super(`whop answered ${status}: ${typeof body === "string" ? body.slice(0, 200) : JSON.stringify(body).slice(0, 200)}`); + this.status = status; + this.body = body; + } +} + +// store: { get(key), set(key, value) }. Used for variant reuse, so matching deals share one inline plan instead of +// leaving a trail of one-off variants on the product. +export function memoryStore() { + const m = new Map(); + return { get: async (k) => m.get(k) ?? null, set: async (k, v) => void m.set(k, v) }; +} + +export function whop({ apiKey, fetch: f = globalThis.fetch, base = BASE, product = PRODUCT, store = memoryStore(), + live = false, dailyBilling = "unconfirmed" } = {}) { + const call = async (method, path, body) => { + if (!apiKey) throw new Error("no Whop API key"); + const res = await f(base + path, { + method, + headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const out = await res.json().catch(() => ({})); + if (!res.ok) throw new WhopError(res.status, out); + return out; + }; + + const writing = (what) => { + if (!live) throw new Error(`refusing to ${what} on Whop: pass live: true once the operator has approved the previews`); + }; + + return { + product, + app: APP, + + // One checkout per (tier, price): the variant is reused when a later deal lands on the same number. + async checkout({ tier: id, price, member, thread, deal, affiliate_code = null, redirect_url = null, platform = null }) { + const t = tier(id); + if (t.kind === "renewal" && dailyBilling !== "confirmed") { + throw new Error(`${t.id} bills every ${t.billing_period} day(s); confirm daily billing with Whop, then pass dailyBilling: "confirmed"`); + } + const key = variantKey(t.id, price); + const metadata = { app: APP, tier: t.id, deal, member, thread, seats: t.seats, price: cents(price), variant: key }; + + const known = await store.get(key); + if (known) return { ...known, reused: true, metadata }; + + writing("create a checkout configuration"); + const plan = { + product_id: product, + plan_type: t.kind, + initial_price: cents(price), + base_currency: CURRENCY, + ...HIDDEN, + ...(t.kind === "renewal" ? { billing_period: t.billing_period } : { expiration_days: t.expiration_days }), + }; + const made = await call("POST", "/checkout_configurations", { + plan, metadata, + ...(affiliate_code ? { affiliate_code } : {}), // a creator's commission, paid by Whop; no reseller cut here + ...(redirect_url ? { redirect_url } : {}), + ...(platform ? { platform } : {}), + }); + const row = { id: made.id ?? null, url: made.purchase_url ?? made.url ?? null, variant: key, plan: made.plan?.id ?? null }; + await store.set(key, row); + return { ...row, reused: false, metadata }; + }, + + async membership(id) { + return call("GET", `/memberships/${encodeURIComponent(id)}`); + }, + + // Every membership this user holds on Taskmaster's product. The server asks this, never the member. + async membershipsFor(user) { + const out = await call("GET", `/memberships?user_id=${encodeURIComponent(user)}&product_id=${encodeURIComponent(product)}`); + return out.data ?? out.memberships ?? []; + }, + + // Merged, not replaced: the seat list and the swap history live here and a write that replaced the object would + // drop whatever Whop or another part of the app keeps beside them. + async mergeMembershipMetadata(id, patch) { + writing("write membership metadata"); + return call("POST", `/memberships/${encodeURIComponent(id)}`, { metadata: patch }); + }, + }; +} diff --git a/taskmaster/test/cards.test.js b/taskmaster/test/cards.test.js new file mode 100644 index 0000000..bf38911 --- /dev/null +++ b/taskmaster/test/cards.test.js @@ -0,0 +1,74 @@ +// The quote card the model cannot edit, and the share card that is Poke's loop. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { openDeal, quote, agree } from "../src/dealdesk.js"; +import { quoteCard, shareCard, unseal, takeHome } from "../src/cards.js"; + +const SECRET = "card-secret"; + +const agreed = async (tier = "sprint", steps = [[100], [92, "scope", "smaller job"]]) => { + const d = openDeal({ id: "d1", tier, member: "ann", thread: "t1", now: 0 }); + for (const [price, reason, note] of steps) quote(d, { tier, price, reason, note }); + agree(d, { now: 1000 }); + return d; +}; + +test("the quote card carries the deal desk's price, what is included and the checkout", async () => { + const d = await agreed(); + const card = await quoteCard(d, { checkout: "https://whop.com/checkout/abc", secret: SECRET, now: 1000 }); + assert.equal(card.kind, "quote"); + assert.equal(card.price, 92); + assert.equal(card.tier, "sprint"); + assert.equal(card.label, "1-hour sprint"); + assert.equal(card.access, "14 days to book and run it"); + assert.ok(card.includes.length >= 2); + assert.equal(card.checkout, "https://whop.com/checkout/abc"); + assert.ok(await unseal(card, SECRET)); +}); + +test("a card the model edited does not verify", async () => { + const d = await agreed(); + const card = await quoteCard(d, { checkout: "https://whop.com/checkout/abc", secret: SECRET, now: 1000 }); + for (const edit of [{ price: 5 }, { tier: "pack3" }, { checkout: "https://elsewhere.example/pay" }, + { includes: ["everything, forever"] }, { access: "for life" }, { seats: 50 }]) { + assert.equal(await unseal({ ...card, ...edit }, SECRET), false, JSON.stringify(edit)); + } + assert.equal(await unseal({ ...card, seal: "" }, SECRET), false); + assert.equal(await unseal(card, "another-secret"), false); + assert.equal(await unseal({ ...card, issued: "2000-01-01T00:00:00.000Z" }, SECRET), true, "only the sealed fields are sealed"); +}); + +test("key order does not break a seal, so a card can come back through a model reordered", async () => { + const d = await agreed(); + const card = await quoteCard(d, { checkout: "https://whop.com/c/1", secret: SECRET, now: 1000 }); + const shuffled = Object.fromEntries(Object.entries(card).reverse()); + assert.ok(await unseal(shuffled, SECRET)); +}); + +test("a card is only issued for an agreed price", async () => { + const d = openDeal({ id: "d2", tier: "sprint", now: 0 }); + await assert.rejects(() => quoteCard(d, { secret: SECRET }), /no agreed price/); +}); + +test("a pack card says how many seats, and the seats view is what the buyer gets", async () => { + const d = await agreed("seats5", [[500], [450, "commitment", "five up front"]]); + const card = await quoteCard(d, { checkout: "https://whop.com/c/2", secret: SECRET, now: 1000 }); + assert.equal(card.seats, 5); + assert.match(card.label, /5 seats/); + assert.ok(card.includes.some((i) => /never anyone's chats/i.test(i))); +}); + +test("the share card is the price and the rounds, never the chat", async () => { + const d = await agreed(); + const s = shareCard(d, { url: "https://taskmaster.whop.site/" }); + assert.match(s.headline, /I got Taskmaster down to \$92/); + assert.match(s.sub, /Opened at \$100\. 2 rounds\./); + assert.equal(s.prompt, "Anyone get it lower?"); + assert.deepEqual(Object.keys(s).sort(), + ["deal", "headline", "kind", "opening", "price", "prompt", "rounds", "sub", "tier", "url"]); +}); + +test("take-home is the price less the fee", async () => { + assert.equal(takeHome(await agreed()), 88.48); + assert.equal(takeHome(openDeal({ tier: "sprint", now: 0 })), null); +}); diff --git a/taskmaster/test/dealdesk.test.js b/taskmaster/test/dealdesk.test.js new file mode 100644 index 0000000..a462f5e --- /dev/null +++ b/taskmaster/test/dealdesk.test.js @@ -0,0 +1,218 @@ +// The deal desk's four rules, and the two refusals that are about honesty rather than money. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { openDeal, quote, agree, walkaway, paid, MAX_ROUNDS, REASONS } from "../src/dealdesk.js"; +import { TIERS, tier, fee, net, variantKey } from "../src/tiers.js"; + +const sprint = () => openDeal({ id: "d1", tier: "sprint", member: "ann", thread: "t1", now: 0 }); + +test("the tier catalog carries the old prices, and the pack's discount is 17%", () => { + assert.equal(TIERS.sprint.anchor, 100); + assert.equal(TIERS.sprint.floor, 80); // the published intro price is the floor + assert.equal(TIERS.lite.anchor, 50); + assert.equal(TIERS.daily.anchor, 10); + assert.equal(TIERS.checkins.anchor, 5); + assert.equal(TIERS.pack3.floor, 250); + assert.equal(Math.round((1 - 250 / 300) * 100), 17, "3 for 250 saves 17% against 300, not 15%"); + assert.equal(TIERS.seats5.anchor, 500); // 5 seats x the per-seat anchor + assert.equal(TIERS.seats10.floor, 720); // 10 seats x a per-seat floor that takes the pack's 17% + assert.equal(TIERS.daily.kind, "renewal"); + assert.equal(TIERS.daily.billing_period, 1); + assert.equal(TIERS.sprint.kind, "one_time"); + assert.ok(TIERS.sprint.expiration_days > 0); +}); + +test("fees are about 9.5% of $5 and 3.8% of $100, the figures the card quotes", () => { + assert.ok(Math.abs(fee(5) / 5 - 0.095) < 0.002, fee(5)); + assert.ok(Math.abs(fee(100) / 100 - 0.038) < 0.002, fee(100)); + assert.equal(net(100), 96.2, "a $100 sprint arrives as $96.20"); + assert.equal(net(5), 4.52, "a $5 day arrives as $4.52: small tickets lose three times the share"); +}); + +test("matching deals share a variant key, different prices do not", () => { + assert.equal(variantKey("sprint", 80), variantKey("sprint", 80.0)); + assert.notEqual(variantKey("sprint", 80), variantKey("sprint", 90)); + assert.notEqual(variantKey("sprint", 80), variantKey("lite", 80)); +}); + +test("the opening is the anchor, and the desk never quotes above it or walks a price back up", () => { + const d = sprint(); + assert.equal(d.anchor, 100); + const open = quote(d, { tier: "sprint", price: 100 }); + assert.equal(open.verdict, "accept"); + assert.equal(d.price, 100); + assert.equal(d.opening, 100); + + const up = quote(d, { tier: "sprint", price: 120 }); + assert.equal(up.verdict, "refuse"); + assert.equal(up.rule, "above_anchor"); + assert.equal(d.price, 100, "a refusal changes nothing"); + + const back = quote(d, { tier: "sprint", price: 95, reason: "scope", note: "they want more now" }); + assert.equal(back.verdict, "accept", "95 is a step down of 5, inside what scope earns"); + const walkUp = quote(d, { tier: "sprint", price: 100 }); + assert.equal(walkUp.verdict, "counter"); + assert.equal(walkUp.rule, "no_walk_up"); + assert.equal(walkUp.price, 95); +}); + +test("no step down for nothing", () => { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + const free = quote(d, { tier: "sprint", price: 85 }); + assert.equal(free.verdict, "counter"); + assert.equal(free.rule, "no_reason"); + assert.equal(free.price, 100, "the standing price stands"); + + const unknown = quote(d, { tier: "sprint", price: 85, reason: "they seem nice" }); + assert.equal(unknown.verdict, "counter"); + assert.equal(unknown.rule, "no_reason"); +}); + +test("a step is capped at what its reason earns, and each reason is spendable once", () => { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + const over = quote(d, { tier: "sprint", price: 85, reason: "scope", note: "dropping the write-up" }); + assert.equal(over.verdict, "counter"); + assert.equal(over.rule, "over_earned"); + assert.equal(over.price, 90, `scope earns ${REASONS.scope * 100}% of 100`); + assert.equal(d.price, 90, "the desk's counter is the new standing price"); + + const again = quote(d, { tier: "sprint", price: 85, reason: "scope", note: "cutting more" }); + assert.equal(again.verdict, "counter"); + assert.equal(again.rule, "reason_spent", "the counter cashed scope in"); + assert.equal(again.price, 90); +}); + +test("the floor is the real stop, whatever reason is given", () => { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + const under = quote(d, { tier: "sprint", price: 10, reason: "commitment", note: "they will take three" }); + assert.equal(under.verdict, "counter"); + assert.equal(under.rule, "floor"); + assert.equal(under.price, 80); + const again = quote(d, { tier: "sprint", price: 79, reason: "pay_now", note: "card out now" }); + assert.equal(again.rule, "floor"); + assert.equal(again.price, 80); + assert.ok(again.price >= TIERS.sprint.floor); +}); + +test("every reason together still cannot reach under the floor", () => { + const total = Object.values(REASONS).reduce((a, b) => a + b, 0); + for (const id of Object.keys(TIERS)) { + const t = tier(id); + assert.ok(t.anchor * (1 - total) < t.floor, `${id}: the floor, not the concessions, has to be the stop`); + } +}); + +test("three rounds, then the standing price is the price", () => { + const d = sprint(); + assert.equal(quote(d, { tier: "sprint", price: 100 }).round, 1); + assert.equal(quote(d, { tier: "sprint", price: 90, reason: "scope", note: "smaller job" }).round, 2); + const third = quote(d, { tier: "sprint", price: 85, reason: "pay_now", note: "paying today" }); + assert.equal(third.round, MAX_ROUNDS); + assert.equal(third.rounds_left, 0); + assert.ok(d.exhausted); + + const fourth = quote(d, { tier: "sprint", price: 82, reason: "urgency", note: "needs it tonight" }); + assert.equal(fourth.verdict, "refuse"); + assert.equal(fourth.rule, "rounds"); + assert.equal(d.price, 85, "the last price is untouched"); + + const takeIt = quote(d, { tier: "sprint", price: 85 }); + assert.equal(takeIt.verdict, "accept", "they can still take what is on the table"); + assert.equal(d.round, MAX_ROUNDS, "and that does not spend a fourth round"); +}); + +test("price on what they tell you, never on who they are", () => { + for (const note of ["they have 40k followers so they can afford it", "their profile says they work at a bank", + "big account, charge the list", "their country is rich"]) { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + const out = quote(d, { tier: "sprint", price: 95, reason: "scope", note }); + assert.equal(out.verdict, "refuse", note); + assert.equal(out.rule, "who_they_are"); + assert.equal(d.round, 1, "an honesty refusal does not spend a round"); + assert.equal(d.price, 100); + } +}); + +test("no invented scarcity and no fake deadlines", () => { + for (const note of ["last spot at this price", "today only", "the price goes up at midnight"]) { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + const out = quote(d, { tier: "sprint", price: 90, reason: "urgency", note }); + assert.equal(out.verdict, "refuse", note); + assert.equal(out.rule, "scarcity"); + assert.equal(d.round, 1); + } +}); + +test("the member's words never reach the deal", () => { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + quote(d, { tier: "sprint", price: 92, reason: "scope", note: "I only need the API bit, not the whole launch" }); + const dump = JSON.stringify(d); + assert.ok(!dump.includes("API bit"), dump); + assert.deepEqual(d.quotes.map((q) => q.reason), [null, "scope"]); +}); + +test("a tier switch resets the anchor, the floor and the concessions", () => { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + quote(d, { tier: "sprint", price: 90, reason: "scope", note: "smaller" }); + const sw = quote(d, { tier: "lite", price: 50 }); + assert.equal(sw.verdict, "accept"); + assert.equal(d.tier, "lite"); + assert.equal(d.anchor, 50); + assert.equal(d.floor, 40); + assert.deepEqual(d.spent, [], "a switch is not a way around the floor"); + assert.equal(quote(d, { tier: "lite", price: 60 }).rule, "above_anchor"); +}); + +test("a seat pack is priced as a pack, inside a per-seat floor", () => { + const d = openDeal({ id: "p1", tier: "seats5", member: "lead", thread: "t2", now: 0 }); + assert.equal(d.seats, 5); + quote(d, { tier: "seats5", price: 500 }); + const under = quote(d, { tier: "seats5", price: 300, reason: "commitment", note: "five seats up front" }); + assert.equal(under.verdict, "counter"); + assert.equal(under.rule, "floor"); + assert.equal(under.price, 400, "80 a seat is the floor"); + const more = quote(d, { tier: "seats5", price: 380, reason: "pay_now", note: "paying today" }); + assert.equal(more.rule, "floor", "once the desk is at the floor, another reason just gets the floor back"); + assert.equal(more.price, 400); +}); + +test("a modest opening still stops at the same floor", () => { + const d = openDeal({ id: "d2", tier: "sprint", opening: 90, now: 0 }); + assert.equal(d.anchor, 90); + assert.equal(d.list, 100); + assert.equal(d.floor, 80); + assert.equal(quote(d, { tier: "sprint", price: 95 }).rule, "above_anchor"); + quote(d, { tier: "sprint", price: 90 }); + const step = quote(d, { tier: "sprint", price: 80, reason: "scope", note: "less work" }); + assert.equal(step.rule, "over_earned"); + assert.equal(step.price, 81, "a modest anchor earns smaller steps: 10% of 90, not of 100"); + assert.equal(quote(d, { tier: "sprint", price: 70, reason: "urgency", note: "needs it today" }).rule, "floor"); + assert.equal(openDeal({ tier: "sprint", opening: 5, now: 0 }).anchor, 80, "an arm cannot open under the floor"); + assert.equal(openDeal({ tier: "sprint", opening: 400, now: 0 }).anchor, 100, "nor above the list price"); +}); + +test("agree, walk away and get paid", () => { + const d = sprint(); + quote(d, { tier: "sprint", price: 100 }); + assert.throws(() => paid(d, { now: 1 }), /not agreed/); + agree(d, { now: 1 }); + assert.equal(d.outcome, "agreed"); + assert.equal(quote(d, { tier: "sprint", price: 90 }).rule, "closed", "an agreed deal takes no more quotes"); + paid(d, { now: 2, receipt: "pay_1" }); + assert.equal(d.outcome, "paid"); + assert.equal(d.receipt, "pay_1"); + assert.throws(() => walkaway(d, { now: 3 }), /was paid/); + + const gone = sprint(); + quote(gone, { tier: "sprint", price: 100 }); + walkaway(gone, { now: 5 }); + assert.equal(gone.outcome, "walkaway"); + assert.equal(quote(gone, { tier: "sprint", price: 100 }).rule, "closed"); +}); diff --git a/taskmaster/test/deallog.test.js b/taskmaster/test/deallog.test.js new file mode 100644 index 0000000..98d0bab --- /dev/null +++ b/taskmaster/test/deallog.test.js @@ -0,0 +1,86 @@ +// Every deal logged, no chat text in it, and the log read back as price discovery. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { openDeal, quote, agree, paid, walkaway } from "../src/dealdesk.js"; +import { line, validate, record, memorySink, summary, KEYS } from "../src/deallog.js"; + +const NOW = Date.parse("2026-10-20T12:00:00Z"); + +function haggled({ tier = "sprint", steps, outcome }) { + const d = openDeal({ id: `d_${outcome}_${tier}`, tier, member: "ann", thread: "t1", now: NOW }); + for (const [price, reason, note] of steps) quote(d, { tier, price, reason, note }); + if (outcome === "walkaway") walkaway(d, { now: NOW + 1000 }); + else { agree(d, { now: NOW + 1000 }); if (outcome === "paid") paid(d, { now: NOW + 2000, receipt: "pay_1" }); } + return d; +} + +test("a logged deal is the opening, the counters, the final price, the rounds and the outcome", () => { + const d = haggled({ steps: [[100], [92, "scope", "only the API part, not the whole launch"]], outcome: "paid" }); + const row = validate(line(d, { now: NOW + 3000 })); + assert.equal(row.tier, "sprint"); + assert.equal(row.list, 100); + assert.equal(row.anchor, 100); + assert.equal(row.floor, 80); + assert.equal(row.opening, 100); + assert.deepEqual(row.counters, [100, 92]); + assert.deepEqual(row.reasons, ["scope"]); + assert.equal(row.final, 92); + assert.equal(row.rounds, 2); + assert.equal(row.outcome, "paid"); + assert.equal(row.net, 88.48); + assert.equal(row.member, "ann"); +}); + +test("no chat text, by construction and by validation", () => { + const d = haggled({ steps: [[100], [92, "scope", "only the API part, not the whole launch"]], outcome: "paid" }); + const row = line(d, { now: NOW }); + assert.ok(!JSON.stringify(row).includes("API part")); + assert.deepEqual(KEYS.filter((k) => /note|text|chat|message|said|reply/.test(k)), []); + assert.throws(() => validate({ ...row, note: "I keep missing my own deadlines" }), /not a deal-log field/); + assert.throws(() => validate({ ...row, member: "ann said: I am broke" }), /member must be an id/); + assert.throws(() => validate({ ...row, reasons: ["they seemed keen"] }), /earned-reason tags/); + assert.throws(() => validate({ ...row, outcome: "sort of paid" }), /outcome must be one of/); + assert.throws(() => validate({ ...row, rounds: 9 }), /rounds must be 0\.\.3/); + assert.throws(() => validate({ ...row, ts: "last Tuesday" }), /ts must be an ISO date/); + assert.throws(() => validate({ ...row, final: "ninety-two" }), /final must be a number/); +}); + +test("a walkaway is logged too: it is the half of price discovery a payment cannot show", async () => { + const sink = memorySink(); + const gone = haggled({ steps: [[100], [90, "scope", "smaller"]], outcome: "walkaway" }); + const row = await record(gone, { sink, now: NOW + 5000 }); + assert.equal(row.outcome, "walkaway"); + assert.equal(row.final, 90, "the price they would not take is the useful number"); + assert.equal(sink.rows.length, 1); +}); + +test("the log reads back as prices per tier, which is what replaces the haggle later", async () => { + const sink = memorySink(); + await record(haggled({ steps: [[100], [92, "scope", "x"]], outcome: "paid" }), { sink, now: NOW }); + await record(haggled({ steps: [[100], [90, "commitment", "x"]], outcome: "paid" }), { sink, now: NOW }); + await record(haggled({ steps: [[100], [85, "pay_now", "x"]], outcome: "walkaway" }), { sink, now: NOW }); + await record(haggled({ tier: "lite", steps: [[50]], outcome: "paid" }), { sink, now: NOW }); + + const out = summary(sink.rows); + const sprint = out.find((t) => t.tier === "sprint"); + assert.equal(sprint.deals, 3); + assert.equal(sprint.paid, 2); + assert.equal(sprint.walkaways, 1); + assert.equal(sprint.low, 90); + assert.equal(sprint.high, 92); + assert.equal(sprint.median_price, 90); + assert.equal(sprint.median_rounds, 2); + assert.equal(sprint.net, 175.03); + assert.equal(out.find((t) => t.tier === "lite").median_price, 50); +}); + +test("the default sink appends one JSON line per deal", async () => { + const written = []; + const { fileSink } = await import("../src/deallog.js"); + const sink = fileSink("deals.jsonl", { fs: { appendFile: async (p, s) => written.push([p, s]) } }); + await record(haggled({ steps: [[100]], outcome: "paid" }), { sink, now: NOW }); + assert.equal(written.length, 1); + assert.equal(written[0][0], "deals.jsonl"); + assert.ok(written[0][1].endsWith("\n")); + assert.equal(JSON.parse(written[0][1]).final, 100); +}); diff --git a/taskmaster/test/flow.test.js b/taskmaster/test/flow.test.js new file mode 100644 index 0000000..0211ae1 --- /dev/null +++ b/taskmaster/test/flow.test.js @@ -0,0 +1,221 @@ +// The whole thing, end to end, against a fake Venice and a fake Whop: greet, haggle, quote card, a simulated +// signed payment, then coach mode. Nothing is bought and nothing is created on Whop. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { app } from "../src/server.js"; +import { sign, SIG_HEADER, TS_HEADER } from "../src/webhook.js"; +import { memorySink } from "../src/deallog.js"; +import { PRODUCT } from "../src/mode.js"; +import { QUOTE_TOOL } from "../src/voice.js"; +import { unseal } from "../src/cards.js"; + +const NOW = Date.parse("2026-10-20T12:00:00Z"); +const ENV = { TASKMASTER_CARD_SECRET: "card-secret", WHOP_WEBHOOK_SECRET: "whsec_test", + TASKMASTER_SHEET: "You are Taskmaster. [sheet]", TASKMASTER_SHARE_URL: "https://taskmaster.whop.site/" }; + +// A fake Venice that plays a script: each entry is either a line, or a line plus the quote tool call the character +// would have made. It also records what prompt it was sent, which is how the mode tests below read the mode. +function script(turns) { + const seen = []; + const voice = { + async say({ system, messages, tools, max_tokens }) { + seen.push({ system, messages, tools, max_tokens }); + const next = turns.shift() ?? { text: "..." }; + return { text: next.text ?? "", calls: next.quote ? [{ id: `c${seen.length}`, name: "quote", args: next.quote }] : [], usage: null }; + }, + }; + voice.seen = seen; + return voice; +} + +// `arm` pins the anchor-height experiment, so these tests read one mechanic rather than whichever arm the member's +// id happens to hash into. +function world({ turns = [], memberships = [], now = NOW, arm = "list" } = {}) { + const deals = new Map(), threads = new Map(), writes = []; + const sink = memorySink(); + const whop = { + membershipsFor: async () => memberships, + membership: async (id) => memberships.find((m) => m.id === id) ?? null, + checkout: async ({ tier, price, deal }) => ({ id: `cfg_${tier}_${price}`, url: `https://whop.com/checkout/cfg_${tier}_${price}`, + variant: `${tier}:${price}`, reused: false, metadata: { deal } }), + mergeMembershipMetadata: async (id, patch) => { writes.push([id, patch]); return { id }; }, + }; + const voice = script(turns); + const fetch = app(ENV, { + voice, whop, clock: () => now, log: sink, experiments: { arm: async () => arm }, + deals: { get: async (id) => deals.get(id) ?? null, save: async (d) => void deals.set(d.id, d) }, + threads: { get: async (id) => threads.get(id) ?? null, save: async (t) => void threads.set(t.id, t) }, + }); + return { fetch, deals, threads, writes, sink, voice, memberships }; +} + +const post = (fetch, path, body) => fetch(new Request(`https://taskmaster.whop.site${path}`, + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })); +const get = (fetch, path) => fetch(new Request(`https://taskmaster.whop.site${path}`)); + +test("greet, haggle, quote card, simulated payment, coach mode", async () => { + const w = world({ + turns: [ + { text: "So you are stuck on a launch you keep moving." }, // greet + { text: "", quote: { tier: "sprint", price: 100 } }, // opens at the anchor + { text: "A hundred. One hour, one blocker." }, + { text: "", quote: { tier: "sprint", price: 92, reason: "scope", note: "only the API part" } }, // earned step + { text: "Ninety-two, since you cut the write-up." }, + ], + }); + w.threads.set("t1", { id: "t1", member: "user_ann", deal: null, turns: [] }); + + const greet = await (await post(w.fetch, "/api/chat", { thread: "t1", text: "hi" })).json(); + assert.equal(greet.mode, "haggle"); + assert.equal(greet.reply, "So you are stuck on a launch you keep moving."); + assert.deepEqual(greet.cards, []); + assert.deepEqual(w.voice.seen[0].tools, [QUOTE_TOOL], "the haggle prompt gets exactly one tool"); + assert.ok(w.voice.seen[0].system.startsWith(ENV.TASKMASTER_SHEET)); + + const open = await (await post(w.fetch, "/api/chat", { thread: "t1", text: "how much?" })).json(); + assert.equal(open.verdict, "accept"); + assert.equal(open.deal.price, 100); + assert.equal(open.deal.round, 1); + assert.equal(open.cards.length, 1, "an accepted price is a card straight away"); + + const deal = [...w.deals.values()][0]; + assert.equal(deal.outcome, "agreed"); + + // the desk's verdict is handed back to the character, so it says the desk's number and not its own + const handback = w.voice.seen[2].messages.at(-1).content; + assert.match(handback, /\[deal desk\] accept/); + + const card = open.cards[0]; + assert.equal(card.price, 100); + assert.equal(card.checkout, "https://whop.com/checkout/cfg_sprint_100"); + assert.ok(await unseal(card, ENV.TASKMASTER_CARD_SECRET)); + + // the member presses the button; a card that came back edited does not buy anything + const taken = await (await post(w.fetch, "/api/agree", { thread: "t1", card })).json(); + assert.equal(taken.checkout, card.checkout); + const forged = await post(w.fetch, "/api/agree", { thread: "t1", card: { ...card, price: 5 } }); + assert.equal(forged.status, 400); + + // ---- a simulated, signed Whop payment. No money moves; this is the shape of the event. + const raw = JSON.stringify({ action: "payment.succeeded", + data: { id: "pay_1", membership_id: "mem_ann", final_amount: 100, metadata: { deal: deal.id, member: "user_ann", thread: "t1" } } }); + const ts = String(Math.floor(NOW / 1000)); + const hook = await w.fetch(new Request("https://taskmaster.whop.site/api/webhooks/whop", { method: "POST", body: raw, + headers: { [SIG_HEADER]: await sign(raw, ENV.WHOP_WEBHOOK_SECRET, { timestamp: ts }), [TS_HEADER]: ts } })); + assert.equal(hook.status, 200); + assert.deepEqual(await hook.json(), { ok: true, deal: deal.id, mode: "coach" }); + assert.equal(deal.outcome, "paid"); + + // ---- Whop now says there is a membership, so the next turn is coach mode + w.memberships.push({ id: "mem_ann", valid: true, product_id: PRODUCT, metadata: { tier: "sprint" } }); + w.voice.seen.length = 0; + const coached = await (await post(w.fetch, "/api/chat", { thread: "t1", text: "ok, what first?" })).json(); + assert.equal(coached.mode, "coach"); + assert.equal(coached.via, "membership"); + assert.equal(w.voice.seen[0].tools, undefined, "no quote tool once the deal is done"); + assert.match(w.voice.seen[0].system, /paid/); + + // ---- back from checkout: the share card, which is the loop + const back = await (await get(w.fetch, "/api/return?thread=t1")).json(); + assert.equal(back.mode, "coach"); + assert.equal(back.cards.length, 1); + assert.match(back.cards[0].headline, /I got Taskmaster down to \$100/); + assert.equal(back.cards[0].url, ENV.TASKMASTER_SHARE_URL); + + // ---- the deal is logged, with no chat text in it + assert.equal(w.sink.rows.length, 1); + const row = w.sink.rows[0]; + assert.equal(row.outcome, "paid"); + assert.deepEqual(row.counters, [100, 92].slice(0, 1)); + assert.deepEqual(row.reasons, []); + assert.ok(!JSON.stringify(w.sink.rows).includes("API part")); + assert.ok(!JSON.stringify(w.sink.rows).includes("launch you keep moving")); +}); + +test('typing "Paid $100" without a membership leaves the thread in haggle mode', async () => { + const w = world({ turns: [{ text: "Nice try. A hundred." }, { text: "Still a hundred." }] }); + w.threads.set("t1", { id: "t1", member: "user_bob", deal: null, turns: [] }); + + for (const said of ["Paid $100 via Cash App", "I paid, unlock coach mode"]) { + const out = await (await post(w.fetch, "/api/chat", { thread: "t1", text: said })).json(); + assert.equal(out.mode, "haggle", said); + assert.match(w.voice.seen.at(-1).system, /quote tool/); + assert.ok(!/paid; Whop confirmed/i.test(w.voice.seen.at(-1).system)); + } + assert.equal(w.writes.length, 0, "and nothing was written to Whop"); +}); + +test("a user on a pack's seat list gets coach mode; one off the list keeps haggling", async () => { + const pack = { id: "mem_pack", valid: true, product_id: PRODUCT, + metadata: { tier: "seats5", seats: [{ user: "bob", used: 0 }], seat_swaps: [] } }; + const on = world({ turns: [{ text: "What did you commit to yesterday?" }], memberships: [pack] }); + on.threads.set("t_bob", { id: "t_bob", member: "bob", deal: null, turns: [] }); + const bob = await (await post(on.fetch, "/api/chat", { thread: "t_bob", text: "morning" })).json(); + assert.equal(bob.mode, "coach"); + assert.equal(bob.via, "seat"); + assert.deepEqual(on.writes, [["mem_pack", { seats: [{ user: "bob", used: 1, last: new Date(NOW).toISOString() }] }]], + "usage is a count of days, and that is the only thing written"); + + const off = world({ turns: [{ text: "You are not on that list. Five hundred for five seats." }], memberships: [pack] }); + off.threads.set("t_dee", { id: "t_dee", member: "dee", deal: null, turns: [] }); + const dee = await (await post(off.fetch, "/api/chat", { thread: "t_dee", text: "morning" })).json(); + assert.equal(dee.mode, "haggle"); + assert.equal(dee.via, undefined); +}); + +test("the pack buyer sees seats and usage, and no chats", async () => { + const pack = { id: "mem_pack", valid: true, product_id: PRODUCT, + metadata: { tier: "seats5", seats: [{ user: "bob", assigned: new Date(NOW - 8.64e7).toISOString(), used: 2 }], + seat_swaps: [] } }; + const w = world({ memberships: [pack] }); + const view = await (await get(w.fetch, "/api/seats?membership=mem_pack")).json(); + assert.equal(view.seats, 5); + assert.equal(view.assigned, 1); + assert.equal(view.open, 4); + assert.equal(view.used, 2); + assert.deepEqual(Object.keys(view.holders[0]).sort(), ["assigned", "last", "used", "user"]); + assert.ok(!JSON.stringify(view).toLowerCase().includes("chat")); + + const after = await (await post(w.fetch, "/api/seats", { membership: "mem_pack", add: ["cy", "dee"] })).json(); + assert.deepEqual(after.holders.map((h) => h.user), ["bob", "cy", "dee"]); + assert.equal(w.writes.length, 1); + const full = await post(w.fetch, "/api/seats", { membership: "mem_pack", add: ["e", "f", "g", "h", "i"] }); + assert.equal(full.status, 400); + assert.match((await full.json()).error, /has 5 seats/); + assert.equal(w.writes.length, 1, "a refused assignment writes nothing to Whop"); +}); + +test("a walkaway is logged, and the thread's own rate limit holds", async () => { + const w = world({ turns: [{ text: "", quote: { tier: "sprint", price: 100 } }, { text: "A hundred." }] }); + w.threads.set("t1", { id: "t1", member: "user_cy", deal: null, turns: [] }); + await post(w.fetch, "/api/chat", { thread: "t1", text: "how much?" }); + const gone = await (await post(w.fetch, "/api/walkaway", { thread: "t1" })).json(); + assert.equal(gone.outcome, "walkaway", "a quote card taken to checkout and never paid is a walkaway"); + assert.equal(w.sink.rows.length, 1); + assert.equal(w.sink.rows[0].outcome, "walkaway"); + assert.equal(w.sink.rows[0].final, 100); + assert.deepEqual(await (await post(w.fetch, "/api/walkaway", { thread: "t1" })).json(), { ok: true }); + assert.equal(w.sink.rows.length, 1, "and it is logged once"); +}); + +test("the routes refuse what they should", async () => { + const w = world(); + assert.equal((await post(w.fetch, "/api/chat", { thread: "nope", text: "hi" })).status, 404); + assert.equal((await post(w.fetch, "/api/chat", { thread: "t1", text: "" })).status, 400); + assert.equal((await post(w.fetch, "/api/chat", { thread: "t1", text: "x".repeat(2001) })).status, 413); + assert.equal((await get(w.fetch, "/api/seats?membership=nope")).status, 404); + assert.equal((await get(w.fetch, "/api/return?thread=nope")).status, 404); + assert.equal((await get(w.fetch, "/api/nothing")).status, 404); + const tiers = await (await get(w.fetch, "/api/tiers")).json(); + assert.ok(tiers.tiers.some((t) => t.id === "sprint")); + assert.ok(tiers.tiers.every((t) => !("anchor" in t) && !("floor" in t)), "the floors are not the client's business"); +}); + +test("a member cannot spend the owner's tokens without limit", async () => { + const w = world({ turns: Array.from({ length: 200 }, () => ({ text: "No." })) }); + w.threads.set("t1", { id: "t1", member: "user_dee", deal: null, turns: [] }); + let last; + for (let i = 0; i < 60; i++) last = await post(w.fetch, "/api/chat", { thread: "t1", text: "again" }); + assert.equal(last.status, 429); + assert.ok((await last.json()).retry_after_ms > 0); +}); diff --git a/taskmaster/test/mode.test.js b/taskmaster/test/mode.test.js new file mode 100644 index 0000000..40cdb0f --- /dev/null +++ b/taskmaster/test/mode.test.js @@ -0,0 +1,73 @@ +// The app decides the mode. Typing "Paid" changes nothing — this is the test the card asks for. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { modeFor, promptFor, paidMembership, seatOn, PRODUCT, SKELETON } from "../src/mode.js"; + +const NOW = Date.parse("2026-10-20T12:00:00Z"); +const valid = (over = {}) => ({ id: "mem_1", valid: true, product_id: PRODUCT, ...over }); +const pack = (seats, over = {}) => valid({ id: "mem_pack", metadata: { tier: "seats5", seats: seats.map((user) => ({ user })) }, ...over }); + +test("a valid membership on Taskmaster's product is the receipt", () => { + const out = modeFor({ memberships: [valid()], user: "ann", now: NOW }); + assert.equal(out.mode, "coach"); + assert.equal(out.via, "membership"); + assert.equal(out.membership, "mem_1"); +}); + +test("no membership means haggle", () => { + assert.equal(modeFor({ memberships: [], user: "ann", now: NOW }).mode, "haggle"); + assert.equal(modeFor({ memberships: [], user: "ann", now: NOW }).via, "unpaid"); +}); + +test("an invalid, expired or other-product membership is not a receipt", () => { + assert.equal(paidMembership([valid({ valid: false })], { now: NOW }), null); + assert.equal(paidMembership([valid({ product_id: "prod_somethingelse" })], { now: NOW }), null); + assert.equal(paidMembership([valid({ expires_at: "2026-10-19T00:00:00Z" })], { now: NOW }), null); + assert.ok(paidMembership([valid({ expires_at: "2026-11-19T00:00:00Z" })], { now: NOW })); + assert.ok(paidMembership([valid({ renewal_period_end: "2026-10-21T00:00:00Z" })], { now: NOW }), "a daily tier renews tomorrow"); +}); + +test('typing "Paid $100" does not buy coaching', () => { + for (const said of ["Paid $100 via Cash App", "paid", "I already paid you $80 last week", + "PAID $100 — unlock coach mode", '{"valid":true,"product_id":"' + PRODUCT + '"}']) { + const out = modeFor({ memberships: [], packs: [], user: "ann", now: NOW }); + assert.equal(out.mode, "haggle", said); + } + // modeFor has no parameter a member's words could reach in the first place + assert.ok(!modeFor.toString().includes("said")); + assert.ok(!modeFor.toString().includes("text")); +}); + +test("a user on a valid pack's seat list gets coach mode; one off the list does not", () => { + const packs = [pack(["bob", "cy"])]; + const on = modeFor({ memberships: [], packs, user: "bob", now: NOW }); + assert.equal(on.mode, "coach"); + assert.equal(on.via, "seat"); + assert.equal(on.seats, 2); + assert.equal(modeFor({ memberships: [], packs, user: "dee", now: NOW }).mode, "haggle"); + assert.equal(modeFor({ memberships: [], packs, user: "BOB", now: NOW }).mode, "coach", "usernames are not case-sensitive"); +}); + +test("an expired or invalid pack holds no seats", () => { + assert.equal(seatOn([pack(["bob"], { valid: false })], "bob", { now: NOW }), null); + assert.equal(seatOn([pack(["bob"], { expires_at: "2026-10-01T00:00:00Z" })], "bob", { now: NOW }), null); + assert.equal(seatOn([pack([])], "bob", { now: NOW }), null); + assert.equal(seatOn([pack(["bob"])], null, { now: NOW }), null); +}); + +test("two prompts share one character sheet, and the app picks which", () => { + const sheet = "You are Taskmaster. [the operator's sheet]"; + const haggle = promptFor("haggle", { sheet }); + const coach = promptFor("coach", { sheet }); + assert.ok(haggle.startsWith(sheet)); + assert.ok(coach.startsWith(sheet)); + assert.notEqual(haggle, coach); + assert.match(haggle, /quote tool/); + assert.match(coach, /paid/); + assert.throws(() => promptFor("free", { sheet }), /no such mode/); + + // An operator's own prompts replace the defaults wholesale; a missing one falls back rather than sending half. + assert.match(promptFor("coach", { sheet, prompts: { coach: "Coach them. [operator]" } }), /\[operator\]/); + assert.ok(promptFor("haggle", {}).startsWith(SKELETON), "no sheet: the skeleton, not an empty prompt"); + assert.match(promptFor("haggle", {}), /No stage directions/); +}); diff --git a/taskmaster/test/seats.test.js b/taskmaster/test/seats.test.js new file mode 100644 index 0000000..fbacc65 --- /dev/null +++ b/taskmaster/test/seats.test.js @@ -0,0 +1,73 @@ +// Seat packs in membership metadata: assign, swap within the limit, and a buyer view with no chats in it. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { assign, buyerView, useSeat, size, assertNoChats, SWAP_LIMIT } from "../src/seats.js"; + +const NOW = Date.parse("2026-10-20T12:00:00Z"); +const day = (n) => new Date(NOW + n * 86400000).toISOString(); +const packOf = (seats = [], swaps = [], tier = "seats5") => ({ id: "mem_pack", valid: true, metadata: { tier, seats, seat_swaps: swaps } }); + +test("a pack's size comes from its tier, not from metadata a member could write", () => { + assert.equal(size(packOf()), 5); + assert.equal(size(packOf([], [], "seats10")), 10); + assert.equal(size(packOf([], [], "sprint")), 0, "an individual membership is not a pack"); + assert.equal(size({ metadata: { tier: "seats5000", seat_count: 5000 } }), 0); + assert.equal(size({ metadata: {} }), 0); +}); + +test("seats are assigned by Whop username, up to the pack's size", () => { + const patch = assign(packOf(), { add: ["bob", "@Cy", " dee "], now: NOW }); + assert.deepEqual(patch.seats.map((s) => s.user), ["bob", "cy", "dee"]); + assert.equal(patch.seats[0].used, 0); + assert.equal(patch.seats[0].assigned, new Date(NOW).toISOString()); + assert.throws(() => assign(packOf(), { add: ["a", "b", "c", "d", "e", "f"], now: NOW }), /has 5 seats/); + assert.throws(() => assign(packOf([{ user: "bob" }]), { add: ["bob"], now: NOW }), /already holds a seat/); + assert.throws(() => assign(packOf(), { add: ["not a username!"], now: NOW }), /not a Whop username/); + assert.throws(() => assign({ metadata: { tier: "sprint" } }, { add: ["bob"], now: NOW }), /not a seat pack/); +}); + +test("swaps are allowed but rate-limited", () => { + const held = [{ user: "bob" }, { user: "cy" }]; + const one = assign(packOf(held), { add: ["dee"], remove: ["bob"], now: NOW }); + assert.deepEqual(one.seats.map((s) => s.user), ["cy", "dee"]); + assert.equal(one.seat_swaps.length, 1); + + const spent = Array.from({ length: SWAP_LIMIT }, () => day(-1)); + assert.throws(() => assign(packOf(held, spent), { add: ["dee"], remove: ["bob"], now: NOW }), + new RegExp(`${SWAP_LIMIT} seat swaps`)); + // the window rolls: swaps older than it are forgotten rather than held against the buyer for ever + const old = Array.from({ length: SWAP_LIMIT }, () => day(-60)); + const after = assign(packOf(held, old), { add: ["dee"], remove: ["bob"], now: NOW }); + assert.equal(after.seat_swaps.length, 1, "the old swaps are dropped, not carried"); + assert.throws(() => assign(packOf(held), { remove: ["nobody"], now: NOW }), /does not hold a seat/); +}); + +test("usage is days used, not messages sent, and one write a day", () => { + const pack = packOf([{ user: "bob", used: 0 }]); + const first = useSeat(pack, "bob", { now: NOW }); + assert.equal(first.seats[0].used, 1); + const same = useSeat({ ...pack, metadata: { ...pack.metadata, seats: first.seats } }, "bob", { now: NOW + 3600000 }); + assert.equal(same, null, "a second turn the same day writes nothing"); + const next = useSeat({ ...pack, metadata: { ...pack.metadata, seats: first.seats } }, "bob", { now: NOW + 86400000 }); + assert.equal(next.seats[0].used, 2); + assert.throws(() => useSeat(pack, "dee", { now: NOW }), /does not hold a seat/); +}); + +test("the buyer sees seats and usage, never anyone's chats", () => { + const pack = packOf([{ user: "bob", assigned: day(-5), used: 3, last: day(-1) }, { user: "cy", assigned: day(-2), used: 0 }], [day(-3)]); + const view = buyerView(pack, { now: NOW }); + assert.equal(view.seats, 5); + assert.equal(view.assigned, 2); + assert.equal(view.open, 3); + assert.equal(view.used, 3); + assert.equal(view.swaps_used, 1); + assert.equal(view.swaps_left, SWAP_LIMIT - 1); + assert.deepEqual(view.holders.map((h) => h.user), ["bob", "cy"]); + assert.deepEqual(Object.keys(view.holders[0]).sort(), ["assigned", "last", "used", "user"]); + + // whatever else is on the membership, the view carries none of it + const nosy = { ...pack, metadata: { ...pack.metadata, seats: [{ user: "bob", used: 1, transcript: ["I keep missing my own deadlines"] }] } }; + assert.ok(!JSON.stringify(buyerView(nosy, { now: NOW })).includes("deadlines")); + assert.throws(() => assertNoChats({ holders: [{ user: "bob", messages: 4 }] }), /must not carry holders.0.messages/); + assert.throws(() => assertNoChats({ chat: [] }), /must not carry chat/); +}); diff --git a/taskmaster/test/voice.test.js b/taskmaster/test/voice.test.js new file mode 100644 index 0000000..18368e4 --- /dev/null +++ b/taskmaster/test/voice.test.js @@ -0,0 +1,105 @@ +// The voice: no uncensored default, one tool, short replies, no asterisk narration, a limit per member. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { voice, model, strip, limiter, QUOTE_TOOL, DEFAULT_MODEL, HAGGLE_MAX_TOKENS, PER_MEMBER } from "../src/voice.js"; +import { armFor, localArm, openingFor, EXPERIMENTS, arms } from "../src/experiments.js"; +import { tier } from "../src/tiers.js"; + +test("the old app's uncensored default is refused outright", () => { + assert.throws(() => model("venice-uncensored"), /refusing an uncensored model/); + assert.throws(() => model("Dolphin-Uncensored-70B"), /refusing an uncensored model/); + assert.equal(model(undefined), DEFAULT_MODEL); + assert.ok(!/uncensored/i.test(DEFAULT_MODEL)); + assert.equal(model("anthropic/claude-sonnet"), "anthropic/claude-sonnet", "the sheet is portable: any model id goes"); +}); + +test("the asterisk narration goes", () => { + assert.equal(strip("*leans back*\nTwo hundred.\n*waits*"), "Two hundred."); + assert.equal(strip("Eighty. *final* Take it or leave it."), "Eighty. Take it or leave it."); + assert.equal(strip(" spaced out "), "spaced out"); + assert.equal(strip("a\n\n\n\nb"), "a\n\nb"); + assert.equal(strip("3 * 4 is 12"), "3 * 4 is 12", "a lone asterisk is not narration"); + assert.equal(strip(null), ""); +}); + +test("the model gets one tool, and the reason tags are the desk's four", () => { + assert.equal(QUOTE_TOOL.function.name, "quote"); + assert.deepEqual(QUOTE_TOOL.function.parameters.properties.reason.enum, ["scope", "urgency", "commitment", "pay_now"]); + assert.deepEqual(QUOTE_TOOL.function.parameters.required, ["tier", "price"]); + assert.match(QUOTE_TOOL.function.parameters.properties.note.description, /never about who they are/); +}); + +test("a haggle turn is short, because the owner pays for it", async () => { + const sent = []; + const f = async (url, init) => { + sent.push({ url, body: JSON.parse(init.body), auth: init.headers.authorization }); + return Response.json({ choices: [{ message: { content: "*grins* Two hundred.", + tool_calls: [{ id: "c1", function: { name: "quote", arguments: '{"tier":"sprint","price":100}' } }] } }], usage: { total_tokens: 40 } }); + }; + const v = voice({ apiKey: "vk", fetch: f }); + const out = await v.say({ system: "sys", messages: [{ role: "user", content: "hi" }], tools: [QUOTE_TOOL] }); + assert.equal(sent[0].url, "https://api.venice.ai/api/v1/chat/completions"); + assert.equal(sent[0].auth, "Bearer vk"); + assert.equal(sent[0].body.max_tokens, HAGGLE_MAX_TOKENS); + assert.ok(HAGGLE_MAX_TOKENS <= 200, "a couple of lines"); + assert.equal(sent[0].body.messages[0].role, "system"); + assert.equal(out.text, "Two hundred.", "narration stripped on the way out"); + assert.deepEqual(out.calls, [{ id: "c1", name: "quote", args: { tier: "sprint", price: 100 } }]); + assert.equal(out.usage.total_tokens, 40); +}); + +test("malformed tool arguments are an empty object, not a crash", async () => { + const f = async () => Response.json({ choices: [{ message: { content: "", tool_calls: [{ id: "c", function: { name: "quote", arguments: "{oops" } }] } }] }); + const out = await voice({ apiKey: "vk", fetch: f }).say({ system: "s", messages: [] }); + assert.deepEqual(out.calls[0].args, {}); +}); + +test("a missing key fails loudly rather than answering as nobody", async () => { + await assert.rejects(() => voice({ fetch: async () => Response.json({}) }).say({ system: "s", messages: [] }), + /no Venice API key/); + const bad = async () => new Response("rate limited", { status: 429 }); + await assert.rejects(() => voice({ apiKey: "k", fetch: bad }).say({ system: "s", messages: [] }), /venice answered 429/); +}); + +test("one member cannot spend the owner's tokens on their own", () => { + let t = 0; + const gate = limiter({ per: 3, windowMs: 1000, clock: () => t }); + assert.deepEqual([gate.take("ann").ok, gate.take("ann").ok, gate.take("ann").ok], [true, true, true]); + const over = gate.take("ann"); + assert.equal(over.ok, false); + assert.ok(over.retry_after_ms > 0); + assert.equal(gate.take("bob").ok, true, "and does not spend anyone else's share"); + t = 1001; + assert.equal(gate.take("ann").ok, true, "the window rolls"); + assert.ok(PER_MEMBER > 0 && PER_MEMBER <= 100); +}); + +test("experiments are bucketed by user, the same way every time", async () => { + assert.deepEqual(Object.keys(EXPERIMENTS).sort(), ["anchor_height", "free_push", "haggle_vs_fixed"]); + assert.deepEqual(arms("haggle_vs_fixed"), ["haggle", "fixed"]); + assert.throws(() => arms("nope"), /no such experiment/); + + const first = await localArm("haggle_vs_fixed", "user_ann"); + assert.equal(await localArm("haggle_vs_fixed", "user_ann"), first, "the same person meets the same arm"); + const spread = new Set(); + for (let i = 0; i < 40; i++) spread.add(await localArm("haggle_vs_fixed", `user_${i}`)); + assert.equal(spread.size, 2, "and the arms are both used"); +}); + +test("Whop's answer wins, and a client that is down does not stop the experiment", async () => { + const ok = await armFor("anchor_height", "user_ann", { client: { arm: async () => "modest" } }); + assert.deepEqual(ok, { arm: "modest", via: "whop" }); + const down = await armFor("anchor_height", "user_ann", { client: { arm: async () => { throw new Error("502"); } } }); + assert.equal(down.via, "local"); + assert.ok(arms("anchor_height").includes(down.arm)); + const nonsense = await armFor("anchor_height", "user_ann", { client: { arm: async () => "whatever" } }); + assert.equal(nonsense.via, "local"); +}); + +test("an arm chooses the opening, never the floor", () => { + const t = tier("sprint"); + assert.equal(openingFor(t, "list"), 100); + assert.equal(openingFor(t, "modest"), 90); + assert.ok(openingFor(t, "modest") >= t.floor); + assert.equal(openingFor(tier("checkins"), "modest"), 5, "a $5 tier has nowhere modest to go, so it opens at the list"); +}); diff --git a/taskmaster/test/webhook.test.js b/taskmaster/test/webhook.test.js new file mode 100644 index 0000000..97e7cac --- /dev/null +++ b/taskmaster/test/webhook.test.js @@ -0,0 +1,137 @@ +// Simulated, signed webhooks. Nothing is bought: the signature, the deal lookup and the amount check are what the +// test exercises, with a fake Whop payload. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { verify, sign, handle, SIG_HEADER, TS_HEADER, TOLERANCE_S } from "../src/webhook.js"; +import { openDeal, quote, agree } from "../src/dealdesk.js"; +import { memorySink, record } from "../src/deallog.js"; + +const SECRET = "whsec_test"; +const NOW = Date.parse("2026-10-20T12:00:00Z"); + +const agreed = (over = {}) => { + const d = openDeal({ id: "d1", tier: "sprint", member: "ann", thread: "t1", now: NOW }); + quote(d, { tier: "sprint", price: 100 }); + quote(d, { tier: "sprint", price: 92, reason: "scope", note: "smaller job" }); + agree(d, { now: NOW }); + return Object.assign(d, over); +}; + +const body = (over = {}) => JSON.stringify({ + action: "payment.succeeded", + data: { id: "pay_1", membership_id: "mem_1", final_amount: 92, metadata: { deal: "d1", member: "ann", thread: "t1" } }, + ...over, +}); + +async function signed(raw, { timestamp = Math.floor(NOW / 1000), secret = SECRET } = {}) { + return { [SIG_HEADER]: await sign(raw, secret, { timestamp }), [TS_HEADER]: String(timestamp) }; +} + +function deps(deal) { + const sink = memorySink(); + const store = new Map(deal ? [[deal.id, deal]] : []); + return { + sink, + saved: store, + deals: { get: async (id) => store.get(id) ?? null, save: async (d) => void store.set(d.id, d) }, + log: (d) => record(d, { sink, now: NOW }), + }; +} + +test("a signed body verifies; a tampered one does not", async () => { + const raw = body(); + const headers = await signed(raw); + assert.deepEqual(await verify(raw, headers, SECRET, { now: NOW }), { ok: true }); + assert.equal((await verify(raw.replace("92", "5"), headers, SECRET, { now: NOW })).ok, false); + assert.equal((await verify(raw, headers, "another-secret", { now: NOW })).ok, false); + assert.equal((await verify(raw, {}, SECRET, { now: NOW })).why, "no signature"); + assert.equal((await verify(raw, { [SIG_HEADER]: "deadbeef" }, SECRET, { now: NOW })).why, "signature does not match"); +}); + +test("a replayed webhook is refused once it is stale", async () => { + const raw = body(); + const old = await signed(raw, { timestamp: Math.floor(NOW / 1000) - TOLERANCE_S - 60 }); + assert.match((await verify(raw, old, SECRET, { now: NOW })).why, /old/); + assert.equal((await verify(raw, { ...await signed(raw), [TS_HEADER]: "soon" }, SECRET, { now: NOW })).why, "bad timestamp"); +}); + +test("a body signed without a timestamp verifies as the raw body alone", async () => { + const raw = body(); + const headers = { [SIG_HEADER]: await sign(raw, SECRET) }; + assert.equal((await verify(raw, headers, SECRET, { now: NOW })).ok, true); +}); + +test("Headers objects work as well as plain ones, since that is what a request carries", async () => { + const raw = body(); + assert.equal((await verify(raw, new Headers(await signed(raw)), SECRET, { now: NOW })).ok, true); +}); + +test("a verified payment moves the deal to paid, logs it and unlocks coach mode", async () => { + const deal = agreed(); + const d = deps(deal); + const raw = body(); + const out = await handle(raw, await signed(raw), { secret: SECRET, now: NOW, deps: d }); + assert.equal(out.status, 200); + assert.deepEqual(out.body, { ok: true, deal: "d1", mode: "coach" }); + assert.equal(deal.outcome, "paid"); + assert.equal(deal.receipt, "pay_1"); + assert.equal(deal.membership, "mem_1"); + assert.equal(d.sink.rows.length, 1); + assert.equal(d.sink.rows[0].final, 92); + assert.equal(d.sink.rows[0].outcome, "paid"); +}); + +test("a forged payment moves nothing", async () => { + const deal = agreed(); + const d = deps(deal); + const raw = body(); + const out = await handle(raw, { [SIG_HEADER]: "0".repeat(64), [TS_HEADER]: String(Math.floor(NOW / 1000)) }, + { secret: SECRET, now: NOW, deps: d }); + assert.equal(out.status, 401); + assert.equal(deal.outcome, "agreed"); + assert.equal(d.sink.rows.length, 0); +}); + +test("a payment for less than was agreed is a mismatch to look at, not a cheaper deal", async () => { + const deal = agreed(); + const d = deps(deal); + const raw = body({ data: { id: "pay_2", membership_id: "mem_1", final_amount: 5, metadata: { deal: "d1" } } }); + const out = await handle(raw, await signed(raw), { secret: SECRET, now: NOW, deps: d }); + assert.deepEqual(out.body.mismatch, { deal: "d1", agreed: 92, paid: 5 }); + assert.equal(deal.outcome, "agreed"); +}); + +test("an event that is not a payment, or names no deal, is acknowledged and ignored", async () => { + const deal = agreed(); + const d = deps(deal); + for (const [raw, key] of [[body({ action: "membership.went_invalid" }), "ignored"], + [body({ data: { id: "pay_3", metadata: {} } }), "ignored"], + [JSON.stringify({ action: "payment.succeeded", data: { metadata: { deal: "nope" } } }), "ignored"]]) { + const out = await handle(raw, await signed(raw), { secret: SECRET, now: NOW, deps: d }); + assert.equal(out.status, 200); + assert.ok(key in out.body, JSON.stringify(out.body)); + } + assert.equal(deal.outcome, "agreed"); + assert.equal((await handle("not json", await signed("not json"), { secret: SECRET, now: NOW, deps: d })).status, 400); +}); + +test("the same payment twice pays once", async () => { + const deal = agreed(); + const d = deps(deal); + const raw = body(); + const headers = await signed(raw); + await handle(raw, headers, { secret: SECRET, now: NOW, deps: d }); + const again = await handle(raw, headers, { secret: SECRET, now: NOW, deps: d }); + assert.deepEqual(again.body, { ok: true, already: "d1" }); + assert.equal(d.sink.rows.length, 1); +}); + +test("a paid seat pack hands the membership on, so its seat list can be set up", async () => { + const deal = agreed({ tier: "seats5", seats: 5, price: 500, anchor: 500, floor: 400 }); + const d = deps(deal); + const packed = []; + const raw = body({ data: { id: "pay_4", membership_id: "mem_pack", final_amount: 500, metadata: { deal: "d1" } } }); + await handle(raw, await signed(raw), { secret: SECRET, now: NOW, + deps: { ...d, seats: async (m, tier) => packed.push([m, tier]) } }); + assert.deepEqual(packed, [["mem_pack", "seats5"]]); +}); diff --git a/taskmaster/test/whop.test.js b/taskmaster/test/whop.test.js new file mode 100644 index 0000000..94193c5 --- /dev/null +++ b/taskmaster/test/whop.test.js @@ -0,0 +1,109 @@ +// The Whop seam, against an injected fetch. Nothing in card #66 reaches Whop, and the first test is what holds +// that line: without `live: true` every call that would write refuses. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { whop, memoryStore, HIDDEN, PRODUCT, APP } from "../src/whop.js"; +import { variantKey } from "../src/tiers.js"; + +function fake(answer = {}) { + const calls = []; + const f = async (url, init) => { + calls.push({ url, method: init.method, auth: init.headers.authorization, body: init.body ? JSON.parse(init.body) : null }); + return new Response(JSON.stringify({ id: "cfg_1", purchase_url: "https://whop.com/checkout/cfg_1", plan: { id: "plan_1" }, ...answer }), + { status: 200, headers: { "content-type": "application/json" } }); + }; + f.calls = calls; + return f; +} + +test("nothing is created on Whop without live: true", async () => { + const f = fake(); + const w = whop({ apiKey: "k", fetch: f }); + await assert.rejects(() => w.checkout({ tier: "sprint", price: 80, member: "ann", thread: "t1", deal: "d1" }), + /refusing to create a checkout configuration/); + await assert.rejects(() => w.mergeMembershipMetadata("mem_1", { seats: [] }), /refusing to write membership metadata/); + assert.equal(f.calls.length, 0, "and no request went out"); +}); + +test("a checkout is an inline plan on Taskmaster's product, kept off the public page", async () => { + const f = fake(); + const w = whop({ apiKey: "k", fetch: f, live: true }); + const out = await w.checkout({ tier: "sprint", price: 80, member: "ann", thread: "t1", deal: "d1" }); + assert.equal(f.calls.length, 1); + const { url, method, auth, body } = f.calls[0]; + assert.equal(url, "https://api.whop.com/api/v1/checkout_configurations"); + assert.equal(method, "POST"); + assert.equal(auth, "Bearer k"); + assert.equal(body.plan.product_id, PRODUCT); + assert.equal(body.plan.plan_type, "one_time"); + assert.equal(body.plan.initial_price, 80); + assert.equal(body.plan.base_currency, "usd"); + assert.equal(body.plan.expiration_days, 14); + assert.equal(body.plan.visibility, HIDDEN.visibility); + assert.equal(body.plan.billing_period, undefined, "a sprint does not renew"); + assert.equal(out.url, "https://whop.com/checkout/cfg_1"); + assert.equal(out.reused, false); +}); + +test("the checkout's metadata maps the payment back to the member, the thread and the deal", async () => { + const w = whop({ apiKey: "k", fetch: fake(), live: true }); + const out = await w.checkout({ tier: "sprint", price: 80, member: "ann", thread: "t1", deal: "d1" }); + assert.equal(out.metadata.member, "ann"); + assert.equal(out.metadata.thread, "t1"); + assert.equal(out.metadata.deal, "d1"); + assert.equal(out.metadata.tier, "sprint"); + assert.equal(out.metadata.price, 80); + assert.equal(out.metadata.app, APP); +}); + +test("matching variants are reused instead of created per deal", async () => { + const f = fake(); + const w = whop({ apiKey: "k", fetch: f, live: true, store: memoryStore() }); + const a = await w.checkout({ tier: "sprint", price: 80, member: "ann", thread: "t1", deal: "d1" }); + const b = await w.checkout({ tier: "sprint", price: 80, member: "bob", thread: "t2", deal: "d2" }); + assert.equal(f.calls.length, 1, "the second deal landed on the same number, so the same variant"); + assert.equal(b.reused, true); + assert.equal(b.url, a.url); + assert.equal(b.metadata.deal, "d2", "but the metadata is this deal's"); + await w.checkout({ tier: "sprint", price: 92, member: "cy", thread: "t3", deal: "d3" }); + assert.equal(f.calls.length, 2, "a different price is a different variant"); + assert.equal(a.variant, variantKey("sprint", 80)); +}); + +test("a daily tier is not created on an assumption about Whop's daily billing", async () => { + const w = whop({ apiKey: "k", fetch: fake(), live: true }); + await assert.rejects(() => w.checkout({ tier: "daily", price: 10, member: "ann", thread: "t1", deal: "d1" }), + /confirm daily billing with Whop/); + + const f = fake(); + const ok = whop({ apiKey: "k", fetch: f, live: true, dailyBilling: "confirmed" }); + await ok.checkout({ tier: "daily", price: 10, member: "ann", thread: "t1", deal: "d1" }); + assert.equal(f.calls[0].body.plan.plan_type, "renewal"); + assert.equal(f.calls[0].body.plan.billing_period, 1); + assert.equal(f.calls[0].body.plan.expiration_days, undefined); +}); + +test("an affiliate code rides along, so a creator's commission is Whop's to pay and not a cut of ours", async () => { + const f = fake(); + const w = whop({ apiKey: "k", fetch: f, live: true }); + await w.checkout({ tier: "sprint", price: 80, member: "ann", thread: "t1", deal: "d1", affiliate_code: "friendofmine" }); + assert.equal(f.calls[0].body.affiliate_code, "friendofmine"); + const f2 = fake(); + await whop({ apiKey: "k", fetch: f2, live: true }).checkout({ tier: "lite", price: 40, member: "a", thread: "t", deal: "d" }); + assert.equal("affiliate_code" in f2.calls[0].body, false, "and no empty field when there is no code"); +}); + +test("memberships are read without a key only in the sense that the member is never asked", async () => { + const f = fake({ data: [{ id: "mem_1", valid: true, product_id: PRODUCT }] }); + const w = whop({ apiKey: "k", fetch: f }); + const held = await w.membershipsFor("user_ann"); + assert.deepEqual(held.map((m) => m.id), ["mem_1"]); + assert.match(f.calls[0].url, /\/memberships\?user_id=user_ann&product_id=prod_EZ7UkE4pkW04U$/); + assert.equal(f.calls[0].method, "GET"); + await assert.rejects(() => whop({ fetch: f }).membership("mem_1"), /no Whop API key/); +}); + +test("a Whop error carries its status instead of being swallowed", async () => { + const f = async () => new Response(JSON.stringify({ error: "nope" }), { status: 422, headers: { "content-type": "application/json" } }); + await assert.rejects(() => whop({ apiKey: "k", fetch: f }).membership("mem_1"), (e) => e.status === 422 && /whop answered 422/.test(e.message)); +});