Relationships
#1544 swamp auth whoami should report paid tier and trial status
Opened by keeb · 8/6/2026· Shipped 8/7/2026
Problem
swamp auth whoami tells you who you are but not what you're entitled to. There is
no way, from the CLI, to answer "am I on a paid plan?", "am I in a trial?", or "how many
days of trial do I have left?" — the operative has to open the web UI (/orgs or
/o/<slug>/billing) to find out.
This matters in three concrete places:
- Entitlement-gated failures are unexplainable. Private extensions are gated on
planEntitlesPrivateExtensions(plan)(plan !== "free"), but when a push or pull is refused the CLI has no local notion of plan to say why, or to suggest which collective needs upgrading. - Trial expiry is invisible. swamp-club runs a product-owned 30-day trial
(
TRIAL_DURATION_DAYS = 30, started bycollective_created,serve_token_minted, ordatastore_token_minted). Day 31 changes what the operative is told — but nothing in the CLI ever tells them. - Support and scripting. "Paste your
swamp auth whoami --json" should be enough to triage a billing-adjacent report. Today it isn't.
Current state
CLI side (swamp):
src/cli/commands/auth_whoami.ts— the Cliffy commandsrc/libswamp/auth/whoami.ts:42—WhoamiIdentitysrc/infrastructure/http/swamp_club_client.ts:62-93—WhoamiResponse,WhoamiOrganizationsrc/presentation/renderers/auth_whoami.ts— log + json renderers
GET /api/whoami returns { authenticated, id, username, email, name, organizations[] },
where each org is { slug, name, role, personal }. The CLI parses organizations and then
throws away everything but slug (getCollectives() at swamp_club_client.ts:88-92),
so log mode prints only Collectives: a, b. No plan, no trial, anywhere in the chain.
Server side (swamp-club) — billing is per-collective, not per-user; a user's paid
status is derived from the subscriptions of the collectives they belong to:
- Plan lives in Mongo
billing_subscription,_id = collectiveId(lib/infrastructure/mongo-subscription-repository.ts:36) PlanId = "free" | "team" | "business" | "enterprise"(lib/billing-plans.ts:16-38)SubscriptionStatusmirrors Stripe;ENTITLING_STATUSES = ["active","trialing","past_due"], andeffectivePlan()collapses to"free"when access isn't granted (lib/domain/billing/subscription.ts:46-64, 164-167)- The 30-day product trial is separate from Stripe's
trialing:trialStartedAt/trialTriggeron theorganization_profiledoc;TrialStatus(state: none|active|expired,startedAt,endsAt,dayNumber,daysRemaining) is derived on read, never persisted (lib/trial.ts:26,40-48,60-89)
routes/api/whoami.ts:73-78 deliberately projects only slug/name/role/personal.
Proposed solution
1. Server: extend GET /api/whoami
Add per-collective entitlement to each entry in organizations, plus a top-level
roll-up for the common "am I paid?" question:
{
"authenticated": true,
"id": "...", "username": "...", "email": "...", "name": "...",
"plan": "team", // highest plan across the user's collectives
"organizations": [
{
"slug": "acme", "name": "Acme", "role": "admin", "personal": false,
"plan": "team", // effectivePlan(): "free" unless grantsAccess()
"planName": "Team",
"subscriptionStatus": "active", // Stripe status, or null when no subscription
"trial": { // omitted/null when state === "none"
"state": "active", // none | active | expired
"startedAt": "2026-07-20T00:00:00.000Z",
"endsAt": "2026-08-19T00:00:00.000Z",
"daysRemaining": 13
}
}
]
}Two notes on implementation cost:
- Trial data is free.
handleGetWhoamialready callslistUserCollectivesWithRoles(userId)(routes/api/whoami.ts:64-65), which returnsCollectiveData & { role }— andCollectiveDataalready carriestrialStartedAt/trialTriggervia theorganization_profilejoin (lib/infrastructure/better-auth-collective-queries.ts:318-338). Just runtoTrialStatusView(o)(lib/app/views/organization-view.ts:24-37) over what's already loaded. Zero extra queries. - Plan costs one batched read. Add
subscriptionRepotoWhoamiDeps(routes/api/whoami.ts:11-14) and callfindManyByCollectiveIds(orgIds)(lib/repositories/subscription-repository.ts:17-19).
Precedents to copy rather than reinvent:
| Need | Existing code |
|---|---|
| Highest plan across a user's collectives | lib/app/lab/enrich-author-plans.ts:30-146 (TIER_RANK, batched lookup, 60s TTL cache, clearPlanCache() on sub change) |
| Serializable subscription projection | toSubscriptionView (lib/app/views/billing-view.ts:14-63) |
| Paid plan must supersede the trial clock | lib/app/collective-page-context.ts:80-96 — "a collective that is paying must never be told it is on a free trial" |
Collective API tokens (routes/api/whoami.ts:85-101) should get the same fields for their
single owning collective.
The additions are purely additive, so older CLIs keep working.
2. CLI: surface it
WhoamiResponse/WhoamiOrganization(swamp_club_client.ts:62-93) gain the new optional fields.WhoamiIdentity(libswamp/auth/whoami.ts:42) carriesplanand a per-collective entitlement list rather than a barestring[]of slugs. This means retiring the lossygetCollectives()flattening — the auth.json cache and thecollectivesfield need to keep their current shape for compatibility, so the richer data should ride alongside rather than replace it.log mode:
keeb ([REDACTED-EMAIL]) on https://swamp-club.com Plan: Team Collectives: acme Team active keeb Free trial: 13 days left (ends 2026-08-19)json mode: pass the fields through verbatim.
All fields must be optional on the CLI side — a self-hosted or older swamp-club won't send them, and whoami must not regress to an error in that case.
Alternatives considered
- A separate
swamp auth plan/swamp billing statuscommand. More surface for the same single round-trip; whoami is already the "what does the server think of me" call, and it already fetches the org list this would need. - Resolve plan CLI-side from a billing endpoint. Requires a second authenticated
request and duplicates
effectivePlan()/ trial-derivation logic in the CLI, where it will drift. The server owns entitlement (see also the "server is authoritative" principle for telemetry identity) — it should own this answer too. - Only expose the top-level roll-up, not per-collective. Insufficient: the actionable question when a private-extension push is refused is which collective needs upgrading, which a single roll-up can't answer.
Out of scope
Making the 30-day trial actually grant entitlement. Today it grants none
(lib/trial.ts:29-33) — this issue is about reporting status, not changing it.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.