Releases: cboxdk/cbox-id
Release list
v0.35.0 — a console that explains itself
The console assumed you already knew what an IdP does. This release stops it assuming
that — and fixes the CI that had been red on main for a reason no developer machine
could reproduce.
Onboarding and help
- A "?" on every page title opens two or three sentences in plain language, and
links to a full guide where one exists. Twelve new admin guides ship under
docs/guides/. Nothing links to a page that does not exist: a topic with no guide,
or a deployment withDOCS_BASE_URLblanked, renders no link at all. - The setup checklist is measured, not asserted. Every step is derived from live
state, so it ticks itself whether the work was done from the checklist, the page, or
the API. The old card hardcoded "Organization created ✓" and had three items that
could never tick. Steps an organization is not entitled to are dropped rather than
shown locked, so the list can actually reach 100%. - A guided first run at
/get-started, with the same measured steps given room to
explain themselves. Reachable from Settings after it is dismissed. - Empty states teach instead of shrugging: what the thing is, why you want it, and
the first three steps.
Navigation that means what it says
- Nav label == page heading == browser title. Six pages disagreed with the sidebar
entry that reached them ("Stored tokens" → a page headed "Token vault"). The SCIM pair
became Sync users in / Sync users out, which says which way people move. - One rail area per subject. The console had grown twelve top-level areas because
each module minted its own; analytics, compliance and risk now join the area that
already owns their subject. Area orders are unique, so the rail no longer reshuffles
depending on which modules are enabled. - The activity log names people. Actor and target were raw ULIDs, so the row saying
a member was added did not say who.
Breaking
- One env prefix, not two. The
ID_*fallback kept alongsideCBOX_ID_*in 0.34.0
is removed — 37 config reads carried both spellings. See Upgrading below.
Fixed
- CI, red on
mainand unreproducible anywhere else: the suite needed a Redis nobody
starts.cboxdk/laravel-queue-metricsships enabled with a Redis storage driver and
a listener that writes on every job dispatch. Developer machines usually have a Redis
running, and CI does not — so the suite looked green everywhere except the one place
that mattered. Metrics are now off for the suite, which is verified with no Redis
reachable at all. The test environment was also made authoritative while proving it:
tests/bootstrap.phpbeats anything the host exports, because phpunit.xml's<env>
does not write$_SERVERand Laravel reads that first. - The parallel suite. 48 tests failed under
--parallelwhile passing serially:
helpers declared in one test file and called from another are simply absent in the
worker that needs them. Shared fixtures moved totests/Pest.php, with a tokenising
guard so the next leak fails the build. - The accessibility guard grew from 8 console pages to 18 — all WCAG 2.1 A/AA clean.
Upgrading
Breaking: CBOX_ID_ is now the only env prefix. 0.34.0 renamed the vendored
modules' variables and kept the old ID_* names as a fallback; that fallback is gone.
An ID_* variable is now ignored rather than refused, so the setting silently falls
back to its default — CBOX_ID_DEVICES_ENABLED to off, CBOX_ID_COMPLIANCE_SINK
to null. Grep your deployment before upgrading:
grep -rn 'ID_[A-Z]' k8s/ .env | grep -v CBOX_ID_
UPGRADING.md lists the four
affected families.
php artisan migrate — one new table (onboarding_dismissals), holding the only thing
the checklist cannot derive: an admin having put it away.
Optional: point DOCS_BASE_URL at a rendered docs site (and clear DOCS_LINK_SUFFIX)
once one exists, or blank it on an air-gapped deployment to drop every outbound link.
v0.32.0 — one open app
The open-core split is gone. Cbox ID self-hosts with no licence key, no limits, and every capability present; billing becomes something you add where you actually bill someone.
Added — the five console modules (analytics, compliance, connectors, risk-plus, whitelabel) are part of the application; CBOX_ID_ENTITLEMENTS=open is the new default; a relational analytics event store for deployments with no column store; CI runs MySQL 8.4 and PostgreSQL 17.
Fixed — four defects the new engine coverage surfaced, two of them live bugs on an engine that ran in production: the Apps & API keys page 500ing on MySQL, and the environment-scope guard refusing legitimate writes on PostgreSQL because thirteen ulid() columns compiled to blank-padded char(26).
Operators upgrading: read UPGRADING.md — a metered deployment must now say so explicitly, and the five plugin packages must be removed.
v0.31.0 — account lifecycle audits itself
Picks up laravel-id v0.64.0, where Accounts::suspend() and reactivate() take an actor and record the audit internally. The operator screen's own audit block is gone — an audit written at the call site is one a second caller can forget, and this screen was the only caller there had ever been.
Known cost, tracked rather than hidden: the package scopes that entry to the system chain (an account sits above the tenancy boundary, consistent with PlatformOperators), so an account's own activity view no longer shows its suspension. The test now asserts both halves so the gap cannot quietly disappear.
v0.30.0 — token privilege fixes
Picks up laravel-id v0.63.0: /user-tokens/introspect required no scope (any environment API key could introspect every PAT in its environment), and an empty resource-family allow-list granted every family.
Both are breaking upstream and both have zero blast radius here — verified against the production database, which holds 0 environment API keys and 0 user API tokens, and this app calls neither changed contract.
Also carries a table rename (password_reset_tokens → cbox_id_password_reset_tokens, which collided with Laravel's own skeleton migration). Requires php artisan migrate — on PostgreSQL a rename, not a rewrite.
v0.29.0 — identifier columns off padded char
Picks up laravel-id v0.62.0, which moves 225 identifier columns across 81 tables off PostgreSQL's blank-padded char. Requires php artisan migrate.
This deployment is not currently affected — every id in production is a full 26-character ULID, verified directly — so the migration is preventive and changes no data. On PostgreSQL foreign keys are preserved throughout, so there is no integrity window.
The reason it matters: the framework's test suite had never run against PostgreSQL, the engine this app runs on. It now does, 1359 passed / 0 failed, up from 1020 / 338 failed.
v0.28.0 — honest destructive actions, hardened API
Security: a "deleted" organization kept authenticating its members, consenting, and minting tokens — the status was written by the console and enforced by nothing. Latent rather than breached (production had none in that state). Now enforced through an exhaustive match with no default, so a status added upstream fails analysis rather than defaulting to allowed.
The console claimed to delete users and did not. There is no foreign key on user_id anywhere, so the guard clause meant to catch leftover records was unreachable — every "User deleted" left the person's sessions, passkeys, TOTP seeds and full SCIM payload behind, with no event and no audit entry. Removed rather than repaired; deactivation is the honest capability today.
Compliance: the docs inherited a GDPR Art. 17 claim with no implementation behind it. Corrected.
API hardening before the Terraform/CLI expansion: the documented error envelope is now actually honoured (validation failures rendered Laravel's default shape, with no error key that both specs declare required), rate limits bucket per credential instead of per IP (one NAT meant one shared bucket across tenants), and every API response in the suite is validated against its documented schema — which found eight routinely-null fields typed as non-nullable, plus a documented field the API has never returned.
Operators can now suspend accounts.
v0.27.0 — the risk trail
Monitor mode has been running for weeks and producing nothing: every risk decision went to Log::info, production runs LOG_CHANNEL=stderr with no aggregation, and each rollout destroyed the corpus. There was no way to set an enforcement threshold from evidence.
Risk decisions now persist to a risk_decisions table with the score, outcome, the mode the decision was made under, and the per-signal weighted points — so a signal can be re-weighted, not just the threshold moved. docs/security/adaptive-risk.md carries five tuning queries, each executed against real PostgreSQL.
Deliberately not the audit chain: those appends lock the chain head, and these writes happen before authentication, so an unauthenticated burst could serialise the tamper-evident trail of everyone who is authenticated.
IP and email are both keyed HMAC pseudonyms; the mail domain stays clear for provider-abuse patterns. 90-day retention. RISK_MODE stays monitor — enforcement is the decision this data exists to inform.
v0.26.0 — all six hook points in the console
Requires cboxdk/laravel-id ^0.59.1.
Added
- Five more inline hook points are now registrable from the console —
post_login,
pre_registration,post_registration,pre_password_change,
post_password_change, alongside the existingtoken_minting. The console reads
HookPoint::cases(), so they appeared as soon as the framework shipped them.
Fixed
- The hook picker rendered the raw PHP case name, so an admin chose between
"TokenMinting" and "PrePasswordChange", and the list showed the machine value
token_minting. Both now use the framework'slabel(), and the picker shows
description()beside each option — which states whether that hook can refuse the
operation, the fact you need before wiring a URL that can stop people signing in.
v0.25.0 — signup abuse controls
Signup had no bot protection, and production showed the bill: five of eight accounts
were Gmail dot-abuse bot signups, each of which had provisioned a full environment and
none of which ever verified an address.
Security
- A self-serve signup no longer provisions an environment up front. The account, its
home organization, its owner and its first project are created immediately; the
environment — the routable IdP whose signing key is warmed on creation — is released
only when the owner opens the emailed verification link (new
App\Platform\SignupProvisioner, consumed byEmailVerificationController). This does
not make bulk signup harder, it makes it worthless. Idempotent: a replayed link never
mints a second environment, and a suspended account is not revived by one. - A risk-triggered CAPTCHA on signup. An
Outcome::Challenge/StepUpfrom the
risk scorer now demands a Cloudflare Turnstile token, verified server-side against
siteverify(newApp\Platform\Turnstile); previously the signup form consulted only
shouldBlock(), so a Challenge outcome did nothing at all. A missing or rejected token
is a field error, never a 500. Deliberately not an always-on CAPTCHA — the friction
lands on the submissions the scorer flagged. - Both keys unset (
CBOX_ID_TURNSTILE_SITE_KEY/CBOX_ID_TURNSTILE_SECRET_KEY) means
the feature does not exist: no widget, no Cloudflare script, and the CSP keeps its
strictscript-src 'self' 'unsafe-eval'.https://challenges.cloudflare.comis added
toscript-srcandframe-srconly on a deployment that configured it.
Changed
risk.modeis untouched and still ships asmonitor. Flipping it toenforceis a
production tuning decision that needs the monitor-mode data behind it; this release
only makes the Challenge branch mean something once it is flipped.- The workspace launchpad explains the wait, rather than showing a project with no
environments and no reason why. It now also names the address the confirmation went to
and the sender to look for, and says the link is good for 24 hours.
Added
- A resend control on the launchpad banner (
App\Platform\MemberEmailVerification,
App\Platform\Enums\VerificationResendOutcome). Holding the environment back until the
address is proven put a real owner's whole account behind one email; without a resend,
losing it or letting the 24-hour token lapse left an account with a member, a project
and no way forward. The action takes anAccountMember, never an address, so there is
no input to steer it at someone else's inbox; it retires every previously-issued link
before minting the next one (single-use is not the same as single-live); it answers
identically whether or not the address is already confirmed, so the button is not a
verification oracle; and it is a no-op once the environment is up. Throttled at 3 per
10 minutes per member — outbound mail is the abusable resource, and the member id is
the key nobody can rotate.
v0.24.0 — MembershipRole adoption
Requires cboxdk/laravel-id ^0.58, which made MembershipRole a type rather than a
string on the membership and invitation contracts. Adopting it surfaced four latent
bugs the type change had nothing to do with.
Fixed
- Any page listing pending invitations would have 500'd.
Invitation::$roleis now
cast to the enum upstream, and two templates still calleducfirst()on it —
environment/organizations/show.blade.phpandmembers.blade.php. Neither had a test. - The role dropdowns never pre-selected a member's current role, because
@selected($m['role'] === $val)compared an enum case to a string. Pre-existing. - Role validation is now deny-by-default against the assignable set rather than merely
a valid enum case.MembershipRole::tryFrom('viewer')returns a real case that this
console must never assign, so a non-null check was not sufficient —'viewer'is now
refused exactly like'archduke'. - The three JS-invoked role actions had no field to report into and previously used
in_array(); they now go through the same deny-by-default parse.
Changed
- New
App\Platform\OrgRoles— the subject-plane analogue of the framework's
AccountRole::assignable(), exposing the assignable set, a validation rule, a message
naming the accepted roles, and a parse for actions with no field. All four role selects
now render from it instead of hand-written option triples, and each gained an@error
sink so the message is visible. cboxdk/laravel-idfloor raised to>=0.58— the app does not run below it.
Testing
- Four regression tests drive each affected form with both an unknown value and a
real-but-unassignable enum case, asserting a field error rather than a 500, no side
effect, and no mail. Mutation-checked: swapping the guard back tofrom()makes them
fail with theValueErrorat the component line, so they pin the distinction rather
than the presence of a rule.