From b7a178b1a9a0f2e5bdaddc79d55ef49afb06c102 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 02:17:27 -0700 Subject: [PATCH 1/5] Directory setup: ask an OpenID Connect provider's client ID as the audience The server now refuses a new OpenID Connect directory without a required audience, since without one a token the provider issued to any other application signs people in. The guide's connection step asks for the client ID inbuxa is registered with at the provider, can't go on without it, and saves it as requireAudience. Test: the presets test builds an OIDC directory with requireAudience from the audience typed (it had none before); typecheck, lint and the full test run pass. (cherry picked from commit efea8c3fddc76ed975ca6cd8cb01c694d353c95a) --- src/features/directory/DirectorySetupPage.tsx | 12 +++++++++++- src/features/directory/presets.test.ts | 10 ++++++++-- src/features/directory/presets.ts | 3 +++ src/i18n/en.json | 4 +++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/features/directory/DirectorySetupPage.tsx b/src/features/directory/DirectorySetupPage.tsx index c6adf96..f8dd1ab 100644 --- a/src/features/directory/DirectorySetupPage.tsx +++ b/src/features/directory/DirectorySetupPage.tsx @@ -98,6 +98,7 @@ export function DirectorySetupPage() { bindPassword: '', allowInvalidCerts: false, issuer: '', + audience: '', usernameDomain: '', }); const [savedId, setSavedId] = useState(null); @@ -307,7 +308,7 @@ export function DirectorySetupPage() { ? /^ldaps?:\/\//.test(conn.url.trim()) && conn.baseDn.trim() !== '' && (savedId !== null || conn.bindDn.trim() === '' || conn.bindPassword !== '') - : /^https:\/\//.test(conn.issuer.trim()); + : /^https:\/\//.test(conn.issuer.trim()) && conn.audience.trim() !== ''; return ( {field('issuer', t('dirSetup.issuer', 'Issuer URL'), preset.example.issuer)} + {field( + 'audience', + t('dirSetup.audience', 'Client ID (audience)'), + 'inbuxa', + t( + 'dirSetup.audienceHint', + 'The client ID inbuxa is registered with at the provider. Only tokens issued for it sign in, not tokens the provider gave other applications.', + ), + )} {field( 'usernameDomain', t('dirSetup.usernameDomain', 'Domain for plain user names (optional)'), diff --git a/src/features/directory/presets.test.ts b/src/features/directory/presets.test.ts index 7103623..0a40f2e 100644 --- a/src/features/directory/presets.test.ts +++ b/src/features/directory/presets.test.ts @@ -14,6 +14,7 @@ const conn: Connection = { bindPassword: 'pw', allowInvalidCerts: false, issuer: '', + audience: '', usernameDomain: '', }; @@ -39,13 +40,18 @@ describe('directoryObject', () => { expect(directoryObject(presetById('openldap'), { ...conn, bindPassword: '' }, '')).not.toHaveProperty('bindSecret'); }); - it('builds an OIDC directory from the issuer', () => { + it('builds an OIDC directory from the issuer and the audience', () => { expect( - directoryObject(presetById('keycloak'), { ...conn, issuer: ' https://sso.example.org/realms/x ' }, 'KC'), + directoryObject( + presetById('keycloak'), + { ...conn, issuer: ' https://sso.example.org/realms/x ', audience: ' inbuxa ' }, + 'KC', + ), ).toEqual({ '@type': 'Oidc', description: 'KC', issuerUrl: 'https://sso.example.org/realms/x', + requireAudience: 'inbuxa', usernameDomain: null, claimUsername: 'email', claimName: 'name', diff --git a/src/features/directory/presets.ts b/src/features/directory/presets.ts index 095f880..52d99af 100644 --- a/src/features/directory/presets.ts +++ b/src/features/directory/presets.ts @@ -139,6 +139,8 @@ export interface Connection { bindPassword: string; allowInvalidCerts: boolean; issuer: string; + /** The client ID registered for this server at the provider: a token must name it as its audience. */ + audience: string; usernameDomain: string; } @@ -149,6 +151,7 @@ export function directoryObject(p: Preset, c: Connection, description: string): '@type': 'Oidc', description, issuerUrl: c.issuer.trim(), + requireAudience: c.audience.trim(), usernameDomain: c.usernameDomain.trim() || null, ...p.fields, }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 30db239..9d06be6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -887,7 +887,9 @@ "defaultMoves": "Every other domain without its own directory signs in through {{dir}}.", "done": "Sign-in now goes through {{dir}}.", "doneHint": "Have someone sign in to the webmail with their directory password. Their account is created on first sign-in, with the name, aliases and groups the directory gives.", - "tabTitle": "Sign-in directory" + "tabTitle": "Sign-in directory", + "audience": "Client ID (audience)", + "audienceHint": "The client ID inbuxa is registered with at the provider. Only tokens issued for it sign in, not tokens the provider gave other applications." }, "held": { "noSubject": "(no subject)", -- 2.54.0 From fb4679c41848eef148c1a57c6a4862c9662ef9b9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 02:17:27 -0700 Subject: [PATCH 2/5] Security: flag OpenID Connect directories open to other apps' tokens Two new checks on the security to-do list read every directory: - SS-30, important: an OpenID Connect directory with no required audience (only one saved before the server required it can be like this). Tokens its provider issued to other applications sign in. - SS-31, important: an OpenID Connect directory that takes tokens which aren't JWTs on the user info endpoint's word (opaqueTokens: userinfo, which directories saved before the setting existed read as). Any token the provider issued, to any application, passes. Both are Reviews that open the directory. They are numbered past the spec's requirements, SS-19 to SS-29, which share the numbering; the server keeps acceptances for checks up to SS-40. The page help lists them after SS-18. Test: the checks test flags the old directory for both and leaves a directory with an audience, one using introspection and an LDAP one alone; the help test still finds every check explained once, in order. (cherry picked from commit 2b37d229aee177eb13185ea7b23220c0c70505ae) --- src/features/security/checks.test.ts | 21 +++++++++++++ src/features/security/checks.ts | 45 +++++++++++++++++++++++++++- src/features/security/load.ts | 15 ++++++++++ src/help/securityChecks.test.ts | 1 + src/help/securityChecks.ts | 10 +++++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/features/security/checks.test.ts b/src/features/security/checks.test.ts index 8c059bd..308e040 100644 --- a/src/features/security/checks.test.ts +++ b/src/features/security/checks.test.ts @@ -179,4 +179,25 @@ describe('the checks (acceptance tests 1-8)', () => { const locked = { ...fresh(), metrics: { prometheus: { '@type': 'Enabled', authSecret: '********' } } }; expect(ids(locked)).not.toContain('SS-12'); }); + + it('OpenID Connect directories without an audience, or taking opaque tokens on user info, are flagged', () => { + const dir = { oidc: true, requireAudience: 'inbuxa', opaqueTokens: null }; + const s: Snapshot = { + ...fresh(), + directories: [ + { ...dir, id: 'a', name: 'Keycloak' }, + { ...dir, id: 'b', name: 'Old Keycloak', requireAudience: null, opaqueTokens: 'userinfo' }, + { ...dir, id: 'c', name: 'Introspected', opaqueTokens: 'introspection' }, + { ...dir, id: 'd', name: 'LDAP', oidc: false, requireAudience: null }, + ], + }; + const items = evaluate(s, defaults).items.filter((i) => i.check === 'SS-30' || i.check === 'SS-31'); + expect(items.map((i) => [i.check, i.subject])).toEqual([ + ['SS-30', 'b'], + ['SS-31', 'b'], + ]); + expect(items[0].title).toContain('Old Keycloak'); + expect(items[0].action).toEqual({ kind: 'review', viewName: 'x:Directory', id: 'b' }); + expect(ids(fresh())).not.toContain('SS-30'); + }); }); diff --git a/src/features/security/checks.ts b/src/features/security/checks.ts index cdcede0..ce04a43 100644 --- a/src/features/security/checks.ts +++ b/src/features/security/checks.ts @@ -86,6 +86,17 @@ export interface DomainSnapshot { dmarcPolicy: string | null; } +/** An external directory, as SS-30 and SS-31 read it. */ +export interface DirectorySnapshot { + id: string; + name: string; + /** OpenID Connect: the only kind these checks look at. */ + oidc: boolean; + requireAudience: string | null; + /** reject, userinfo or introspection; null when not set. */ + opaqueTokens: string | null; +} + export interface Snapshot { now: number; imap?: { allowPlainTextAuth?: boolean }; @@ -101,6 +112,7 @@ export interface Snapshot { metrics?: { prometheus?: { '@type'?: string; authSecret?: unknown } }; mtaSts?: { mode?: string }; domains?: DomainSnapshot[]; + directories?: DirectorySnapshot[]; /** The legacy-protocols switch: true while any legacy protocol is on. */ legacyOn?: boolean; /** Why a part couldn't be read, by part name. */ @@ -122,7 +134,8 @@ export type Part = | 'mtaSts' | 'domains' | 'dns' - | 'legacy'; + | 'legacy' + | 'directories'; /** The schema's defaults, as the console knows them. */ export type Defaults = (object: string, field: string) => unknown; @@ -551,6 +564,36 @@ export function evaluate(s: Snapshot, defaults: Defaults): Result { check('SS-14', 'Certificates expiring within 30 days', ['certificates'], certItems(30 * DAY, 'important', 'SS-14')); + // SS-30 and SS-31 are numbered past the spec's requirements (SS-19 to + // SS-29), which share the numbering. + check('SS-30', 'OpenID Connect directories without an audience', ['directories'], () => + (s.directories ?? []) + .filter((d) => d.oidc && !d.requireAudience) + .map((d) => ({ + check: 'SS-30', + severity: 'important' as const, + subject: d.id, + value: null, + title: `${d.name || d.id} accepts tokens issued to other applications`, + why: 'It names no required audience, so a token its provider issued to any other application signs people in here. Set it to the client ID inbuxa is registered with at the provider. A later release will require it.', + action: { kind: 'review' as const, viewName: 'x:Directory', id: d.id }, + })), + ); + + check('SS-31', 'Opaque tokens taken on the user info endpoint’s word', ['directories'], () => + (s.directories ?? []) + .filter((d) => d.oidc && d.opaqueTokens === 'userinfo') + .map((d) => ({ + check: 'SS-31', + severity: 'important' as const, + subject: d.id, + value: 'userinfo', + title: `${d.name || d.id} accepts any token its provider issued`, + why: 'A token that isn’t a JWT signs in whenever the provider’s user info endpoint answers for it, and it does for tokens issued to any application. Check them by introspection, with the client ID and secret inbuxa has at the provider, or reject them.', + action: { kind: 'review' as const, viewName: 'x:Directory', id: d.id }, + })), + ); + // Good practice check('SS-15', 'Legacy mail protocols', ['legacy'], () => diff --git a/src/features/security/load.ts b/src/features/security/load.ts index 76b621e..ecb07ab 100644 --- a/src/features/security/load.ts +++ b/src/features/security/load.ts @@ -64,6 +64,12 @@ const CALLS: Call[] = [ { part: 'senderAuth', method: 'x:SenderAuth/get', ids: ['singleton'], properties: ['dmarcVerify'] }, { part: 'metrics', method: 'x:Metrics/get', ids: ['singleton'], properties: ['prometheus'] }, { part: 'mtaSts', method: 'x:MtaSts/get', ids: ['singleton'], properties: ['mode'] }, + { + part: 'directories', + method: 'x:Directory/get', + ids: null, + properties: ['@type', 'description', 'requireAudience', 'opaqueTokens'], + }, { part: 'domains', method: 'x:Domain/get', @@ -166,6 +172,15 @@ export async function loadSnapshot(signal?: AbortSignal, critical = false): Prom }; }); } + if (lists.directories) { + snapshot.directories = lists.directories.map((d) => ({ + id: String(d.id), + name: String(d.description ?? ''), + oidc: d['@type'] === 'Oidc', + requireAudience: typeof d.requireAudience === 'string' ? d.requireAudience : null, + opaqueTokens: typeof d.opaqueTokens === 'string' ? d.opaqueTokens : null, + })); + } if (lists.tlsStrategies) { snapshot.tlsStrategies = lists.tlsStrategies.map((t) => ({ id: String(t.id), diff --git a/src/help/securityChecks.test.ts b/src/help/securityChecks.test.ts index 4a7c921..18cecfe 100644 --- a/src/help/securityChecks.test.ts +++ b/src/help/securityChecks.test.ts @@ -24,6 +24,7 @@ const PARTS: Part[] = [ 'domains', 'dns', 'legacy', + 'directories', ]; describe('security page help', () => { diff --git a/src/help/securityChecks.ts b/src/help/securityChecks.ts index 6697e65..a6d675d 100644 --- a/src/help/securityChecks.ts +++ b/src/help/securityChecks.ts @@ -103,6 +103,16 @@ export const SECURITY_CHECK_HELP: { check: string; label: string; text: string } label: 'DMARC policies', text: 'Good practice for each domain whose DMARC record says p=none. Once DMARC reports show your own mail passing, move to quarantine or reject at your DNS host.', }, + { + check: 'SS-30', + label: 'OpenID Connect directories without an audience', + text: 'Important for each OpenID Connect directory saved without a required audience (a new one can’t be). A token its provider issued to any other application signs people in here. Review opens the directory: set the audience to the client ID inbuxa is registered with at the provider.', + }, + { + check: 'SS-31', + label: 'Opaque tokens taken on the user info endpoint’s word', + text: 'Important for each OpenID Connect directory that accepts tokens which aren’t JWTs whenever the provider’s user info endpoint answers for them, as directories saved before the choice existed do. Review opens the directory: choose introspection, with inbuxa’s client ID and secret at the provider, or reject.', + }, ]; export const SECURITY_PAGE_HELP: PageHelp = { -- 2.54.0 From b2d29b836c5a35360f3dee0fa21b1ac01b04639f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 21:44:44 -0700 Subject: [PATCH 3/5] GitHub release copy: wait for the release files github-release needed only publish, so it ran beside files and copied the Gitea release before files had attached the tarball and SHA256SUMS. Every GitHub copy of a release had no files. It now needs files too. (cherry picked from commit 56a4caea72a221b7e2c2dac4899109e67b2418b2) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cba33cd..e1e2bd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,9 +286,12 @@ jobs: # release is the real one; this is left out of the report to Gitea, so a # failure here cannot fail a release. PR and issue numbers in the notes are # rewritten to Gitea links: on GitHub a bare #16 is some other PR. + # It waits for `files` too: started after `publish` alone, it ran beside + # `files` and copied the Gitea release before its files were attached, so + # every GitHub copy had none. github-release: if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} - needs: [publish] + needs: [publish, files] runs-on: ubuntu-latest permissions: contents: write -- 2.54.0 From a022ce4d2b04d41b9ecafff16a2d66d84278a5f2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 1 Nov 2026 09:14:03 -0700 Subject: [PATCH 4/5] CI: leave :latest to the newest series once 27.1 ships A dated release from this branch moved :latest, GHCR's :latest and the GitHub "latest release" on every tag. That is right while 2026.x is the newest series, and wrong once 27.1 ships: a security release from this branch would take :latest back to the older line. Both CIs now ask Gitea for the newest final releases and move :latest only while none is numbered by series (v27.1, v27.1.3), so the switch happens on its own when 27.1 is published. The check fails rather than guess. The inherited v1.0.x numbers don't count as a series. --- .gitea/workflows/ci.yml | 13 ++++++++++++- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9542da3..56726b2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -121,6 +121,7 @@ jobs: REGISTRY: ${{ vars.REGISTRY }} IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }} PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + READ_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec - run: | @@ -133,6 +134,16 @@ jobs: exit 1 fi echo "VERSION=$VERSION" >> "$GITHUB_ENV" + # :latest belongs to the newest series: once a final release numbered + # by series exists (v27.1, v27.1.3), a dated release from release/2026 + # leaves it alone. A failed check stops here rather than guess. + releases="$(curl -fsS -H "Authorization: token $READ_TOKEN" "$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/releases?draft=false&pre-release=false&limit=50")" + series="$(printf '%s' "$releases" | jq -er '[.[].tag_name | select(test("^v[0-9]{2}\\."))] | first // ""')" + if [ -n "$series" ]; then + echo "$series is out: :latest stays on its series"; echo "LATEST_TAG=" >> "$GITHUB_ENV" + else + echo "no series release yet: :latest moves"; echo "LATEST_TAG=--tag $IMAGE:latest" >> "$GITHUB_ENV" + fi - run: | test -n "$REGISTRY" test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; } @@ -146,7 +157,7 @@ jobs: --platform linux/amd64,linux/arm64 \ --provenance=false --sbom=false \ --tag "$IMAGE:$VERSION" \ - --tag "$IMAGE:latest" \ + $LATEST_TAG \ --push . docker buildx imagetools inspect "$IMAGE:$VERSION" # Gitea keeps a container package on its owner; linking it shows it on diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1e2bd5..909ddae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.v.outputs.version }} + latest: ${{ steps.latest.outputs.latest }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v @@ -112,6 +113,20 @@ jobs: exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # :latest belongs to the newest series. Once a final release numbered by + # series exists (v27.1, v27.1.3), a dated release from release/2026 + # leaves :latest and GitHub's latest release where they are. The check + # fails rather than guess: a wrong :latest is worse than a re-run. + - id: latest + run: | + set -euo pipefail + url="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?draft=false&pre-release=false&limit=50" + series="$(curl -fsS "$url" | jq -er '[.[].tag_name | select(test("^v[0-9]{2}\\."))] | first // ""')" + if [ -n "$series" ]; then + echo "$series is out: :latest stays on its series"; echo "latest=false" >> "$GITHUB_OUTPUT" + else + echo "no series release yet: :latest moves"; echo "latest=true" >> "$GITHUB_OUTPUT" + fi # Each architecture on its own native runner, pushed as an untagged image by # digest; `publish` joins the two digests into one multi-arch tag. @@ -165,7 +180,8 @@ jobs: overwrite: true if-no-files-found: error - # Joins the digests into `:` and `:latest`, and links the package + # Joins the digests into `:`, and `:latest` while the version job + # says so, and links the package # to the repository on Gitea. The release itself already exists: Gitea's # weekly-release.yml made it, which is what made the tag. publish: @@ -187,10 +203,12 @@ jobs: - name: Create the manifest env: VERSION: ${{ needs.version.outputs.version }} + LATEST: ${{ needs.version.outputs.latest }} run: | refs=() for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done - docker buildx imagetools create -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" "${refs[@]}" + tags=(-t "${IMAGE}:${VERSION}"); if [ "$LATEST" = true ]; then tags+=(-t "${IMAGE}:latest"); fi + docker buildx imagetools create "${tags[@]}" "${refs[@]}" docker buildx imagetools inspect "${IMAGE}:${VERSION}" # Shows the package on the repository's Packages tab. Idempotent. - env: @@ -250,7 +268,7 @@ jobs: # ------------------------------------------------------ ghcr replica ------ # Copies the release image from the Gitea registry, which stays the - # authoritative one, to ghcr.io under the same version tag and :latest. It is + # authoritative one, to ghcr.io under the same version tag, and :latest as above. It is # a copy, not a second build: the digest on GHCR is the digest on the # registry, so `docker pull ghcr.io/...` gets exactly the same image. Left # out of the report to Gitea, like the release copy, so a GHCR problem @@ -266,13 +284,15 @@ jobs: - env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.version.outputs.version }} + LATEST: ${{ needs.version.outputs.latest }} run: | set -euo pipefail src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" dst="ghcr.io/${GITHUB_REPOSITORY,,}" tag="$TAG" echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag" + args=(-t "$dst:$tag"); if [ "$LATEST" = true ]; then args+=(-t "$dst:latest"); fi + docker buildx imagetools create "${args[@]}" "$src:$tag" want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')" got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')" echo "registry $src:$tag = $want" @@ -291,7 +311,7 @@ jobs: # every GitHub copy had none. github-release: if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} - needs: [publish, files] + needs: [version, publish, files] runs-on: ubuntu-latest permissions: contents: write @@ -299,6 +319,7 @@ jobs: GITEA_URL: ${{ vars.GITEA_URL }} GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} + LATEST: ${{ needs.version.outputs.latest }} steps: - run: | set -euo pipefail @@ -325,7 +346,7 @@ jobs: curl -fsSL -o "files/$name" "$url"; files+=("files/$name") done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json) title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG" - if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi + if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; elif [ "$LATEST" = true ]; then kind=--latest; else kind=--latest=false; fi gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \ --notes-file notes.md "$kind" "${files[@]}" echo "created the GitHub release for $TAG with ${#files[@]} file(s)" -- 2.54.0 From f131071bdeb3d3f37ba1633414839886b19555da Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 1 Nov 2026 09:14:03 -0700 Subject: [PATCH 5/5] Version 2026.10.26.4 --- inbuxa-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inbuxa-version.json b/inbuxa-version.json index 44dd220..394b601 100644 --- a/inbuxa-version.json +++ b/inbuxa-version.json @@ -1,3 +1,3 @@ { - "version": "2026.10.26.3" + "version": "2026.10.26.4" } -- 2.54.0