Skip to content

Fix the phpseclib conflict: own the Solid-OIDC client, verify the callback, and adopt release/v* tagging - #9

Merged
roncodes merged 11 commits into
mainfrom
release/v0.0.8
Sep 23, 2026
Merged

roncodes merged 11 commits into
mainfrom
release/v0.0.8

Conversation

@roncodes

@roncodes roncodes commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Why

Fleetbase could not resolve its dependencies with this extension installed:

- fleetbase/core-api 1.6.63 requires laravel/socialite ^5.31
- fleetbase/solid-api 0.0.7 requires jumbojett/openid-connect-php ^1.0.2
- jumbojett/openid-connect-php v1.0.2 requires phpseclib/phpseclib ^3.0.7
- laravel/socialite ^5.31 requires phpseclib/phpseclib ^4.0
- You can only install one version of a package

Upstream Jumbojett has not migrated to phpseclib 4 — master still declares ^3.0.7 and still imports phpseclib3\Crypt\RSA. phpseclib 4 renamed its root namespace to phpseclib4\, so widening the constraint to ^3.0.7 || ^4.0 resolves and then fatals at runtime. Pinning Core API or Socialite back was not on the table.

The audit found the pin was bought for nothing. Jumbojett touches phpseclib in exactly one method, verifyRSAJWTSignature(), reachable only from verifyJWTSignature() / verifySignatures() — and this extension overrode the former, while the callback path (OIDCController@completeRegistration → exchangeCodeForTokens) never reached Jumbojett's authenticate() at all. Transport, token requests, client registration, session storage and discovery were all overridden too. About 150 lines of a 2,100-line dependency were in use, and phpseclib never executed on any Solid code path.

The corollary is the part that matters: the authorization callback had no ID token signature verification, no state check and no nonce check, and the WebID that drives every pod read and write was taken from an unverified JWT.

What

Jumbojett is removed and the Solid-OIDC client lives here, in server/src/Client/OpenIDConnectClient.php and server/src/Auth/.

Socialite is not the alternative, and this was checked before deciding: Solid-OIDC needs RFC 7591 dynamic client registration, RFC 9449 DPoP-bound tokens and an issuer discovered per tenant at runtime, and Laravel\Socialite\Two\AbstractProvider provides none of the three. Core API's OAuth drivers sign users into Fleetbase from fixed IdPs; Solid makes Fleetbase a client of a user's pod.

What is reused is Core API's verification half. SolidIdTokenVerifier deliberately mirrors Fleetbase\Auth\OAuth\IdTokenVerifier — same 300s JWKS cache constant, resolve by kid, one forced refetch on an unknown kid, claims returned rather than a boolean, TLS never inferred from the app environment. It diverges in two documented places: it uses firebase/php-jwt rather than lcobucci/jwt, because Core's verifier hardcodes Lcobucci\JWT\Signer\Rsa\Sha256 (RS256 only) and a Solid provider is operator-chosen with node-oidc-provider also offering PS256 and ES256; and it validates nonce, which Core has no reason to know about.

Every collaborator is injectable, which is what makes the handshake testable without a network or Redis.

Security

All of these were absent on the callback before:

  • ID token verification — signature against the provider's JWKS, iss (exact), aud, azp when aud is multi-valued (OIDC Core 3.1.3.7), expiry with a bounded 60s leeway, nonce, and sub presence.
  • state validation — server-side, single use, consumed before comparison so a forged callback cannot be retried; a mismatch voids the whole pending request.
  • Algorithm confusion blocked structurally — a key without alg is dropped rather than assumed RS256, and php-jwt rejects a header algorithm differing from the key's, so alg: none and HS256-signed-with-the-RSA-public-key both fail. Both are tested.

One vulnerability found in the code being rewritten, and fixed:

DPoP private keys were web-reachable. They were persisted with a bare Storage::put(). In a Fleetbase install filesystems.default is env('FILESYSTEM_DRIVER', 'public'), and the public disk is rooted at storage/app/public, symlinked to public/storage and served at APP_URL/storage — so solid/dpop_keys_<uuid>.json, containing the RSA private key every access token is bound to, was fetchable over HTTP (or written to an S3/GCS bucket). Keys now go to an explicitly named private disk (solid.oidc.key_disk, default local), and a key found in the old location is migrated and the exposed copy deleted, so nobody is signed out. Covered by DPoPKeyStorageTest, which I confirmed fails if the disk selection is reverted.

