Skip to content

feat: a public live demo, a read-only viewer role, and links out of the login footer - #336

Merged
fclairamb merged 47 commits into
mainfrom
batch/2026-09-06
Sep 6, 2026
Merged

fclairamb merged 47 commits into
mainfrom
batch/2026-09-06

Conversation

@fclairamb

Copy link
Copy Markdown
Owner

Three specs, each implemented in isolation and cleared by an independent completeness audit before being archived to specs/done/2026/09/. Two needed a fix round; both re-audited clean.

1. viewer is finally read-only — a real privilege fix

This is a behaviour change. viewer is offered in the members UI as the read-only tier, and nothing enforced it. A viewer could create and delete checks, resolve incidents, publish to public status pages, edit notification plumbing, and send test reports. Anyone relying on that — knowingly or not — will now get a 403.

  • New RequireOrgWrite floor, applied structurally in the orgGroup helper, so a new org route cannot ship without it.
  • The role is read from the membership row, not the token: a demotion bites on the next request, and a PAT minted while the user was a user stops writing the moment they are demoted.
  • Exactly two self-scoped exemptions: your own notification settings (/users/me/*) and your own PAT (POST /tokens).
  • MCP gets the same floor. Scope was the only gate there, so a viewer holding a full-mcp token could create_check straight past REST middleware.
  • Incident ack/snooze/resolve are deliberately denied — they change what the whole team is paged about. A team that wants that gives the person user.

The deliverable is the proof, not the middleware: a route walk asserts every non-GET org route refuses a viewer on the gate's own message (so an unrelated 403 can't mask a regression), with a positive control walking the same table as a user — a floor that also locks out ordinary users would be worse than the hole it closes.

Implementation found four write surfaces the spec's own inventory missed, all previously open to viewers: the Slack and Discord install-url endpoints, MS Teams link-code, and job cancellation.

Out of scope by design: dash0 still renders write buttons for viewers. The honest 403 is the intended interim; hiding them is a follow-up spec.

2. A public live demo account — off by default

Evaluators had to sign up before seeing anything work. This adds a real demo org and user reachable through the standard login, on the production server so the multi-region fleet and real history are the demo.

Safety is a positive allowlist, not a denylist: a demo session may create a check and edit or delete its own, and nothing else. Everything else answers DEMO_READ_ONLY. Seeded catalogue checks are created_by = NULL and therefore untouchable by construction, with no "protected" flag to forget.

  • Guard sits inside RequireAuth/RequireMCPAuth — the one chokepoint every authenticated route passes — and matches on the router pattern, never the raw path.
  • Load is bounded by entitlements that already existed (maxChecks, maxChecksPerMinute), not new machinery.
  • Only side-effect-free credential-free probe types; only SolidPing-owned targets — the demo never probes a third party. Notifications go to our own /api/v1/fake sink.
  • A 30-minute cleanup job deletes expired visitor checks through the real delete path and reconciles the shared identity, so anything that slips is undone within half an hour.

SP_DEMO_ENABLED defaults to false — merging this exposes nothing until it is switched on.

3. The login footer says what it is and links somewhere

v0.24.0 as bare text becomes SolidPing v0.24.0, two links: the brand to the marketing site with UTM parameters distinguishing SaaS from self-hosted, the version to the changelog. This also exposes deploymentMode on /api/mgmt/version — dash0 previously had no way to tell which flavour it was running on, and the alternative was sniffing the hostname.

Notes for review and deploy

  • New migration 019_v0_25_0 on both backends: users.demo and checks.created_by.
  • created_by is now recorded for every check creator, not just demo sessions — useful audit data in its own right, and exposed as createdBy.
  • The billing service was verified — not assumed — to never write entitlements for an org with no Polar customer, so the pinned demo row cannot be overwritten. ../solidping-billing is untouched.
  • An integration test proves a correctly-signed billing entitlements push still returns 2xx through the full chain, with a viewer-session negative control proving the new floor really is in the chain that push travels through.

QA

Backend build, lint and full test suite green; dash0 build, lint and 1372 unit tests green; docs build green. dash0 ESLint holds at its 42-error pre-existing baseline with zero new errors in touched files.

🤖 Generated with Claude Code

fclairamb and others added 30 commits September 6, 2026 21:13
Two of the three carried their own defaults; the billing question was a
factual one and is answered by verification rather than assumption. Records
all three in the file, since that is the only thing the implementer reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds DeploymentMode to version.Info, set from s.config.Deployment.Mode
the same way RunMode is today, so dash0 can tell SaaS from self-hosted
without an extra request. Regenerates the OpenAPI client and covers
both the config-level default resolution and the endpoint's JSON
output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The login footer previously rendered only a bare version string with
nowhere to go. Splits it into two links: "SolidPing" to the marketing
site (UTM-tagged via the new marketing-url helper, which branches on
the deployment mode now exposed by /api/mgmt/version) and the version
to the docs changelog. Adds the "muted inline external link" pattern
to the design reference since none existed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asserts the "SolidPing v1.2.3" footer text, the brand link's
self-hosted UTM campaign and target=_blank, the version link's plain
changelog href, and that login-runmode is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on number with nowhere to go

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tapi

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rules

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects the independent audit found against §4 and §5 of spec
2026-09-06-02, all in the demo seeding path.

maxChecks ratcheted. It was computed from the LIVE check total plus the
headroom, and the cleanup job re-pinned on the same formula every thirty
minutes — so under exactly the traffic the cap exists to bound, the
ceiling climbed monotonically. The seeded count is now persisted at seed
time (demo.seeded_checks) and every later re-pin reads that constant. An
org seeded by an older binary falls back to counting NULL-owned rows,
which is precisely the seeded set, and persists the answer.

The seeded heartbeat had no ping token. The sample ships an empty config
on purpose because Validate is what mints the token — and the demo seeder
was the only seeding path that never called Validate. The heartbeat
landed with no ping URL to copy, and rotate-token is not on the demo
write allowlist, so it could not be repaired from inside the demo. The
catalog now validates every sample the way createSampleCheck does, which
also restores the config validation, the check.created audit event and
the runner wake-up it was skipping.

"Three or more public regions on every check" was never set. Regions were
left empty and resolved through the org default, the system default, then
every defined region — true in production by luck, silently false on an
instance with a narrow default_regions. The public region set is now
resolved at seed time and pinned, up to three, taking whatever exists on
an instance with fewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GenerateMCPAccessToken built its Claims without Demo. That was harmless
only because the RequireMCPAuth backstop re-derives the flag from the user
row — but §1 says the claim is populated wherever claims are minted, and a
guard that works only because a second layer repairs the first is exactly
the fragility the second layer exists to cover.

The mint now resolves it from the user row itself, which costs it a ctx
parameter. The backstop stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proof identified the authenticated route set empirically, by probing
each route anonymously and keeping the ones that answered 401 NO_TOKEN.
That fails safe but under-covers: any RequireAuth route whose anonymous
probe answered something else — a 404 or 422 from a middleware ahead of
the auth check — was silently skipped, so a route that slipped the guard
would have been reported as a pass.

Every registered non-GET API route now lands in exactly one of three
explicit buckets: a named list of by-design-unauthenticated routes (each
entry carrying why it is safe), the write allowlist, or a 403
DEMO_READ_ONLY. Nothing is skipped. The named list is asserted in both
directions — an entry whose route has since been put behind RequireAuth
fails, and an entry naming a route that no longer exists fails — so it
cannot rot into a silent exemption. The anti-vacuity floors stay, joined
by one on the allowlist itself.

Also adds the test §9 names as "PAT-derived demo claims are guarded too":
a PAT minted for the demo user is refused DEMO_READ_ONLY on a write, with
the same PAT succeeding on a GET as the positive control — without which
a merely broken credential would pass a refusal-only assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…emo read-only

Three §8 leftovers the audit found.

The auto-create wands on Status pages and Integrations still rendered next
to the New buttons that were correctly replaced. Neither POST is on the
demo write allowlist, so the wand could only ever produce the read-only
toast — offering an action that cannot succeed is worse than offering
none. Both are now hidden for a demo session, including the wand in the
empty status-pages state.

auth.demo.loginHint described "a read-only demo account" in all four
locales. The demo is explicitly not read-only: creating a check is the
whole point of it, and the login page was talking a visitor out of the one
thing it exists to show. Reworded to what is true — create a check and
watch it run, everything is public and disappears after an hour — with key
parity preserved.

Adds the demo banner unit test §9 names: it renders nothing without a demo
session, states both facts in resolved copy, points the CTA at the in-app
registration route, and carries no dismiss control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment justifying why POST /auth/reset-password sits on the
unauthenticated list pointed at TestResetPasswordRefusesADemoUser, which
does not exist; the test is TestResetPasswordRefusesTheDemoAccount
(server/internal/handlers/auth/demo_reset_test.go:20). A wrong pointer in
the one comment explaining why a public route is safe is worth correcting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fclairamb and others added 16 commits September 6, 2026 23:32
… SolidPing without signing up

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every non-GET route registered through the orgGroup helper now requires at
least the `user` role, read from the membership row so a demotion takes
effect on the next request rather than at the next token refresh.

Two groups keep the old chain via a new orgGroupSelf helper, because what
they write is only ever the caller's own row: /orgs/:org/users/me/* and
POST /orgs/:org/tokens. The entitlements chain gains the floor for its
admin-user fallback; its signed service path short-circuits ahead of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MCP tool calls never pass through the org-scoped REST middleware, so an
mcp-scoped PAT belonging to a viewer would still reach the services
directly. handleToolsCall now resolves the caller's membership row after
the scope check and refuses any isMutationTool call below the user role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The walk sorts every registered non-GET route under /api/v1/orgs/{org}
into one of three buckets — public by design, self-scoped, or a 403 whose
BODY MESSAGE is the write floor or a stricter admin/owner denial — with
anti-vacuity floors on each, plus the same walk as an ordinary member as a
positive control.

It found three groups that the orgGroup helper never covered: the Slack,
Discord and MS Teams org-integration install/link chains, and DELETE
/orgs/:org/jobs/:uid (canceling a background job). All four are now
floored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…changelog

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Slack install-url fixture built its own chain of RequireAuth +
RequireOrgAccess under a comment claiming it wired the middleware "exactly
as server.go registers it". Since the viewer read-only floor landed that is
no longer true: server.go registers install-url through orgGroup, which adds
RequireOrgWrite.

The consequence was worse than a stale comment. Because the fixture omitted
the new middleware, TestBuildInstallURLForOrg_MemberSucceeds kept passing
while asserting that a MemberRoleViewer gets 200 — the exact opposite of what
production does — so the test documented the hole rather than the gate, and a
regression that removed the floor would not have failed anything.

The viewer case is converted, not deleted: a viewer is now refused, asserted
on the gate's own message rather than on a bare 403, and a `user` succeeds as
the positive control that this is a role floor and not a broken fixture.

The jobs fixture carried the same claim with the same omission; it had no
contradicting assertion, but a fixture that says "the exact wiring
app/server.go uses" has to actually be it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RequireOrgWrite short-circuits on isServiceAuthorized, and the middleware
unit test pins that. What nothing pinned was the ORDER of the real chain: if
the floor ever moved ahead of the service short-circuit, or the signature
middleware were dropped from the entitlements group, billing would start
answering 401/403 in production while every isolated test stayed green.

So this drives the production route table — real NewServer, real SetupRoutes,
over the wire — with a correctly-signed PUT /api/v1/orgs/:org/entitlements
carrying no session at all, and asserts 2xx plus the row actually landing.

Three negative controls stop it passing vacuously: the byte-identical request
unsigned, the same request signed with the wrong secret, and a real viewer
session — the last one is what proves the floor is genuinely in the chain the
2xx travels through. Verified by mutation: deleting the isServiceAuthorized
short-circuit turns the signed push into a 401 and fails the test.

The signedauth fixture gains the same middleware for the same reason as the
Slack one — its header comment names the chain it drives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e MCP users look

Two surfaces the spec's §5 sweep missed.

wiki/architecture.md enumerates the four roles twice — under Users and under
Authorization — and both listings stopped at the ordering. The ordering does
not tell a reader that `viewer` is a floor rather than a label, so both now
carry the one-sentence rule, and the Authorization entry names the middleware
and the orgGroup helper that make it structural.

The MCP page described tool gating as scope-only, which stopped being true
when the role floor landed. A viewer holding a full-`mcp` token now gets a
-32002 refusal on every mutating tool, and the page a user would actually
check said their scope was sufficient. It now shows the exact error, and says
which of the two fixes applies: the token carries only mcp:read, or the person
behind it is a viewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ly but nothing enforces it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb
fclairamb enabled auto-merge (squash) September 6, 2026 22:14
CI caught two real defects in the batch, both in the demo flow, plus one
test that asserted something the environment cannot satisfy.

**The demo entered the wrong organization.** routeResult fell back to the
org whose login page the visitor was on when the login response carried no
explicit resolution. For an ordinary sign-in that is right — you land on
/orgs/<yours>/login and log into <yours> — but the live-demo button is
offered on EVERY org's login page and signs you into `demo`, so a visitor
arriving from /orgs/acme/login was dropped into `acme`, an org the demo user
is not a member of. routeResult now takes the org the credentials were
actually for; every existing caller keeps today's behaviour via the default.

**The first check a visitor creates left them stranded.** The create page
PUTs the form's selected notification channels immediately after the check
is created, and the demo org's sink webhook is a default channel, so the
form preselected it. That PUT is not on the demo allowlist and is refused,
which aborted the submit before it navigated — the check WAS created, and
the visitor was left looking at the form with no sign of it. That is exactly
the moment the demo exists to deliver. A demo session now preselects no
channels, and a refused channel attach no longer aborts a create that
already succeeded (only DEMO_READ_ONLY is swallowed; for an ordinary user a
dropped binding is still a real error worth surfacing).

**The footer version assertion could not pass in CI.** It required a semver,
but the binary is stamped from `git describe --tags --always` and CI checks
out without tags, so the server there reports a bare commit sha. The shape
under test is "brand, space, v-prefixed version" — how a build happens to be
versioned is not that test's business.

The demo E2E now waits for the demo org rather than reading the URL once the
banner appears: the app settles on the demo org but transiently passes back
through the originating one, so a single read caught the intermediate value.
Asserting the destination is also the point — it is the regression above.

Verified against a test-mode side-car: demo-account and login are 26/26
green, dash0 lint holds at its 42-error baseline with none new, and unit
tests are 1372/1372.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb
fclairamb merged commit 1057376 into main Sep 6, 2026
13 checks passed
@fclairamb
fclairamb deleted the batch/2026-09-06 branch September 6, 2026 23:37
@fclairamb fclairamb mentioned this pull request Sep 6, 2026
fclairamb added a commit that referenced this pull request Sep 6, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.25.0](v0.24.0...v0.25.0)
(2026-09-06)


### Features

* a public live demo, a read-only viewer role, and links out of the
login footer ([#336](#336))
([1057376](1057376))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant