Skip to content

Batch 2026-07-08: 24 specs (enterprise auth, session/auth fixes, monitoring UI) + infra fixes - #118

Merged
fclairamb merged 239 commits into
mainfrom
batch/2026-07-08
Jul 9, 2026
Merged

fclairamb merged 239 commits into
mainfrom
batch/2026-07-08

Conversation

@fclairamb

@fclairamb fclairamb commented Jul 9, 2026 •

Copy link
Copy Markdown
Owner

Integration branch for the 2026-07 spec batch: 24 specs implemented, QA'd, and each gated behind an independent completeness/security audit before archiving, plus two infrastructure fixes. 225 commits.

Enterprise auth (largest item — staged in 3 audited parts + cross-cutting review)

  • Generic OAuth2/OIDC provider (discovery, ID-token validation via go-oidc, config UI). A security audit caught and fixed an account-takeover vector — auto-linking by email is now gated on a real email_verified: true claim, matching Google/Discord.
  • SAML 2.0 SP (SP-initiated, crewjam/saml; signature/Conditions/Audience/Recipient/InResponseTo all enforced; metadata endpoint; config UI).
  • LDAP/AD bind auth on the password-login path (search-then-bind, RFC-4515 injection escaping, empty-password/unauthenticated-bind guard, local-password-first fallback that can't lock out the super-admin, config UI).
  • All three share the maxSsoUsers entitlement counting and UserProvider linking; global-only config, one instance each.

Auth / session correctness

  • Per-org session duration + token-refresh chain fix — sessions no longer die ~1h after login; adds auth.session_max_duration (system param + per-org override); fixes several silent refresh failures and an OAuth handoff dropping the refresh token.
  • Create-org from /no-org now mints an org-scoped session token (previously every API call 403'd for a first-time org creator).
  • auth.Service read a frozen AuthConfig snapshot — per-org overlay changes (registration-disabled toggle, session cap) were silently ignored because the Service cached config at construction; it now reads the overlaid fields live.
  • Zero-org session logged out almost immediately — a session for a user with no org membership 401'd on /auth/me and was bounced to login; /auth/me now returns no-org info and the dash keeps a zero-org session alive in validateSession.
  • Deep link lost after login — every post-login success path (orgRedirect, default, org-picker, and the already-authenticated effect) now resolves its destination through one shared resolveDestination helper, so a deep link survives login instead of dumping the user on the org root. Tightened the open-redirect guard to a same-origin relative-path check (rejects absolute / //host URLs); verified OAuth redirect_uri round-trips through the callback.

Monitoring / dashboard

  • Multi-region response-time chart: failures now show red segments + always-visible failure dots even on dense views.
  • Colored status badges in "Checks at a glance" and the pinned-result box.
  • Uptime-bar badge: fixed truncated history (7d/30d/90d now show full range) with a retention-derived query safety cap.
  • created lifecycle results no longer swallowed by aggregation.
  • Incident ack magic-link page: restyled + countdown redirect (XSS-safe URL interpolation).
  • Slack channel/user pickers: cursor pagination so large workspaces are fully searchable.
  • validateSearch boolean query-params (showSuppressed, graphFull) fixed (string-vs-bool coercion) + earlier 07-06/07-07 specs.

Test / tooling

  • membership-requests E2E — disambiguated an ambiguous "save" button selector that made the Playwright spec flaky.

Infrastructure fixes (this session)

  • devloop rotating log hard-cap — a newline-less/hot-looping child could grow logs/backend.log without bound (this filled the disk); now bounded to 2*maxSize*(backups+1).
  • goconst lint — extracted apiKeyName constant so the whole-repo golangci-lint CI gate passes (only-new-issues is disabled in CI).

Each spec was archived only after a fresh, skeptical, independent audit returned clean; security-sensitive specs (auth) went through multiple audit+fix rounds.

🤖 Generated with Claude Code

fclairamb and others added 30 commits July 6, 2026 21:15
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a one-click MCP connector setup experience: a new "AI assistants"
dashboard page (Organization section) with the instance MCP URL, per-client
install affordances (Claude, Claude Code, Cursor, VS Code, generic/other),
read-only-scope and self-hosted-caveat callouts, plus a docs rewrite that
leads with the URL-only OAuth flow and demotes the manual bearer-token flow
to a headless/CI fallback section.
…name

Every incident.* event (created/escalated/escalation_failed/resolved/
reopened/acknowledged/unacknowledged/snoozed/unsnoozed, single + group
variants) now sets CheckUID on the event row and includes check_uid,
check_slug, check_name in the payload. Previously acknowledged/unack/
snoozed/unsnoozed had none of these, and created/resolved/reopened had
check_uid+check_slug but not check_name.

emitEvent now sets event.CheckUID from incident.CheckUID in one place,
covering the created/escalated/resolved/reopened funnel. The four
direct-event paths (ack/unack/snooze/unsnooze) and the escalation-step
job's soft-failure event each get a best-effort GetCheck lookup so the
payload can carry check_slug/check_name too.
activation.Emit gains a trailing variadic extra ...models.JSONMap param.
Two reserved keys (_incident_uid, _check_uid) are popped off and set on
the event row (IncidentUID/CheckUID) instead of being written to Payload;
everything else merges into the stored payload alongside "source". All
existing call sites keep compiling unchanged since the new param is
optional.

Wires it up for:
- org.activation.first_notification_configured (channel creation + Freebox
  pairing in integrations/service.go): payload gains channel_uid,
  channel_name, channel_type from the just-created Connection.
- org.activation.first_incident_paged (job_notification.go): links the
  event to the incident/check that triggered the first page via the
  reserved UID keys, plus check_slug/check_name in the payload.
…ctivity

event-display.tsx: getEventCheckName now falls back to payload.check_slug
when check_name is absent (covers pre-existing incident events that only
ever captured the slug). Adds getEventChannelName/getEventChannelUid for
the notification-channel enrichment, and a distinct Rocket icon for
org.activation.* milestones (previously fell through to the generic
Calendar).

RecentActivityList (dashboard card) now renders BOTH the incident link
and the check link when their UIDs are present, instead of only ever
showing a single check link gated on payload.check_name. The channel-name
variant of "First Notification Configured" links the channel name to its
integration edit page when channel_uid is present, falling back to the
generic sentence for historical events lacking the new payload fields.

i18n: fill every missing types.*/descriptions.* key for the full 18-value
EventType constant list (escalation_failed, reopened, unacknowledged,
snoozed, unsnoozed, status_update.*, signup_completed,
first_result_received, first_incident_paged) across en/de/es/fr, keeping
this repo's existing all-locales-key-parity convention. Leaves the
pre-existing incident.opened key untouched (no matching backend constant,
out of scope here).
…nline

CopyableValue and CopyErrorButton in notifications.$notificationUid.tsx
duplicated the same clipboard mechanics already factored out into
copyable-code.tsx's copyToClipboard(). Extract a shared CopyableInline
primitive (inline value + button, or bare button via inline={false}) and
register it on the design reference page.
…ifecycle

Adds event_check_enrichment_test.go covering the full incident lifecycle
through ProcessCheckResult (created, resolved, escalated, reopened) and
the direct-event paths (acknowledge, unacknowledge, snooze, unsnooze),
asserting every emitted event has CheckUID set on the row plus
check_uid/check_slug/check_name in the payload. Also pins the
no-backfill contract: a hand-built "old shape" event round-trips through
ListEvents with no row CheckUID and no synthesized check_name.

Adds two activation tests: extra payload + reserved UID keys
(_incident_uid/_check_uid) round-trip onto the event row without leaking
into the stored payload, and the plain channel-payload shape used by
first_notification_configured. Required creating real check/incident
rows for the FK-backed columns — an initial version using placeholder
UUIDs passed vacuously (Emit swallows the FK failure and inserts nothing,
which ListEvents then reported as zero events, not a failure).

Also splits Emit's extra-merging loop into a small applyExtra helper to
keep cyclomatic complexity under the linter's threshold.
Extends mockDashboard() to accept an events fixture, and adds three
dashboard E2E tests: an incident.created row renders both the incident
link and a named check link; a historical event missing check_name
falls back to check_slug for the check link text; and a real channel
created via the API surfaces its name as a link to the integration page
in the first_notification_configured row.

All three verified passing against the local :4000 test-mode devloop.
integrations/service_test.go: TestCreateChannelEmitsEnrichedActivationEvent
drives CreateIntegration through a real webhook creation and asserts the
resulting first_notification_configured event carries channel_uid/
channel_name/channel_type.

test/integration/incident_notifications_audit_test.go:
TestFirstIncidentPagedEventIsEnriched reuses the existing
notificationAuditSetup/triggerIncident harness to run a real
NotificationJobRun end to end and asserts first_incident_paged links to
the triggering incident/check (IncidentUID/CheckUID on the row) with
check_slug/check_name in the payload.
…07-06-2

Recent activity: incident/check links, activation event rendering,
notification channel name.

- Backend: every incident.* lifecycle event (created/escalated/
  escalation_failed/resolved/reopened/acknowledged/unacknowledged/
  snoozed/unsnoozed, single + group) now sets CheckUID on the row and
  includes check_uid/check_slug/check_name in the payload.
- activation.Emit gains an optional extra payload (variadic
  models.JSONMap) with two reserved keys (_incident_uid/_check_uid) that
  link the resulting event row to an incident/check. Wired up for
  first_notification_configured (channel_uid/channel_name/channel_type)
  and first_incident_paged (incident/check linkage + check_name).
- Frontend: RecentActivityList renders both the incident link and a
  named check link (falling back check_name -> check_slug -> generic
  label); first_notification_configured shows the channel name linked to
  its integration page; a distinct icon for org.activation.* milestones;
  every EventType constant now has an i18n label across en/de/es/fr.
- Tests: 9 Go tests pinning the enriched payloads across the incident
  lifecycle + both activation call sites, 3 new Playwright E2E tests for
  the dashboard Recent activity card.
Consolidates CopyableValue and CopyErrorButton in notifications.$notificationUid.tsx
into a shared CopyableInline primitive in copyable-code.tsx.
…ntion)

Postgres: DISTINCT ON (check_uid) + organization_uid predicate to ride
results_raw_idx. SQLite: equivalent ROW_NUMBER() OVER (PARTITION BY
check_uid ...) window-function CTE, joined back to the base table so the
rn column never leaks into the models.Result scan. Both backends now stay
behaviorally identical per the sync-pg-to-sqlite convention.

Threads organization_uid through the three call sites (ListChecks, GetCheck,
checkworker's executePassiveJob) and the mock in slack_test.go.

Previously both backends fetched every raw row for the requested checks and
kept only the newest per check in a Go loop — with the default 24h raw
retention, a 1-minute check could return up to 1,440 rows to keep 1.
… scoping

Seeds 50 raw rows across two checks on both backends and asserts the map
returns exactly one row per check (the newest by period_start), plus a
second test proving the organization_uid predicate actually scopes results
and doesn't leak across organizations.
fclairamb and others added 23 commits July 9, 2026 19:47
…shot

The auth Service froze a value copy of config.AuthConfig in NewService,
before InitializeSystemConfig applies the system-parameter overlay onto the
shared *config.Config. Fields the overlay mutates were therefore stale.

Audit of every s.cfg.X read on the Service:
- RegistrationEmailPattern (Register): overlaid -> read live fullCfg.Auth.
  Fixes the visible bug where registration reported enabled but 403'd.
- SessionMaxDuration (resolveSessionMaxDuration): overlaid -> read live;
  corrected the misleading doc-comment that claimed s.cfg was runtime-current.
- JWTSecret (5 sign/validate sites + passkey_session.go 2 sites): overlaid AND
  auto-generated post-NewService by ensureJWTSecret -> read live at sign AND
  validate. The rest of the system (unsubscribe, feedback/files signed URLs,
  incident magic links, email) already uses the live secret; the frozen Service
  copy meant session JWTs were signed with the empty/default pre-overlay secret.
- AccessTokenExpiry, RefreshTokenExpiry: NOT overlaid -> left frozen, with a
  struct comment documenting the boot-only-vs-live split.

OAuth provider config (Google/GitHub/... and OIDC/SAML/LDAP) lives at the top
level of config.Config and its provider services hold a live *config.Config, so
no provider field is read from the Service's frozen snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
golangci-lint (goconst) flagged the repeated "name" map-key literal in
client.go. CI runs golangci-lint over the whole server tree with
only-new-issues disabled, so this pre-existing finding fails the
backend-lint job. Extract it into the existing apiKey* constant block,
mirroring apiKeyChannel/apiKeyText/etc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…of 401

GetUserInfo unconditionally resolved claims.OrgSlug via GetOrganizationBySlug,
which errors with ErrOrganizationNotFound (mapped to 401) for the empty slug a
zero-org session carries — silently destroying a legitimate session on the next
page load. Split out getUserInfoNoOrg: for an empty OrgSlug, skip the org and
membership-role lookups, return Organization: nil and role "" (RoleSuperAdmin
for superadmins), mirroring completeLogin's resolvedOrg==nil branch. The
non-empty-OrgSlug path is unchanged. MeResponse.Organization gains omitempty so
a nil org is an absent field, not null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MeResponse.organization is now optional (matches AuthResponse). The up-front
forced refresh for tokens missing expires_at now only fires when a refresh
token exists — a zero-org session has none by design, so the unconditional
refresh escalated to logout. With no refresh token, /auth/me is the arbiter: a
200 (org absent) keeps the session, a 401/403 still clears it in the catch, so
zombie-loop protection for legacy sessions with a refresh token is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ves the user holding a token scoped to no org
…ow unbounded

The rotating log writer preferred to cut the active file at a line boundary at
or after maxSize, but when an incoming chunk contained no newline it appended
the whole chunk without rotating (rotationCut returned -1). A backend
hot-looping on an error — or emitting one pathologically long line — therefore
grew logs/backend.log without any bound; a stuck dev-test loop produced a
multi-GB backend.log this way and helped fill the disk.

Enforce a hard ceiling of 2*maxSize: the line-boundary preference still applies
below it, but once the active file would reach the ceiling it is force-cut
mid-line, so total on-disk size stays bounded by 2*maxSize*(backups+1) whatever
the child emits. Adds TestRotatingWriterHardCapsNewlinelessStream covering the
previously-uncovered no-newline path (would breach the ceiling pre-fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	server/cmd/devloop/rotate.go
#	server/cmd/devloop/rotate_test.go
#	server/go.mod
#	server/go.sum
… is still valid

The zombie-loop fix made every 4401 close run refresh-or-give-up, so a
server-initiated 4401 on a token that has NOT expired (transient server
state, proxy hiccup, restart) tore the session down instead of reconnecting.
This regressed the pre-existing live-updates e2e "reconnect after a
server-initiated close (4401-style)" test.

Gate the refresh-or-give-up path on the token being unprovable-as-live
(expired or unknown expiry), mirroring the run() loop's pre-dial check: when
the access token still has time left, a 4401 is transient — reconnect with
the current token. The zombie case (past/absent expiry + refused refresh)
still gives up. Adds a unit test for the still-valid-token reconnect path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h the Service

TestRegistrationOverlayPathEnablesRegister models the real boot ordering: the
Service is constructed with an EMPTY AuthConfig, then registration_email_pattern
is applied ONLY by mutating the shared *config.Config pointer afterward (as the
systemconfig overlay does between NewServer and route setup). Register must then
succeed, and GET /api/v1/auth/providers registrationEnabled must agree in both
the unset (both off) and overlay-set (both on) states. Baking the pattern into
the AuthConfig passed to NewService would not reproduce the bug.

TestSessionMaxDurationSystemWideViaOverlay pins that a system-wide
session_max_duration applied via the overlay (no org override) is honored.
@fclairamb
fclairamb enabled auto-merge (squash) July 9, 2026 21:30
@fclairamb fclairamb changed the title Batch 2026-07-08: 20 specs (enterprise auth, session/auth fixes, monitoring UI) + infra fixes Batch 2026-07-08: 24 specs (enterprise auth, session/auth fixes, monitoring UI) + infra fixes Jul 9, 2026
@fclairamb
fclairamb merged commit 4f9cfeb into main Jul 9, 2026
11 checks passed
@fclairamb
fclairamb deleted the batch/2026-07-08 branch July 9, 2026 21:45
@fclairamb fclairamb mentioned this pull request Jul 9, 2026
fclairamb added a commit that referenced this pull request Jul 9, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.2.2](v0.2.1...v0.2.2)
(2026-07-09)


### Features

* **auth:** enterprise SSO — generic OAuth2/OIDC provider (discovery +
ID-token validation, `email_verified` gating against account-takeover),
SAML 2.0 SP (`crewjam/saml`, full assertion validation, metadata
endpoint), and LDAP/AD bind auth (search-then-bind, RFC-4515 escaping,
super-admin lockout guard), all sharing `maxSsoUsers` entitlement
counting and `UserProvider` linking
([#118](#118))
([4f9cfeb](4f9cfeb))
* **auth:** per-org configurable max session duration —
`auth.session_max_duration` system parameter with a per-org override on
the org settings API
([#118](#118))
([4f9cfeb](4f9cfeb))
* **dash0:** multi-region response-time chart shows red failure segments
and always-visible failure dots even on dense views
([#118](#118))
([4f9cfeb](4f9cfeb))
* **dash0:** colored status badges in "Checks at a glance" and the
pinned check-result box
([#118](#118))
([4f9cfeb](4f9cfeb))
* **incidents:** restyled ack magic-link page with a countdown redirect
to the incident
([#118](#118))
([4f9cfeb](4f9cfeb))


### Bug Fixes

* **auth:** sessions no longer die ~1h after login — repaired the
token-refresh chain and an OAuth handoff that dropped the refresh token
([#118](#118))
([4f9cfeb](4f9cfeb))
* **auth:** creating an org from `/no-org` now mints an org-scoped
session token instead of leaving every API call 403'ing
([#118](#118))
([4f9cfeb](4f9cfeb))
* **auth:** `auth.Service` reads overlaid `AuthConfig` fields
(registration-disabled, session cap) live instead of from a frozen
snapshot ([#118](#118))
([4f9cfeb](4f9cfeb))
* **auth:** a zero-org session is kept alive — `/auth/me` returns no-org
info instead of 401-logging-out the user
([#118](#118))
([4f9cfeb](4f9cfeb))
* **dash0:** deep links survive login — every post-login path resolves
through a shared `returnTo` resolver with a tightened same-origin
open-redirect guard
([#118](#118))
([4f9cfeb](4f9cfeb))
* **dash0:** uptime-bar badge shows the full 7d/30d/90d history instead
of only 1/2/3 days
([#118](#118))
([4f9cfeb](4f9cfeb))
* **dash0:** `validateSearch` boolean query-params (`showSuppressed`,
`graphFull`) no longer silently no-op from string-vs-bool coercion
([#118](#118))
([4f9cfeb](4f9cfeb))
* **checks:** `created` lifecycle results are no longer swallowed by
aggregation ([#118](#118))
([4f9cfeb](4f9cfeb))
* **slack:** channel/user pickers paginate (cursor) so large workspaces
are fully searchable
([#118](#118))
([4f9cfeb](4f9cfeb))
* **devloop:** hard-cap the rotating log so a newline-less/hot-looping
child can't grow `logs/backend.log` without bound
([#118](#118))
([4f9cfeb](4f9cfeb))
* **deps:** update go dependencies (non-major)
([#113](#113))
([8d8debd](8d8debd))
* **deps:** update go dependencies (non-major)
([#115](#115))
([d53dafa](d53dafa))
* **deps:** update go dependencies (non-major)
([#116](#116))
([179d2f5](179d2f5))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
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