Repository navigation
feat: a public live demo, a read-only viewer role, and links out of the login footer - #336
Merged
Merged
Conversation
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>
… 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
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>
Merged
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
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
vieweris finally read-only — a real privilege fixThis is a behaviour change.
vieweris 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.RequireOrgWritefloor, applied structurally in theorgGrouphelper, so a new org route cannot ship without it.userstops writing the moment they are demoted./users/me/*) and your own PAT (POST /tokens).viewerholding a full-mcptoken couldcreate_checkstraight past REST middleware.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-urlendpoints, MS Teamslink-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
demoorg 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 arecreated_by = NULLand therefore untouchable by construction, with no "protected" flag to forget.RequireAuth/RequireMCPAuth— the one chokepoint every authenticated route passes — and matches on the router pattern, never the raw path.maxChecks,maxChecksPerMinute), not new machinery./api/v1/fakesink.SP_DEMO_ENABLEDdefaults tofalse— merging this exposes nothing until it is switched on.3. The login footer says what it is and links somewhere
v0.24.0as 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 exposesdeploymentModeon/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
019_v0_25_0on both backends:users.demoandchecks.created_by.created_byis now recorded for every check creator, not just demo sessions — useful audit data in its own right, and exposed ascreatedBy.../solidping-billingis untouched.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