Also: PKCE S256 is mandatory instead of silently dropped; TLS verification is one explicit flag rather than inferred from APP_ENV (which also disabled it in any environment named local/development); OIDC requests no longer follow redirects, which could replay an Authorization: Basic client secret to another host; access and DPoP tokens are no longer written to the logs; state/nonce/code_verifier now carry a TTL where they previously had none; and the access token's cnf.jkt is checked against our DPoP key thumbprint.

Not changed, needs a data migration: solid_identities.token_response stores access and refresh tokens in plaintext via Fleetbase\Casts\Json.

Other fixes

  • DPoP htu strips query and fragment per RFC 9449 §4.2 — a proof built for a URL with parameters was unusable against a server comparing htu strictly.
  • RFC 9449 §8 use_dpop_nonce is retried once.
  • Provider discovery is cached. Every Solid request built a fresh client and refetched .well-known/openid-configuration, so one controller action touching four resources made four extra round trips.
  • Provider error on the callback is surfaced instead of becoming "missing authorization code".
  • authenticate() returns a RedirectResponse rather than header() + exit.
  • Registration declares grant_types including refresh_token; without it a provider defaults to authorization_code alone, which made the requested offline_access scope unusable. Existing registrations untouched.
  • Scopes are deduplicated — the authorization request was sending openid webid offline_access openid.

Dependencies

Removed jumbojett/openid-connect-php, all seven web-token/jwt-*, php-http/guzzle7-adapter and psr/http-factory-implementation — the web-token stack and the PSR adapters it needed had zero references anywhere in the package. Added firebase/php-jwt ^6.10|^7.0, which was already in use but relied on arriving transitively, plus ext-json / ext-openssl. php raised ^8.0 → ^8.1.

easyrdf/easyrdf and ml/json-ld are also unused today but are RDF domain libraries unrelated to this conflict, so I left them — flagging for a call.

CI

Brings this repository onto the same release path as fleetops and storefront, which it was missing entirely — merging a release branch to main produced no tag, so nothing published.

  • release.yml (new) delegates to fleetbase/fleetbase/.github/workflows/release-tag.yml@main, gated on a merged PR whose head branch is release/v* (or the legacy dev-v*). It pushes the tag and nothing else; create-release.yml and the publish jobs in ember.yml already chain off push: tags: v*. version-files is composer.json,package.json,extension.json — this repo is an ember addon plus a server, so it carries all three.
  • RELEASE.md (new) is required by that workflow, and its first line must name the version being released.
  • create-release.yml publishes RELEASE.md as the release body, matching both reference repos.
  • ember.yml and server.yml also run on PRs into a release branch, so work staged on release/v* is checked before the release PR is opened.
  • server.yml gains the Composer GitHub auth step both reference repos have, and runs the test suite again.

On that last point: composer test:unit now runs phpunit. Pest's binary resolves its autoloader from a hardcoded vendor/, which this package does not have — it sets vendor-dir to server_vendor — so pest could never start here. That is why the suite was commented out in CI and why the package had only a placeholder test.

Coverage jobs are deliberately not copied across: this package has no coverage:baseline script or scripts/coverage-summary.php to gate on.

Since this branch is release/v0.0.8 and all three manifests plus RELEASE.md agree on 0.0.8, merging this will tag and release v0.0.8.

Verification

Check Result
Combined app graph (core-api ^1.6.63 + solid-api ^0.0.8, path repos, root's php >=8.0 <8.3) resolves — 234 installs, 0 conflicts
composer why --locked phpseclib/phpseclib only laravel/socialite v5.31.0 requires ^4.0
composer why-not --locked phpseclib/phpseclib ^4.0 "4.0.1 is already installed"
phpunit on PHP 8.2.28 and 8.4.0 OK (108 tests, 208 assertions)
php-cs-fixer --dry-run every touched file clean
phpstan level max 294 errors vs 428 on main; the OIDC client went 129 → 10, and all remaining errors in new code are Laravel symbol-discovery noise (no larastan)
core-api pest --filter Auth 486 passed, 1948 assertions — no regression in shared auth code

The suite was run under PHP 8.2 specifically because that is the floor the app's combined graph resolves to (lcobucci/jwt, cuyz/valinor need ~8.2.0), inside the root's >=8.0 <8.3.

Dependency tree after

fleetbase/core-api 1.6.63
└── laravel/socialite v5.31.0
    ├── phpseclib/phpseclib 4.0.1   ← single version, no conflict
    └── firebase/php-jwt v7.2.0

fleetbase/solid-api 0.0.8
├── firebase/php-jwt ^6.10|^7.0  → v7.2.0  (shared with socialite)
├── fleetbase/core-api *         → 1.6.63
├── fleetbase/fleetops-api *
├── easyrdf/easyrdf ^1.1         → 1.1.1
└── ml/json-ld ^1.2              → 1.2.1

Tests

1 placeholder → 108 tests / 208 assertions. SolidIdTokenVerifierTest (25) covers valid RS256 and ES256 plus every rejection path; OpenIDConnectClientTest (49) drives the whole handshake with the network scripted; plus DPoPKeyPairTest (14, RFC 7638 thumbprint and RFC 9449 proof shape), DPoPKeyStorageTest (5), CachedJwksResolverTest (8), RedisStateStoreTest (7).

I found three bugs in my own code during review and reintroduced each one to confirm the new tests fail, then restored the fixes: a SETEX ... 1 that would have wiped every saved client registration a second after writing it, a missing-nonce path that silently skipped the nonce check, and the DPoP key disk.

Backwards compatibility

No user has to re-authenticate. Existing Redis client registrations are reused (key and CLIENT_NAME unchanged) and existing DPoP keys are migrated rather than regenerated. The browser flow is unchanged — addon/controllers/home.js does a full navigation, and a 302 is followed identically to the old header() + exit.

To call out:

  • exchangeCodeForTokens($code, $state) now requires $state. The signature is unchanged for source compatibility, but a null/empty state throws — without it there is no CSRF control on the callback.
  • Jumbojett\OpenIDConnectClientException → Fleetbase\Solid\Exceptions\OpenIDConnectClientException (same name, same \Exception parent). Nothing in-tree caught the old one.
  • getCodeVerifier() removed — unused in-tree, and protected in Jumbojett, so no external caller could have had it.

Needed outside this repo

Root api/composer.json: "fleetbase/solid-api": "^0.0.8". A ^0.0.7 constraint will not pick this up, since Composer treats ^0.0.x patches as potentially breaking. No Core API changes — it already ships everything this aligned with, and its auth tests pass untouched.

If FILESYSTEM_DRIVER points at S3 or GCS, check that bucket for existing solid/dpop_keys_*.json objects and delete them; the automatic migration only covers the disk the app is currently configured with.

Pre-existing issues found, not fixed here

  • PodService resolves CssAccountService (lines 121, 350) — that class does not exist anywhere in the repository, so the CSS-account pod-creation path throws on entry.
  • SolidController@getAccountIndex() ends in dd() on the live protected GET solid/int/v1/account route, which is also registered twice in routes.php.
  • SolidClient ignores the Setting::system('solid.server') values the admin server-config UI writes, so that UI has no effect. Its data_get($options, ...) fallbacks are also dead, and new SolidClient([]) throws a TypeError.
  • composer test:lint and test:types were already failing before this branch: six long-unformatted files and 428 phpstan errors at level: max (no larastan). I formatted only what I touched and left the rest, so the counts move in the right direction without an unrelated reformatting diff.

Follow-up work on this branch

Node 22 and the engine dependencies

NODE_VERSION: 22.x is now declared once at the workflow level and read by every job, matching fleetops and storefront. The build job's single-entry matrix is gone, so there is one place to change the version instead of five. fleetbase_publish_qa is removed.

Engine dependencies moved to their latest published versions:

before after
@fleetbase/ember-core ^0.3.10 ^0.3.24
@fleetbase/ember-ui ^0.3.17 ^0.4.3
@fleetbase/fleetops-data ^0.1.25 ^0.2.2

ember-ui crosses a 0.3 → 0.4 boundary, so ^0.3.17 would never have taken it. Verified with pnpm install (clean; the peer warnings are the pre-existing ember-source 5.4.1 vs older peer ranges, identical to fleetops) and pnpm run build → "Built project successfully" on Node 22. Solid imports only three symbols from these packages — ember-core's contracts, exports and utils/get-with-default — and all three still resolve.

Also added pnpm-workspace.yaml, copied from fleetops. The Ember job was failing at pnpm install with ERR_PNPM_IGNORED_BUILDS — pnpm 10 refuses to install when a dependency has an unapproved build script, and the workflow installs version: latest. fleetops and storefront both carry a file denying builds for exactly the packages this tripped on (@fortawesome/*, core-js, fsevices); solid was simply missing it, which is why its last green Ember run was 2025-12-30. Environmental drift, not this branch — it would fail the same way on main today.

The four pre-existing issues

All four are fixed here rather than deferred.

1. PodService resolved a class that does not exist. Commit 907664b ("remove CSS credential code — use OIDC tokens only") deleted CssAccountService and CssAccountController but left two app(CssAccountService::class) call sites behind. With no use statement the name resolved to Fleetbase\Solid\Services\CssAccountService, and the container threw on entry — taking out pod creation and the pod listing. Both were "try the CSS account API, fall through to the real method" wrappers, so this finishes what 907664b started and removes them; the working paths underneath are untouched. The css_email / css_password columns are now referenced nowhere, but the migration that adds them is deliberately left in place — dropping columns holding encrypted credentials is a separate, destructive decision.

2. getAccountIndex() ended in dd(). On the live, fleetbase.protected GET solid/int/v1/account route. It returns JSON now, handling the unauthenticated and unsuccessful cases the way getAuthenticationStatus() does, and the duplicate route registration is gone.

While auditing the route table for that duplicate, seven more routes turned out to point at controller methods that do not exist: SolidController@play, @getProfileData, @getSyncStatus, @syncVehicles, @syncDrivers, @syncOrders and @syncAll. Each would 500 on hit and nothing in the console calls any of them, so they are removed — the sync services stay and remain reachable through DataController@importResources, and the profile payload already reaches the console through authentication-status. Every routed method now resolves to a defined one, and that is asserted.

3. The admin server-configuration UI had no effect. saveServerConfig() wrote system.solid.server and getServerConfig() read it back, but SolidClient only ever read config('solid.server.*') — so changing the Solid host or port in the console changed nothing. Resolution is now explicit option → saved setting → config default. Two more bugs lived in the same three lines: the old code passed data_get($options, 'host') as config()'s default, and since the key is always defined a caller-supplied host was silently discarded; and public SolidIdentity $identity was typed non-nullable while being assigned from an absent option, so new SolidClient([]) raised a TypeError. saveServerConfig() now also flushes the OIDC provider memo, since the discovery base derives from the server URL and a long-lived worker would otherwise keep talking to the old provider.

4. composer test failed in all three stages. It passes now.

  • test:unit is back on Pest, via storefront's scripts/pest-runner.php. My earlier commit on this branch switched to phpunit because Pest's binary resolves its autoloader from a hardcoded vendor/ and this package installs to server_vendor. That was a workaround — storefront had already solved it properly with a runner that symlinks vendor → server_vendor for the duration of the run and removes it after. Adopted verbatim, so this package is back on the house runner. scripts/pest-bootstrap.php is deliberately near-empty and in particular does not require the autoloader: storefront's does, because its shims reference Illuminate classes at prepend time, but loading the autoloader before Pest boots leaves its reporter with nothing to print — the suite still runs and still exits 0, and the report silently vanishes. The comment in the file says so.
  • test:types is clean at phpstan level: max against a committed phpstan-baseline.neon. The 275 errors were essentially all Laravel symbols phpstan cannot discover without larastan (facade __callStatic, Eloquent magic properties, global helpers) plus untyped legacy signatures. larastan is not usable here — it needs laravel/framework, and this package requires only the illuminate/* components it uses, so it dies on an undefined LARAVEL_VERSION constant. I tried it and backed it out. The baseline holds new code to level max (nothing written on this branch is in it) and makes the debt explicit rather than leaving a permanently red command. --memory-limit=0, which this phpstan version rejects outright, is now -1 as core-api has it.
  • test:lint — the six long-unformatted files are formatted. Mechanical php-cs-fixer output, no behaviour change, tests pass unchanged either side.

CI now enforces all of it. Run Lint uses test:lint rather than lint: composer lint runs php-cs-fixer in fix mode, so in CI it rewrote files in the runner and exited 0 whatever the state of the branch — it was never a gate. Run Static Analysis is new and enforceable for the first time.

Codecov

Coverage is generated, uploaded as an artifact and sent to Codecov, following storefront. No --fail-under gate, unlike fleetops: most of this package predates its tests and sits at 0%, so a gate would only ever be red. The report is published so the number is visible and can be moved deliberately.

Today it reads 25.45% overall, and the code added on this branch is the covered part:

SolidIdTokenVerifier 95.7%
DPoPKeyPair 93.2%
OpenIDConnectClient 81.4%
CachedJwksResolver, RedisStateStore 100%

The codecov badge is in the README alongside repo / license / npm / packagist / node / php badges, in the shape fleetops uses.

Merge with PR #8

#8 was merged into this branch mid-flight and both sides touched PodService and Utils.

#8 added parse_url() guards in six places. Two were inside the CSS-credential branches this branch deletes, so those guards went with the code — the service they call was removed in 907664b and never existed at runtime. The two in getPodUrlFromWebId() and getStorageUrlFromWebId() are kept, and getUserPods() is still covered because it now reaches getStorageUrlFromWebId() rather than parsing the WebID itself. #8's corrected example comments are kept.

Utils::getSolidServerUrl() needed more than a textual resolution. #8 fixed it to build the URL from config('solid.server.*') instead of a non-existent solid.server.url; this branch separately taught SolidClient to prefer the host and port an administrator saved through the console. Left as merged, the two would report different servers — the same class of inconsistency #8 set out to fix. Utils now delegates to SolidClient::serverUrl(), and both it and the instance getServerUrl() go through one private builder.

Verification after all of the above

Check Result
composer test (lint + types + unit) passes, all three stages, for the first time
phpunit on PHP 8.2.28 and 8.4.0 OK (108 tests, 208 assertions)
pnpm run build on Node 22 Built project successfully
PHP CI on the previous push green — OK (108 tests, 208 assertions) on PHP 8.2.33
Ember CI on the previous push green after the pnpm-workspace.yaml fix
Every routed method resolves asserted
CssAccountService / css_email references none

One thing I did not change: SolidClient::request() still disables TLS verification for pod data requests based on app()->environment('local','development'). That is the data transport rather than the OIDC handshake, and tightening it is a behaviour change for local development that belongs in its own PR.

roncodes and others added 11 commits January 17, 2026 23:11
- Fix hardcoded 'solid:3000' references in PodService.php comments
- Add null safety checks for parse_url() results across multiple methods
- Fix Utils::getSolidServerUrl() to construct URL from config components
- Add JSON decode error handling in OpenIDConnectClient.php
- Improve error messages for invalid WebID formats

This commit addresses the following issues:
1. Critical: Misleading hardcoded example in getPodUrlFromWebId() comments
2. High: Missing null/error checks on parse_url() results
3. High: Configuration inconsistency in getSolidServerUrl()
4. High: Unhandled JSON decode failures in retrieve() and loadDPoPKeyPair()

All changes maintain backward compatibility while improving error handling
and code reliability.
Solid could not be installed alongside current Core API:
jumbojett/openid-connect-php pins phpseclib/phpseclib ^3.0.7 and
laravel/socialite ^5.31 requires ^4.0, so Composer refused to resolve the
application. Upstream Jumbojett has not migrated to phpseclib 4 and
phpseclib 4 renamed its root namespace to phpseclib4\, so widening the
constraint would resolve and then fatal at runtime.

The pin bought nothing. Jumbojett touches phpseclib only in
verifyRSAJWTSignature(), reachable only from verifyJWTSignature() /
verifySignatures() -- and this extension overrode the former while the
callback path (OIDCController -> exchangeCodeForTokens) never reached
Jumbojett's authenticate() at all. Transport, token requests, client
registration, session storage and discovery were all overridden too, so
about 150 lines of a 2,100-line dependency were in use.

The corollary is the important part: the callback had no ID token
signature verification, no state check and no nonce check, and the WebID
driving all pod access was read from an unverified JWT.

So the library is removed rather than replaced. Socialite is not the
alternative -- Solid-OIDC needs RFC 7591 dynamic client registration,
RFC 9449 DPoP-bound tokens and a per-tenant issuer discovered at runtime,
none of which Socialite provides -- but Core API's *verification*
conventions are reused: SolidIdTokenVerifier mirrors
Fleetbase\Auth\OAuth\IdTokenVerifier (300s JWKS cache, resolve by kid, one
forced refetch on an unknown kid, TLS never inferred from the
environment). It uses firebase/php-jwt rather than lcobucci/jwt because
Core's verifier hardcodes RS256 and a Solid provider may sign with PS256
or ES256.

Security, all previously absent on the callback:
- ID token signature, iss, aud, azp, expiry, nonce and sub are verified
- state is validated server side, single use, consumed before comparison
- alg confusion is blocked: a key without alg is dropped rather than
  assumed RS256, so alg:none and HS256-with-the-RSA-public-key both fail
- PKCE S256 is mandatory instead of silently dropped
- DPoP private keys move off the application default disk, which is the
  web-served `public` disk -- an existing key is migrated and the exposed
  copy deleted, so nobody is signed out
- OIDC requests no longer follow redirects, which could replay an
  Authorization: Basic client secret to another host
- access and DPoP tokens are no longer written to the logs
- state/nonce/code_verifier now carry a TTL; they had none
- the access token's cnf.jkt is checked against our DPoP key thumbprint

Also fixed: DPoP htu strips query and fragment per RFC 9449 4.2; the
RFC 9449 8 use_dpop_nonce retry is honoured; discovery is cached instead
of refetched on every Solid request; provider errors on the callback are
surfaced; authenticate() returns a redirect rather than header()+exit;
registration declares the refresh_token grant so the requested
offline_access scope is usable; scopes are deduplicated.

Dependencies: removed jumbojett/openid-connect-php, all seven
web-token/jwt-*, php-http/guzzle7-adapter and
psr/http-factory-implementation (none referenced anywhere); added
firebase/php-jwt, already in use but undeclared; php ^8.0 -> ^8.1.

Tests: 1 placeholder -> 108 tests / 208 assertions, verified on PHP 8.2
and 8.4. test:unit runs phpunit: pest's binary resolves its autoloader
from a hardcoded vendor/, which this package does not have, so it could
never start here -- which is why the suite was disabled in CI.

BREAKING: exchangeCodeForTokens() now requires the callback state, and
Jumbojett\OpenIDConnectClientException is replaced by
Fleetbase\Solid\Exceptions\OpenIDConnectClientException.
Brings this repository onto the same release path as fleetops and
storefront, which it was missing entirely: merging a release branch to
main produced no tag, so nothing published.

- release.yml (new) delegates to
  fleetbase/fleetbase/.github/workflows/release-tag.yml@main, gated on a
  merged PR whose head branch is release/v* (or the legacy dev-v*). It
  pushes the tag and nothing else; create-release.yml and the publish
  jobs in ember.yml already chain off `push: tags: v*`. version-files is
  composer.json,package.json,extension.json -- this repository is an
  ember addon plus a server, so it carries all three, and the tag is
  refused unless they and RELEASE.md all name the version in the branch.
- RELEASE.md (new) is required by that workflow, and its first line must
  name the version being released -- that check is what distinguishes
  notes written for this release from the previous release's leftovers.
- create-release.yml publishes RELEASE.md as the release body, matching
  fleetops and storefront.
- ember.yml and server.yml also run on pull requests into a release
  branch, so work staged on release/v* is checked before the release PR
  is opened.
- server.yml gains the Composer GitHub auth step both reference repos
  have, and runs the test suite again now that it runs under phpunit.

Coverage jobs are deliberately not copied across: this package has no
coverage:baseline script or scripts/coverage-summary.php to gate on.
The Ember job fails at `pnpm install` with ERR_PNPM_IGNORED_BUILDS:

    Ignored build scripts: @fortawesome/fontawesome-common-types@6.4.0,
    @fortawesome/fontawesome-svg-core@6.4.0,
    @fortawesome/free-brands-svg-icons@6.4.0,
    @fortawesome/free-solid-svg-icons@6.4.0, core-js@2.6.12

pnpm 10 refuses to install when a dependency has a build script that has
not been explicitly allowed or denied, and the workflow installs
`version: latest`. fleetops and storefront answer that with a
pnpm-workspace.yaml denying builds for exactly these packages -- none of
them needs its postinstall to produce a working addon build -- which is
why their Ember CI passes and this repository's has not since pnpm 10
shipped. Copied from fleetops verbatim.

Unrelated to the OIDC work on this branch; it is environmental drift that
would fail the same way on main today.
- NODE_VERSION: 22.x at the workflow level, read by all four jobs, matching
  fleetops and storefront. The build job's single-entry matrix is gone, so
  there is one place to change the version instead of five.
- @fleetbase/ember-core ^0.3.10 -> ^0.3.24
  @fleetbase/ember-ui   ^0.3.17 -> ^0.4.3
  @fleetbase/fleetops-data ^0.1.25 -> ^0.2.2
  All three at the latest published versions. ember-ui crosses a 0.3 -> 0.4
  boundary, so ^0.3.17 would never have taken it.
- fleetbase_publish_qa removed.

Verified: `pnpm install` clean (peer warnings are the pre-existing
ember-source 5.4.1 vs older peer ranges, same as fleetops) and
`pnpm run build` -> "Built project successfully" on node 22. Solid imports
only three symbols from these packages -- ember-core's contracts,
exports and utils/get-with-default -- and all three still resolve.
1. PodService resolved a class that does not exist.

   Commit 907664b ("remove CSS credential code - use OIDC tokens only")
   deleted CssAccountService and CssAccountController but left two
   `app(CssAccountService::class)` call sites behind in PodService. There is
   no `use` for it, so the name resolved to
   Fleetbase\Solid\Services\CssAccountService and the container threw on
   entry -- taking out pod creation and the pod listing.

   Both are "try the CSS account API, fall through to the real method"
   wrappers, so this finishes what 907664b started and removes them; the
   working paths underneath are untouched. The css_email/css_password
   columns are now referenced nowhere, but the migration that adds them is
   left in place -- dropping columns holding encrypted credentials is a
   separate, destructive decision.

2. getAccountIndex() ended in dd().

   On the live, fleetbase.protected GET solid/int/v1/account route, so it
   halted the request and dumped the raw pod response to the browser. It
   returns JSON now, handling the unauthenticated and unsuccessful cases
   the way getAuthenticationStatus() does.

3. SolidClient ignored the saved server configuration.

   saveServerConfig() writes system.solid.server and getServerConfig()
   reads it back, but the client only ever read config('solid.server.*') --
   so changing the host or port in the console changed nothing. Resolution
   is now explicit option -> saved setting -> config default.

   Two related bugs in the same three lines: the old code passed
   `data_get($options, 'host')` as config()'s *default*, and since the key
   is always defined a caller-supplied host was silently discarded; and
   `public SolidIdentity $identity` was typed non-nullable while being
   assigned from an absent option, so `new SolidClient([])` raised a
   TypeError. The property is nullable now.

   saveServerConfig() also flushes the OIDC provider memo, since the
   discovery base derives from the server URL and a long-lived worker would
   otherwise keep talking to the old provider.

While auditing the route table for the duplicate `account` registration,
five more routes turned out to point at controller methods that do not
exist: SolidController@play, @getProfileData, @getSyncStatus,
@syncVehicles, @syncDrivers, @syncOrders and @syncall. Nothing in the
console calls any of them and each would 500 on hit, so they are removed.
The sync services themselves stay and remain reachable through
DataController@importResources; the profile payload already reaches the
console through authentication-status.

Every routed method now resolves to a defined one.
`composer test` has been failing in all three of its stages. It passes now.

test:unit -- back on Pest, via storefront's scripts/pest-runner.php.

  The earlier commit on this branch switched to phpunit because Pest's
  binary resolves its autoloader from a hardcoded `vendor/`, which this
  package does not have (vendor-dir is server_vendor), so `pest` could not
  start at all. That was a workaround; storefront already solved it
  properly with a runner that symlinks vendor -> server_vendor for the
  duration of the run and removes it afterwards. Adopted verbatim, so this
  package is back on the house test runner.

  scripts/pest-bootstrap.php is deliberately near-empty and in particular
  does NOT require the autoloader. Storefront's does, because its shims
  reference Illuminate classes at prepend time -- but loading the
  autoloader before Pest boots leaves its reporter with nothing to print:
  the suite still runs and still exits 0, and the report silently vanishes.
  That cost an hour; the comment in the file says so.

test:types -- phpstan is clean at level max against a baseline.

  275 errors, essentially all Laravel symbols phpstan cannot discover
  (facade __callStatic, Eloquent magic properties, global helpers) plus
  untyped legacy signatures. larastan would remove most of them but is not
  usable here: it needs laravel/framework, and this package requires only
  the illuminate/* components it uses, so it dies on an undefined
  LARAVEL_VERSION constant.

  So: phpstan-baseline.neon, generated and committed. New code is held to
  level max -- nothing written on this branch is in the baseline -- and the
  debt is explicit and reviewable rather than a permanently red command.

  Also `--memory-limit=0`, which this phpstan version rejects outright
  ("Memory limit \"0\" cannot be set"), is now -1, as core-api has it.

test:lint -- the six long-unformatted files are formatted.

  PodService, AclService, ResourceSyncService, DataController,
  Support/Utils and routes.php. Mechanical php-cs-fixer output, no
  behaviour change; tests pass unchanged either side of it.

CI now enforces all of it:

  - Run Lint uses test:lint, not lint. `composer lint` runs php-cs-fixer in
    *fix* mode, so in CI it rewrote files in the runner and exited 0
    whatever the state of the branch -- it was never a gate.
  - Run Static Analysis is new, and enforceable for the first time.
  - Coverage is generated, uploaded as an artifact and sent to Codecov,
    following storefront. No --fail-under gate, unlike fleetops: most of
    this package predates its tests and sits at 0%, so a gate would only
    ever be red.

Coverage today is 25.45% overall, and the code added on this branch is the
covered part: SolidIdTokenVerifier 95.7%, DPoPKeyPair 93.2%,
OpenIDConnectClient 81.4%, CachedJwksResolver and RedisStateStore 100%.

Codecov badge added to the README, alongside repo/license/npm/packagist/
node/php badges in the same shape fleetops uses.
RELEASE.md was written before the four pre-existing defects and the engine
dependency upgrades landed on this branch.
Both sides touched PodService and Utils.

PR #8 added parse_url() guards in six places. Two of them were inside the
CSS-credential branches this branch deletes -- the service they call was
removed in 907664b and never existed at runtime -- so those guards go with
the code. The two in getPodUrlFromWebId() and getStorageUrlFromWebId() are
kept, and getUserPods() is still covered because it now reaches
getStorageUrlFromWebId() rather than parsing the WebID itself. #8's
corrected example comments are kept.

Utils::getSolidServerUrl() needed more than a textual resolution. #8 fixed
it to build the URL from config('solid.server.*') instead of a
non-existent solid.server.url; this branch separately taught SolidClient to
prefer the host and port an administrator saved through the console. Left
as merged, the two would report different servers -- the same class of
inconsistency #8 set out to fix. Utils now delegates to
SolidClient::serverUrl(), and both it and the instance getServerUrl() go
through one private builder, so there is a single answer.

phpstan flagged that builder being reached through static:: while private;
it is self:: now, and the baseline is regenerated (242 errors, down from
247, since #8's guards replaced some untyped access).
@codecov

codecov Bot commented Sep 23, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@roncodes
roncodes merged commit 4a726da into main Sep 23, 2026
10 checks passed
@roncodes
roncodes deleted the release/v0.0.8 branch September 23, 2026 05:14
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