108 KiB
v2.8.0 - 2026-08-11
Fixed
- Promotion no longer orphans the demoted old master's LDAP replication. Neither
/site-promotenor/demotetouchedSiteSpoke-- the demoted old master got a fresh join key but noSiteSpokeentry on the new master (noldapServerId, invisible to the peer list), and structurally could never self-heal via/join(refuses re-join for a node that's already a spoke)./demotenow registers itself with the new master immediately, deriving its own endpoint fromstack.selfUrl/stack.ssoHost./site-promote's response also now surfaces that the promoted node's own OpenLDAP ServerID needs asetup.shre-run to actually apply. - The Directory's site slug and the multi-site replication identity are unified. These were two unrelated values that happened to share a name -- a deployment could show a real site name in the Directory catalog and the literal
site-defaultfallback on the Multi-Site modal for the same node.POST /resourcesnow syncssite_config'ssiteSlugto match the moment this node's own site Resource is first created, for a still-default master only.
Added
- LDAP replication status + per-spoke detail on the Multi-Site modal. New
utils/ldap_replication.js'scurrentSlapdServerId()reads the actual running ServerID from this node's ownslapd.conf-- distinct from what the API currently advertises, which can genuinely disagree right after a promotion or a new spoke joining.GET /directory-admin/site-statusnow surfaces both plus astaleflag and a full spokes list (endpoint, assignedldapServerId, relay path), not just an aggregate count.
v2.7.0 - 2026-08-11
Added
- OpenLDAP N-way multi-master replication auto-config.
SiteSpoke.ldapServerIdis now auto-assigned at registration (next free from 2 upward, 1 reserved for the master -- same pattern as jump-host's mesh index), and each site's LDAP URL is derived from its already-known HTTP(S) endpoint rather than a separately-configured field. Newutils/ldap_replication.js,GET /api/site/ldap-peers(spoke-facing, Bearer site join key) andGET /directory-admin/ldap-replication-config(master-local). Operators no longer hand-maintainLDAP_SERVER_ID/LDAP_REPLICATION_HOSTSfor atheta-suite-joined cluster (seetheta-suite'sbootstrap/site-ldap-register.js). Verified against real running containers (docker-compose.multisite-e2e.yml).
v2.6.0 - 2026-08-11
Fixed
- Duplicate access/admin groups on repeated resource promotion. Three independent copies of the same bug (
routes/discovery.js'sPOST /discovery/promote/:slug-- the actual "Promote" button in the UI -- andservices/discovery_reconciler.js'sautoPromotepath both calledResourceGroup.create()directly with no existence check, unlikeroutes/api_directory_admin.js's ownensureResourceGroup, which already carried a comment describing this exact bug). A resource promoted more than once (a retried click, or the same LXC discovered from multiple Proxmox cluster nodes) silently accumulated duplicate rows every time. Consolidated intoResourceGroup.ensure()on the model, used everywhere. GET /api/directory-admin/resourcesran a full LDAP group self-heal fan-out on every single list (ensureSiteGroupsper site +provisionResourceGroupsper resource, each several sequential LDAP round-trips), unconditionally -- confirmed as the actual bottleneck once a directory has more than a handful of resources, not data volume. Moved healing to where resources actually change instead (POST/PUT /resources,POST /discovery/promote/:slug--PUThad none at all before this), and addedPOST /resources/heal-groupsas an explicit on-demand equivalent for backfilling a directory seeded before this change.- An nmap discovery scan that completed successfully could be reported as a failed run with zero hosts found.
node-nmap(the vendored library) treats any stderr output from the nmap binary as fatal -- including nmap's own harmless RTT-calibration warning ("RTTVAR has grown to over N seconds..."), which it prints during a scan that goes on to complete normally, discarding valid results already sitting in the library'srawData. Our plugin now recognizes this specific benign message and manually completes the scan from the data that's already there; any other error still rejects as before. - The Multi-Site modal's "Theta Gateways" count was measuring the wrong subsystem. It counted this app's own unrelated WireGuard roaming-client/exit-node Resources, not jump-host's actual gateway-to-gateway mesh registry. New
utils/jump_client.js(same self-service-token pattern asutils/proxy_client.js) queries jump-host's realGET /api/mesh/gateways, reporting a distinct "unknown" state instead of a misleading 0 when the integration isn't configured. Also added help links to the published multi-site/mesh docs on the modal.
v2.5.0 - 2026-08-10
Added
- No-inbound relay automation. A spoke with no public IP of its own can now register as such (
noInbound/meshIp/publicHostonPOST /api/site/spokes, forwarded throughPOST /api/site/joinfor the real operator join flow), and the master auto-creates/updates the relay route on its owntheta-proxyviautils/proxy_client.js— a new self-serviceprx_...API token client, reusingtheta-proxy's existing token system rather than inventing a new credential type. Verified against a real runningtheta-proxycontainer (GET /api/host/:item's actual{item, results: {...}}response shape, not the flat shape first assumed). - Replication traffic prefers the mesh.
utils/site_replicate.js's fire-and-forget resync push now tries a registered spoke'smeshIpfirst (falling back to its publicendpointon failure) — cross-component routing over the gateway-to-gateway WireGuard mesh instead of the open internet, for any spoke that's registered one. POST /api/site/joinsurfaces the resulting relay status in its response (relay.note), andtheta-suite's bootstrap flow (CFG_SPOKE_NO_INBOUND/CFG_SPOKE_PUBLIC_HOST,bootstrap/site-relay-register.js) drives all of this from the real operator-facing setup script, not just the API.
v2.4.0 - 2026-08-10
Added
- Live catalog replication. A spoke now stays in sync after joining instead of only getting a one-time snapshot: it registers its own endpoint with the master at join time (
POST /api/site/spokes, Bearer the site join key), and every successful master catalog write fires a fire-and-forget push (utils/site_replicate.js) at every registered spoke, concurrently — one unreachable spoke never blocks or delays delivery to another. The spoke'sPOST /api/site/resynchandler re-runs the same tested export-pull-and-import path used at join time rather than applying a partial diff. - Identical-directory agent-signing key.
POST /api/site/exportnow best-effort includes the master's agent-signing key; a spoke adopts it viaagent_keys.adopt()on both join and every resync, so any site'ssso-manager-nodecan validly sign a command for any agent enrolled at any other site (a deliberate blast-radius tradeoff for this deployment's small, trusted scale — seetheta-suite'sdocs/MULTI_SITE_SPEC.md§2). - Coordinated master promotion.
POST /api/directory-admin/site-promotenow demotes the previous master as part of the same action (mints it a fresh join key, calls its newPOST /api/site/demote) instead of leaving a manual two-step gap where two nodes could both believe they're master. Best-effort: an unreachable old master (the WAN-outage scenario this control exists for) never blocks the local promotion — the response'shandofffield reports what happened. - Master Site modal UI: new "Live Replication" (spoke) / "Registered Spokes" (master) status rows; the join form gained a "this site's own reachable URL" field (prefilled from the browser origin) wired to the
selfUrlthe join API already supported but the UI never sent — a UI-driven join previously never registered for live replication, only thesetup.shbootstrap path did; the promote button's success toast now reports the actual handoff result. - New
nodejs/models/site_spoke.js(registered spokes + their push tokens) anddocker-compose.multisite-e2e.yml+test/multisite_join_e2e.js(real two-container master+spoke regression test covering join, live replication, promotion, and demotion end to end).
Fixed
site-promote's god_admin check was dead on arrival. It readreq.user.groups, a field nothing in the codebase ever populates (every other admin gate resolves membership live viapermission.byGroup()/Group.list(user.dn), which also handles nested-group membership) — the check silently evaluated to an empty array on every request, so promotion returned 403 for every user, including a real god_admin, since it shipped in v2.0.0. Only surfaced by the live e2e test, not by inspection.- The read-only write-gate blocked
site-promoteon a spoke before its handler could run — the one mutating request a spoke must be able to make to itself. Exempted/site-promotefrom the gate. GET /api/site/configwas returningmasterJoinKeyandreplicationPushToken— live credentials — directly in the JSON response to any admin session. Replaced with boolean derivatives (hasMasterJoinKey,liveReplication).
v2.3.0 - 2026-08-10
Added
- Multi-site join — UI + enforcement (completes the join layer started in v2.2.0):
- Master Site modal: a fresh install (no users beyond the bootstrap admin, no agents) gets a "Join an Existing Site" form (master URL + site join key); a master gets a Site Join Keys manager (mint/revoke/list, key shown once, copy button); WAN Sync Health now reflects a live probe of the master.
POST /api/site/ping(Bearer site-join-key): lightweight master reachability probe for WAN health.- Spoke read-only: directory-write routes (resources, edges, groups, secrets, grants, driver actions, discovery merges) return
403pointing at the master once a node is a spoke. - Fresh-install guard:
/api/site/joinrefuses unless the directory has no users beyond the admin and no enrolled agents (siteIsFresh);site-statusexposescanJoinso the UI only offers join when it's actually allowed. - The spoke persists the join key (
masterJoinKey) in/config/site.jsonfor WAN health + future write-proxy.
- Unit tests:
siteIsFreshcases (admin-only, second user, enrolled agent, service accounts ignored).
Fixed
- Branch-protection check name: the lint job that
masterrequires is literally named "Syntax check bootstrap.js"; the multi-site bootstrap script is checked by that same job.
v2.2.0 - 2026-08-10
Added
- Multi-site join — server endpoints. A spoke can join an existing master directory:
- Site join keys (
stj_…): mint/revoke/delete under/api/site/join-keys(hashed at rest, shown once — same model as agent join keys). POST /api/site/export(master, Bearer site-join-key, no admin session): returns the LDAP tree (slapcatLDIF) + resource catalog + siteSlug + baseDn.POST /api/site/join(spoke, admin):{ masterUrl, joinKey }pulls the master export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists the spoke role. Refused if already a spoke.- Persisted site role:
isMaster/masterUrl/siteSlugnow live in/config/site.json(env seeds defaults) instead of Node memory, so a restart no longer silently reverts a spoke to master.site-status/site-promoteread/write it. - Docs:
docs/site-join.md(flow, endpoints, plannedsetup.envvars) registered in the docs router. - The UI +
setup.shwiring for join is the next layer; this pass is server-only.
- Site join keys (
- Unit tests for the join helpers (
tests/site_join.test.js) and persisted config (tests/site_config.test.js) — pure logic, in-memory stubs, added tonpm test.
Fixed
- Multi-site modal text was mojibake. The Master/Spoke emojis (👑/⚡) in
views/directory.ejswere UTF-8 that had been round-tripped through cp1252; restored. All other views verified byte-accurate clean.
v2.1.1 - 2026-08-10
Fixed
- "Master Site" button errored with
app.modal.show is not a function. The multi-site status modal used the legacyapp.modal.show()signature; the app exposesapp.modal.open({ title, bodyHtml, size }). The site-status request itself worked — only the rendering call was wrong. - Agents with no discovery yet showed a fake
v2.0.0. Three hardcoded fallbacks now reportunknowninstead, so a host whose agent hasn't connected isn't presented as an old version.
v2.1.0 - 2026-08-10
Added
- Windows install commands in the Install Agent modal. The Directory → Install Agent modal now emits PowerShell one-liners alongside the bash ones for the join-key, pre-register, and custom-config flows. Each downloads the fully-offline theta-agent
setup.exefrom its GitHub release and passes the same values the bash flow uses (/SERVER_URL,/JOIN_KEY,/AUTH_TOKEN,/PUBLIC_KEY, or/B64_CONFIG), so a Windows host enrolls with the same one-command flow as Linux. Complements the theta-agent v2.1.0 Windows release.
Removed
- Large binaries from
nodejs/public/resources/theta-agent/. The agent/tray/helper/setup binaries are now built on GitHub Actions and attached to the theta-agent GitHub release as artifacts;install.shand the modal download them fromreleases/latest/download/. Nothing binary lives in this repo anymore (the smallinstall.shbootstrap script remains).
v2.0.4 - 2026-08-09
Changed
Dockerfile.openldapno longer compiles OpenLDAP from source. Itsldapbuildstage now pullsghcr.io/theta42/openldap-nestgroup:<pinned commit>(built once by.github/workflows/build-openldap-image.ymlfrom the newDockerfile.openldap-builder) instead of cloninggit.openldap.organd running./configure && makeon every build. Cuts ~5 minutes off every build of this Dockerfile, including 3x per CI run's test matrix, and removes the runtime dependency on that mirror being up (it 502'd twice tonight, blocking two PRs). Verified locally end-to-end before merging: built the app image against the published base, ran it, confirmed slapd boots healthy with the nestgroup overlay loaded and the correct pinned commit.
v2.0.3 - 2026-08-09
Fixed
- Directory tab showed unpromoted discoveries.
GET /api/directory-admin/resourcesunconditionally admitted everykind: 'host'resource, and every discovery plugin (UniFi, Proxmox, nmap) creates its finds askind: 'host'— so unchecking "Auto-promote to Directory" on a plugin never actually kept undiscovered/unpromoted devices out of the Directory tab, only out of the LDAP-group auto-provisioning. Now onlysiteresources are unconditionally shown; anything else that discovery ever touched requiresmetadata.managed === true(set by promotion, an agent, or merging into an already-managed resource). GET /api/directory-admin/site-status500'd. QueriedResource.list({ where: { subType: 'wireguard' } }), butsubTypeonly ever lives inmetadata.subType(every driver/discovery plugin reads it that way) — never a top-level DB column, so SQLite raisedno such column: Resource.subType. Filters in JS overmetadata.subTypenow.- Discovered Inventory had no way to review ignored devices. Added a "Show ignored" toggle (off by default) to the tab, so
metadata.ignored === truerows stay hidden from routine triage but remain reachable.
Chore
- Untracked
nodejs/config/inventory.sqlite. It's the app's default runtime DB (nodejs/models/index.jsfalls back to this path when no external DB is configured), not a fixture — it had been committed by mistake across 13 prior releases, churning on every local run. Removed from tracking and gitignored.
v2.0.2 - 2026-08-09
Fixed
- README rebranding & standalone-install cleanup. Removed the "Why this over the alternatives" section and stale links to the old per-repo GitHub Pages site (
theta42.github.io/sso-manager-node/); documentation and secrets links now point at the unifiedtheta42.github.io/theta-suite/site. Made explicit that Theta Directory is deployed as part of Theta Suite and isn't installed or run on its own. Added the agent capability/install screenshots to the gallery.
v2.0.1 - 2026-08-09
Fixed
- OpenBao / OpenBoa Container Exclusion: Skip discovery of internal secret management/renewer containers so they don't populate resources catalog.
- Agent Version API Collection: Added
versiontracking to Agent model and discovery handlers to record and report agent version dynamically. - Console Log Cleanup: Removed leftover
console.logdebug statements in frontend assets. - Resource Save & User Cache Invalidation: Fixed metadata merge on resource updates to prevent losing system fields, and added User cache clear to propagate user/group edits instantly.
- Site Status 500 Fix: Replaced invalid ORM
Resource.findAll()call withResource.list(). - Secret Filtering: Fixed the "With Secrets" filter checkbox by ensuring
hasSecret/secretKeysstates are written to resource metadata and checked by EJS views. - Auto-Group Spawning Prevention: Set default
autoPromoteto false in UniFi, Docker, Proxmox, and Nmap plugins and restricted auto group creation to managed resources to prevent duplicate LDAP group generation.
v2.0.0 - 2026-08-09
Added
- Multi-Site Master Architecture. Multi-Site Master badge,
/api/directory-admin/site-statusAPI, and Master site promotion UI. - Full Telemetry Dashboard Cards. Rendered active
logged_users, physical partitions, host details, and desktop session/power controls (Lock, Display Off, Log Out, Sleep Host). - Theta Directory Rebranding. Rebranded SSO Manager UI and documentation to Theta Directory.
Fixed
- Agent Action Parameter Resolution. Standardized top-level and nested parameter parsing for agent driver actions (
api_directory_admin.js).
v1.33.0 - 2026-08-08
Added
- Directory Key Badges & Secret Filtering. Added a gold
🔑 Secretbadge next to resources with stored OpenBao secrets and aWith Secretsfilter checkbox to filter the directory tree by secret presence. - Kind-Specific Resource Creation Modals. Added dedicated
openAddSiteModal(),openAddHostModal(), andopenAddServiceModal()modal handlers for Site, Host, and Service resources. - Top Toolbar Reorganization. Updated top tree button to "+ Add Site" and removed legacy
Plumbingslider. - Optional Child Secret Key Name on Inheritance. Made key name optional when inheriting parent secrets — automatically defaulting to the original parent secret key name if left blank.
- Discovered Inventory Merge & Ignore Actions. Added
Merge(merge IP/interfaces/OS metadata into target resource) andIgnore(dismiss discovered item) endpoints (/api/directory-admin/discovered/merge&/api/directory-admin/discovered/ignore) and table action buttons. - Agent Tab Telemetry & Desktop Controls. Rendered Agent Binary Version badge (
v1.8.0), all physical disks and filesystems table, Active Logged-in Users card, and Desktop Session & Power Operations card (Lock, Display Off, Log Out, Sleep Host).
v1.32.0 - 2026-08-08
Added
- Subtype Management & Metrics Drivers Engine. Built a 4-tier driver resolution engine (
services/driver_registry.js) binding resourcesubTypemetadata (systemd,docker,proxmox,wireguard,postgresql,redis,unifi,k8s) to operational telemetry, log streaming, and remote lifecycle control. - Subtype Operations APIs. Exposed
/api/directory-admin/resources/:id/driver-metrics,driver-action, anddriver-logsendpoints. - Explicit Secret Inheritance Mode. Enforced strict upward ancestor lineage (
Resource -> Host -> Cluster -> Site) for secret inheritance, resolving explicit pointers (INHERIT:<parentSlug>:<parentKey>) without exposing sibling directory secrets. - Consolidated External App Tokens. Relocated external OpenBao App Token minting into the Configuration page (
/conf-> External App Tokens tab) and deprecated standalone/vaultnavigation item. - Multi-Secret Key Support. Supported multiple secret keys per resource in OpenBao
secret/data/resources/<slug>/confwith per-key merging and deletion. - Cross-Platform Agent Packaging. Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon).
Fixed
- Ancestry Lineage Querying. Fixed
Resource.findAllAncestors(id)memory filtering overResourceEdge.list()to resolve deep ancestor lineage across all graph depths. - Dockerfile Module Inclusion. Included
COPY nodejs/drivers ./driversinDockerfile.openldapandDockerfile.test-runnerfor clean container execution.
v1.31.0 - 2026-08-07
Added
- Resource Secrets Engine & Zero-View Security. OpenBao KV-v2 encrypted secrets for directory resources (
secret/data/resources/<slug>/conf). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticatedtheta-agentinstances. - Strict Secret Key Regex Validation. Secret keys are validated against
^[A-Za-z0-9_]+$(Standard Environment Variable format, e.g.DB_PASSWORD). - Field-Populating Password Generator. Cryptographic secret generator (
window.crypto.getRandomValues) with length selector dropdown (8–128 chars) populating input fields with security notices. - Multi-Level Secret Inheritance. Dynamic secret resolution across any depth of the resource tree (
Services / Apps -> Hosts / Nodes -> Global Sites). - Non-Blocking UI Confirmations. Replaced browser blocking dialogs with async
app.messages.confirm()banners. - UI Directory Layout Improvements. Fixed Directory table resource name and badge order for enhanced readability.
Fixed
- SSSD
sshPublicKeyMapping. Includedldap_user_ssh_public_key = sshPublicKeyin generated agentsssd.conftemplate.
Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
Added
POST /api/v1/ldap/bindandPOST /api/v1/ldap/search— an LDAP-over-HTTPS API (DESIGN.md §3). A client stops speaking LDAP and instead does an HTTPS call to the SSO, which performs the real bind/search against its own OpenLDAP. This kills the hostname / cross-network / LDAPS-cert-chain pain. Caller auth is a Bearer token: an agent token or a self-service API token (PAT)./searchis restricted to agent callers (the SSSD user/group-resolution use case) and runs under the admin bind — see DESIGN.md §9.5 for the scoped-service-account follow-up.- LDAP byte-pump relay (
utils/ldap_tunnel.js) — the SSO relays raw LDAP bytes from an agent's local socket into its real OpenLDAP and pipes the response back, over the existing agent WSS channel (ldap_tunnelmessages). The SSO does not parse LDAP; it is a transparent socket relay. See DESIGN.md §4. POST /api/v1/agent/secrets— an agent fetches its own node-scoped OpenBao secrets (DESIGN.md §5). The agent may only read undersecret/data/nodes/<id>/*; the SSO fetches with its own OpenBao access, so the agent never holds a Vault token. Agent-token authed (not admin-gated).iam_applycommand — the SSO pushes node-scoped IAM config (sudo rules, SSH keys, access control, revocation) to an agent as a signed high-risk command (DESIGN.md §6). Added toHIGH_RISK_COMMANDS.- Agent capabilities in the Directory UI — the agent reports its enabled
capabilities in its
discoveryframe; the SSO stores them and the host's Metrics tab renders them as green/gray badges, so an operator can see at a glance what each agent is allowed to do. GET /api/agent/join-keys/:id/agents— which hosts enrolled through a given join key. Matches on the traceAgent.enrollalready leaves indescription("Self-enrolled with join key<prefix>") rather than a stored relation.- Join key management in the Install Agent modal — a table (label, prefix,
created date, hosts joined, status) alongside the existing mint/select
dropdown, with Revoke and Delete actions and a click-through to see
which hosts joined via a given key. Previously these were API-only. Revoke
and Delete confirm inline within the row ("Revoke? Yes/No") rather than a
blocking native
confirm()(freezes the whole tab) or the sharedapp.messages.confirm()banner (a single.actionMessageshared by the whole card, so a second click before the first resolves leaves a dangling$('body').one('click', ...)handler from the first call and desyncs which row the banner is actually confirming for).
v1.30.2
Fixed
- Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with
554 5.7.1 ... Sender is not same as SMTP authenticate username. Many authenticated relays require theFromaddress to match the authenticated account or they refuse the send outright.models/email.jsfell back to a hardcodednoreply@theta42.comwhensmtp.fromwasn't set, which no relay ever authorized this account to send as. It now falls back tosmtp.userfirst — the address the account can actually prove it owns — before the hardcoded placeholder. - Catalog page card titles read icon-then-name. Swapped to name-then-icon so the resource name leads.
Docs
docs/configuration.mddidn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.docs/plugins.mdlisted 3 of 4 discovery plugin types (missingdocker) and didn't mention themessagingplugin category (twilio,webhook) at all — added both.docs/vault.mdhad no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).docs/discovery.mdwas unreachable from the docs index and missing its back-link — both fixed.README.md's required-groups list was missingapp_sso_directory_admin(gates Directory/Plugins/Agent admin).
v1.30.1
Fixed
- Test Email always failed with
Email.send is not a function.models/email.jsexports{Mail}; the handler required the module and called.sendon it directly. Every other caller destructures it. The button could never have worked. - Test SMS failed with
Unexpected token '<', "<!DOCTYPE ".... It POSTed tohttps://api.voip.ms/v1.0/sms/sendwith Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET againsthttps://voip.ms/api/v1/rest.phpwithapi_username/api_passwordandmethod=sendSMS, so the fabricated URL returned an HTML page andresponse.json()threw. It could never have sent anything. - All SMS delivery was broken, not just the test button.
models/sms.jscalledPluginInstance.find({…}), but @simpleworkjs/orm has nofind— the query method islist({where}). It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too. - Both test endpoints now send through the same senders every real message uses (
Mail.send,SMS.send). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed. - The SMS credential check no longer demands
conf.voipmswhen a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself. - Both endpoints report a failure as a
400with the underlying reason (VoIP.ms error: invalid_credentials,connect ECONNREFUSED …:587) instead of an opaque500. A misconfiguration is the operator's to fix and the UI should be able to show it. - test: a guard suite that fails the build on any call to a non-existent ORM static (
find/findOne/findAll/where), on requiringmodels/emailwithout destructuring{Mail}, and on any reference to the bogusapi.voip.mshost.
Added
- Install Agent offers the join-key flow. The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
v1.30.0
Adds join keys: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh setup.sh install.
theta-agent — enrollment without pre-registering
- feat: join keys.
POST /api/agent/join-keysmints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued its own per-agent token plus the public key it must pin, delivered in theconfigframe; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not. - feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (
AgentJoinKey). Issue/revoke/delete and every self-enrollment are audited.
Directory
- fix: collapsing the tree did nothing.
applyTreeCollapselocated the caret with$row.find('.tree-caret i')and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every<i class="fa-…">into an<svg>, so moments after a render that selector matched nothing — and the early return skipped settinghideBelowDepth, so no row was ever hidden. Collapse state now lives on the caret button and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace. - fix: the Discovery Plugins delete button did nothing. It called
deleteDiscoveryPlugin(), which was never defined — clicking it only threw aReferenceError. - fix: the plugins pane had no
.actionMessageelement, andapp.messagesconfirmations render into one. Without it the returned promise never settles, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it. - feat: discovery plugin instances can be edited. Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting
********back would otherwise store the asterisks as the secret.
Discovery
- fix: a fresh install no longer presents its own containers as things to triage. The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements.
setup.shdeployssso-manager,proxy,jump-host,openbaoandbao-renewer; all five arrived as unmanaged discoveries awaiting promotion. - fix: Docker container slugs were derived from the container id, which changes on every recreate — so each
docker compose upminted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name. - feat: discovered containers carry
composeProject,composeService,containerNameandsourceId.
Docs
- fix:
/docs/discovery404'd — the slug had no entry, though the Discovery tab's help icon linked to it. Newdocs/discovery.mdcovering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection. - fix: the
agentsslug pointed atplugins.md, sodocs/agents.mdwas unreachable in the app.
v1.29.0
Breaking: theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the sso-broker OpenBao policy must grant secret/agent/*); re-run ./setup.sh.
Security — theta-agent channel
- sec:
/api/agent/wsaccepted any token. There was no agent registry, so the endpoint authenticated nothing: any client that could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signedarbitrary_bash— addressed to a token it guessed. Tokens were generated in the browser (generateRandomHexToken) and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a newAgenttable, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent; unknown or revoked tokens are closed with4001and audited. - sec: the command signing key was ephemeral.
AgentManagergenerated an Ed25519 pair in its constructor, so it changed on every process start and thepublic_keyan agent pinned inagent.ymlstopped matching immediately. The key now lives in OpenBao atsecret/agent/signing-keyand survives restarts. If it cannot be loaded the SSO refuses to send high-risk commands rather than signing with a key no agent has seen (signingAvailable: falseonGET /api/agent/nodes). - sec: commands are addressed by agent id, not token. A credential has no business in a URL, an access log or browser history.
- sec: agent actions are audited. Enroll, update, rotate, revoke, delete, every command (with
signed), and every rejected connection are emitted as structured"component":"agent"log records carrying the acting user.
theta-agent — enrollment & resource binding
- feat:
POST /api/agent/enrollmints the token server-side and returns it once; only its SHA-256 is stored. PlusPUT /nodes/:id(rename/rebind),POST /nodes/:id/rotate,POST /nodes/:id/revoke,DELETE /nodes/:id. Rotate, revoke and delete drop the live socket immediately (4004/4003) instead of waiting for a reconnect. - feat: an agent binds to a host resource (
resourceId). The Directory reads that link instead of guessing by hostname — the oldagentsByHost[name]match silently failed whenever a Directory name differed from the machine's hostname, and aliased two hosts that shared one. - feat: agent discovery reaches the Directory. A bound agent's facts (
os,kernel,cpu,ram_total_gb,disk_total_gb,ip) are written onto its host resource, taggeddiscovery_sources: ["theta-agent"]with anagentIdback-reference. An unbound agent goes through the normal reconciler. PreviouslyhandleDiscoverywrote to an in-memory record and updated nothing — the one source actually running on the host contributed nothing to the directory. - feat: agent state is persisted, so an agent that is installed but offline is now distinguishable from one that never existed; enrollments survive a restart. The Directory status dot reflects this: red means "enrolled and not connected" (a fault), grey means no agent enrolled / revoked / service unreachable. Red previously covered both, making an ordinary directory of hosts look like an outage.
- feat: the Install Agent modal enrolls first and builds the install command from the result, including
--public-key.public_keywas never emitted into the generatedagent.ymlbefore, so no installed agent could verify anything. - fix:
registerAgentis synchronous. Awaiting a database write before attaching the WebSocketmessagelistener lost every agent's firstdiscoveryframe, which it sends the instant the socket opens (wsdrops events emitted with no listener attached).
Directory
- feat: the resource tree is collapsible. Any row with children has a caret; the toolbar collapses/expands everything. State persists per browser, so the shape survives the self-heal reload that follows most edits. An active search overrides collapse so matches inside a folded subtree are never hidden.
- fix: the Proxmox plugin mismatched MAC addresses to IPs. It collected MACs and IPs into two flat lists and zipped them by index, so on any multi-NIC guest — or any guest where one NIC had no address — the directory recorded an address against the wrong MAC. NICs are now keyed by MAC, so a pairing can only come from the source that observed both together.
- feat: Proxmox discovery emits an endpoint resource (named from
/cluster/status) with every node parented beneath it, so one endpoint is one subtree instead of several orphan roots. It deliberately carries no IP: giving it the address it is reached at made the reconciler merge it with the node answering on that address, producing a resource that was its own parent. - feat: discovered guests carry
sourceId(<node>/qemu/<vmid>),node,vmidandmacAddress, so a row traces back to the exact guest on the exact hypervisor. Against a live 3-node cluster this took MAC coverage to 53/54 resources andsourceIdto 54/54. - fix: Proxmox interfaces belonging to something running inside a guest (
docker0,veth*,br-*, VPN tunnels) are filtered out — one Home Assistant VM reported 16 of them alongside its single real NIC, and their 172.x addresses gave the reconciler spurious matches. - fix: a stopped VM still reports its MAC (read from the VM config), a DHCP-configured LXC gets its address from the running container's interface list, and Proxmox nodes report their own IP/MAC (recovered from
enx<mac>predictable names, since/nodes/*/networkcarries nohwaddr). Offline nodes are recorded withstatusinstead of skipped, so a hypervisor that is down no longer looks decommissioned and get garbage-collected after a week. - fix: the reconciler could make a resource its own parent. Two slugs in one payload can resolve to the same row once merged; the resulting self-edge renders as an infinitely nested tree and defeats every ancestor walk in the app. Self-edges and cycle-closing edges are now refused and logged.
- fix: hosts were named after their MAC address.
bestNamepreferred the longer name, so UniFi'sac:16:2d:b3:da:80(17 chars) beat Proxmox's real hostnamedl380-0(7). Names are now ranked (hostname > IP > MAC) with length only as a tie-break within a rank. - fix:
isIpnever matched anything —\\.inside a regex literal matches a backslash, not a dot — so an IP-shaped placeholder name was never replaced by a real hostname a later source discovered. - fix: a discovered device can only merge into a resource of the same kind. A VM named
gitea-runnercould match a hand-created service of the same name on the name rule and overwrite it. - perf: the reconciler reads the inventory once per run instead of once per incoming resource — a ~55-resource Proxmox payload against a similar-sized inventory was doing quadratic full-table reads every run.
- fix: the Discovered Inventory table showed "Unknown IP" for almost everything, because it read
metadata.ipwhile any source that enumerates interfaces stores addresses per-NIC. It now falls back to the first NIC address, and showsvmid, slug,sourceIdand per-interface MAC/name.
Profile
- fix: the API Tokens card is no longer wider than every other card on the site — the section sat outside the page's
.container.
Build & docs
- fix:
Dockerfile.test-runnernever copiednodejs/plugins, so every plugin test suite failed in CI as "Cannot find module" and plugin code was effectively untested. Suite count goes 27 → 29. - docs:
docs/agents.mdrewritten for enrollment, the close-code table, resource binding, the persistent signing key, and a correctedpublic_keyexample (the documentedMCowBQYDK2VwAyEA...was an SPKI PEM body — 44 bytes decoded — where the agent requires the raw 32). - docs:
docs/directory.mdcovers the collapsible tree and the corrected seed hierarchy;docs/plugins.mddocuments what the Proxmox plugin produces and why the endpoint has no IP.
v1.28.0
- fix:
/api/agent/nodesno longer 404s — the previous "unconditional mount" was still inside the post-listenonListenhook, so the REST router landed behind app.js's terminal 404 catch-all and every/api/agent/*request 404'd. The router is now mounted synchronously inapp.jsbefore the 404 handler; only the agent WebSocket setup runs ononListen. - feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
- fix: Directory table no longer goes stale after add/remove edge —
addEdge/removeEdgecalled an undefinedloadData(), which threw and left the host/parent linkage stale until a manual refresh; they now callloadResources().addGroup/removeGroupalso refresh so the Access column stays accurate. - feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
v1.27.0
- fix: Directory group names now match
docs/GROUPS.mdexactly — per-resource groups are{site}_{kind}_{name}_{level}(site_local_host_theta-env_access,site_local_app_sso-manager_access), with the kind always present and the resource name slug stripped of its kind prefix. Services map to theappkind. The access-request + resolver tests were updated to the documented convention. - fix: a site resource now carries only
god_admin+ the site-wide groups ({site}_super_admin,{site}_everyone); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal. - fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate
ResourceGrouplinks; linking is now idempotent (check-then-create). - fix:
/api/agent/nodesno longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up. - fix:
POST /api/shared-secrets/rejected valid slugs — the slug regex now allows underscores (was hyphens-only). - fix:
GET /api/shared-secrets/crashed withs.path is not a function— the list spread dropped the instance'spath()method; now uses the staticSharedSecret.pathFor. - fix: promoting a discovered inventory resource crashed with
Resource.update is not a function—updateis an instance method; the promote handler now loads an instance and callsupdate()on it. - feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New
GET /api/vault/apps. - feat: Vault page documents itself — a
/docs/vaulthelp icon in the header, and the doc now covers the Apps + Shared tabs. - feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
v1.26.1
- fix: the legacy
app_super_admingroup is gone —SUPER_ADMIN_GROUP(nested into every resource's_admingroup by auto-provisioning) is nowgod_admin, anddocker-entrypoint.shno longer seeds or nestsapp_super_admin(god_admin is nested into theapp_sso_*groups directly).isSuperAdminstill recognizes a pre-existingapp_super_adminas a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
v1.26.0
- feat: complete the group model (docs/GROUPS.md) —
god_adminis now seeded into LDAP and nested intoapp_super_admin; every site auto-provisions{site}_super_admin,{site}_hosts_*/{site}_apps_*aggregates and{site}_everyone; per-resource_admin/_accessgroups (named{site}_{slug}_{level}, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild. - feat: the naming convention is now enforced server-side —
POST /api/directory-admin/groupsrejects a group CN that isn't a valid group for the target resource (its own_admin/_access/capability, a site aggregate, a site-level group, orgod_admin), so the free-text field can no longer mint*_accessmember-style names - feat:
god_adminis managed from the Directory — the site resource modal surfacesgod_admin+ the site-level groups as associated groups, so its members (and the site's) are editable right there - fix: Directory agent status dots no longer paint every host red when the
/api/agent/nodesendpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm - fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
- fix: in-app
/docs/<slug>pages returned 500 —Dockerfile.openldapnever copied thedocs/tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the wholedocs/dir now ships, and doc images are served at/docs/images - test: group resolver tests now cover the prefixed site-slug convention (
site_local_...is kept verbatim, not re-slugified tosite-local)
v1.25.0
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}hosts/{site}apps aggregates, and per-resource {site}host_admin/access/; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
v1.24.0
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
- feat: Directory resource slug is now read-only and derived from the name
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
v1.23.0
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (
on: { proxyReq }), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2onProxyReq. - fix: vault proxy header injection ordered before
fixRequestBody— the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed) - fix: initORM add-only schema heal —
sequelize.sync()never ALTERs existing tables, so columns added by newer releases (e.g.PluginInstance.lastLog, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft) - feat: external-app vault tokens are long-lived and auto-renewed — minted via the new
sso-apptoken role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app - feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
v1.22.0
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
v1.21.0
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
- feat: shared secrets — users can publish secrets to secret/shared// and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
- feat: shared-secrets API + Shared tab in the vault UI
v1.20.0
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
v1.19.0
- Added WebSocket endpoint for theta-agent C2
v1.18.0
- feat: Add messaging plugins, Docker discovery, fix reconciliation
Changelog
All notable changes to this project are documented here. Format loosely
follows Keep a Changelog; versions
correspond to git tags (vX.Y.Z) and nodejs/package.json's version.
[1.17.2] - 2026-08-01
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
Terms-of-Service configuration the /conf page was missing. Seven issues:
Fixed
- Plugin slug is now auto-generated from the instance name — the New Plugin
modal no longer asks for a Slug (it derived a stable, unique handle from the
name, appending
-2,-3, … on collision). The generated slug still shows in the table and the Edit (read-only) modal.POST /api/pluginsslugis now optional; an explicit slug is still accepted and validated. (routes/api_plugins.js,views/plugins.ejs) - Plugin schedule is a dropdown, not a raw cron box: Hourly / Daily /
Weekly, plus Custom which reveals the raw 5-field cron input. Stored value
is still a cron string, so the server is unchanged. (
views/plugins.ejs) /vaultsecrets list no longer 403s. Root cause: the per-user, per-app, and admin OpenBao policies grantedlistonly onsecret/metadata/.../*(nested paths), never on the directory path itself — so listing a directory's contents (which checksliston the directory, e.g.secret/metadata/users/<uid>or the mount rootsecret/metadata) was denied.vault_broker.js'suserPolicyHcl/appPolicyHclnow also grantliston the bare directory path, andensurePolicynow always re-writes the policy (idempotent) so already-createduser-<uid>policies pick up the new grant on the next vault-page visit. The matchingsso-adminmount-root grant ships in theta-suite v1.31.1 (setup.sh), whereensure_policyis likewise made always-write so re-running./setup.shapplies policy edits./profileno longer shows literal{{…}}tags. Three template fragments sat outside thejq-repeat="user"scope, so they rendered raw: the card headerProfile: {{user.uid}}, theMembers of {{user.uid}}'s Grouptab label, and the Admin Actions block's{{#isActive}}/{{#isInactive}}buttons. The header/label are now populated by JS (theMemberslabel already had a setter pointing at a missing id); the Admin Actions block is moved inside the scope so{{uid}}/{{#isActive}}/{{#isInactive}}render and the correct Activate/Deactivate button shows. (views/profile.ejs)- Editing a plugin now persists. The Edit modal had been prefilled with the
masked secret values and rendered them as fields, but
PUT /:idonly saves non-secret config — so an edited secret was silently dropped. The Edit modal now shows non-secret fields only (secrets have their own Edit-Secrets modal), removing the confusion. (views/plugins.ejs) - nmap plugin: "NMAP not found at command location: nmap" — the
nmapbinary was not installed in the app image.Dockerfile.openldapnowapk addsnmapin the runtime stage, andplugins/discovery/nmap.jstranslates the opaque node-nmap spawn-missing error into an actionablelastError.
Added
- SMS (VoIP.ms) configuration on
/conf. The existing VoIP.ms SMS sender (models/sms.js, used for 2FA OTP delivery) was configurable only via env / config files. It now has an SMS card on/conf(API username, DID, API password), saved to OpenBao atsecret/sso-manager/confundervoipms, with the API password masked (********) and leave-blank-to-keep — mirroring the SMTP card exactly.models/sms.jsreadsconf.voipms.*at call time, so a saved change takes effect live without a restart. (routes/api_conf.js,views/conf.ejs) - Terms of Service editor moved to
/conffrom the admin Overview dashboard, where it never belonged. The sameapp.tos.get/updateflow, the "require all users to re-accept" checkbox, and theapp_sso_admingate (matchingroutes/tos.js's PUT gate) are preserved. The Overview page keeps stats, notifications, and metrics. (views/conf.ejs,views/overview.ejs)
Notes
- The
/vault403 fix is split across two repos: the sso-side per-user/app policy grants andensurePolicy-always-write ship here; thesso-adminmount-root grant andensure_policy-always-write ship in theta-suite v1.31.1. Re-running./setup.shafter upgrading applies the sso-admin grant; per-user policies self-heal on the next vault-page visit.
[1.17.1] - 2026-08-01
Hardens the runtime SMTP/OAuth secret handling on the /conf admin page to
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
no longer returned in cleartext by GET /api/conf or round-tripped through the
form. They remain saved in OpenBao at secret/sso-manager/conf at runtime
(unchanged) — only how they're surfaced to the admin changes.
Changed
GET /api/confnow maskssmtp.passandoauth.jwtSecretto********(was: returned in cleartext). Non-secret fields (host, port, user, from, secure, issuer, token lifetimes) are returned as before.POST /api/confnow treats a blank or********secret-field submission as "keep the current stored value" — so an admin editing the From address or token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT secret. Only a genuinely new, non-blank value overwrites. The preserved values are re-applied to liveconfimmediately, as before./confpage (views/conf.ejs): the Password and JWT Secret fields carry a "leave unchanged to keep the current value stored in OpenBao" hint; the page copy notes secret fields are masked. No JSON-textarea editing is involved — SMTP is and remains configured through structured form fields.
Notes
- SMTP (and OAuth) config was already saved to OpenBao at runtime before
this release (via
POST /api/conf→baoConf.set('sso-manager/conf'), and overlaid back at boot bybao-conf.init). This release closes the cleartext-exposure gap; it does not move the storage path. - No theta-suite policy change required —
secret/sso-manager/confwas already granted to thesso-brokerpolicy.
[1.17.0] - 2026-08-01
A real plugin system: the half-built discovery plugins (statically
configured in sso-secrets.js, only toggleable for cron/enabled) become
configurable, loadable/unloadable plugin instances you manage from a
dedicated Plugins page and the /api/plugins API, with multiple runtime
copies of each type and per-instance secrets stored in OpenBao.
Added
- Plugin instances — a new
PluginInstanceORM model (nodejs/models/plugin_instance.js, Sequelize) is the registry of configured, scheduled plugin copies. Each has apluginType, a uniqueslug(the discovery source name), a cron schedule, anenabledflag (load/unload), non-secretconfig(JSON), and last-run bookkeeping. Multiple instances of the same type are supported. - Plugin registry (
nodejs/services/plugin_registry.js) — generalizes the one-shot discovery-plugin scan inscheduler.js. Plugin types are modules undernodejs/plugins/<category>/<type>.jsexporting a manifest (type,category,name,description,configSchema,validate,run/discover). ExposesgetTypes,getModule,splitConfig(secret vs non-secret),mask, and required-field helpers for the UI/API. - Per-instance secrets in OpenBao (
nodejs/utils/plugin_secrets.js) —configSchemafields flaggedsecret:true(e.g. a ProxmoxtokenSecret, UniFipassword) are stored atsecret/plugins/<instance-id>/conf, never in the DB. The UI only ever sees masked (********) values. Plugins run in-process (BullMQ workers), so they need no OpenBao token of their own — the SSO reads/writes via thesso-brokertoken. Requires theta-suite ≥ v1.30.1 for thesso-brokerpolicy grant onsecret/plugins/*; the API fails-soft with a clear error if absent. /api/pluginsAPI (nodejs/routes/api_plugins.js, replaces the oldroutes/plugins.js) —GET /types, list/get/create/update/update-secrets/ test/load/unload/run/delete/runs. Admin-only (app_sso_admin/app_sso_directory_admin/app_super_admin).- Plugins page (
/plugins,views/plugins.ejs) + nav entry — instance table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms rendered from each type'sconfigSchema. validate("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
Changed
services/scheduler.jsnow schedules from thePluginInstancetable instead of staticconf.discovery.plugins+ a Redis override hash. Each instance owns a stable BullMQ JobScheduler id (plugin:<instanceId>) so load/unload upsert/remove one schedule without disturbing the rest. Discovery plugins reconcile results under the instance'sslug.- The three discovery plugins (
plugins/discovery/{proxmox,unifi,nmap}.js) gained manifests (configSchema,validate,runalias).nmap'stargetRangeis non-secret; ProxmoxtokenSecretand UniFipasswordare secret. - The
/pluginspage route renders the page instead of redirecting to/directory; the Agents & Scheduler tab was removed from/directory(plugins are now managed on the Plugins page). The/docs/agentslink is aliased to/docs/plugins. docs/plugins.md,docs/vault.md,docs/_config.yml(nav), andAPI.md(Plugin Endpoints section) document the new system.
Legacy migration
On first boot of v1.17.0, if the PluginInstance table is empty and
conf.discovery.plugins has entries, one instance per configured type is seeded
automatically (secret fields copied into OpenBao). After that the static
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
empty-table check).
Prerequisite
theta-suite ≥ v1.30.1 — re-run ./setup.sh after upgrading so the
sso-broker OpenBao policy is granted secret/plugins/*. Without it, storing
plugin secrets fails with a clear error.
[1.16.1] - 2026-08-01
Fix: the Configuration (/conf) and Vault (/vault) pages returned 401 for
a logged-in admin. Both view routes did server-side auth using req.user, but
this app's auth-token is a header set by client-side JS (localStorage), not a
cookie — so req.user is undefined on a plain browser navigation.
permission.byGroup(undefined, …) throws status 401, and the middleware.auth
gate on /vault threw Auth.errors.login() (401) for the same reason.
Both routes now render the shell unconditionally (like /users, /directory,
/overview) and gate client-side: conf.ejs already called
app.auth.forceLogin(['admin','app_sso_admin']); vault.ejs now derives
isAdmin + the personal namespace from /api/user/me after forceLogin()
instead of server-rendering them. The /api/conf and /api/vault endpoints
still enforce app_sso_admin + the OpenBao scope server-side, so protection is
unchanged — only the view-route gating moved client-side where the session
actually lives. Also removed a dead duplicate /conf route definition.
[1.16.0] - 2026-08-01
OpenBao becomes the central secrets store for the theta42 stack, and the SSO Manager becomes its broker. This is the SSO's half of the move: it loads its own secrets from OpenBao, mints scoped tokens for users and external apps, and exposes a fixed, role-scoped personal-secrets UI.
Changed
- Secrets now load from OpenBao at boot via
@simpleworkjs/bao-conf, which
deep-merges
secret/sso-manager/confover the file-loaded config (replacing the oldutils/conf_manager.js, which did a shallow-per-key merge).bin/wwwrunsbao-conf.init()aftermodels.initORM()and beforelisten. Fail-soft: if OpenBao is unreachable, boot continues fromCONF_SECRETS. The SSO authenticates with a scopedVAULT_TOKEN(policysso-broker), never the root token. The admin Configuration UI (/api/conf) now writes throughbao-conf.set('sso-manager', …). /api/vaultproxy reworked — the old endpoint was an ungated pass-through that never injected anX-Vault-Token(so the UI was both ungated and broken). It is nowmiddleware.auth→scopeGuard→ a token-injecting proxy.scopeGuardresolves a per-user (user-<uid>) or per-admin (sso-admin) token via the newutils/vault_broker.js(Redis-cached, minted through thesso-brokertoken role) and enforces a path prefix as a second layer on top of the OpenBao policy. The clientauth-tokenis stripped; only the server-minted token reaches OpenBao.- Vault UI reworked and renamed (
views/vaultwarden.ejs→views/vault.ejs; the/vaultroute is nowmiddleware.auth-gated). Non-admin users see only theirsecret/users/<uid>/namespace; admins get free-form path entry acrosssecret/plus an Apps tab to mint scoped tokens for external apps (secret/apps/<name>/*, shown once with copy +curlconvention). - Bumped package version to track the release tag.
Removed
nodejs/utils/conf_manager.js(replaced by@simpleworkjs/bao-conf).nodejs/views/vaultwarden.ejs(renamedvault.ejs).
Security
- Committed-secrets remediation.
config/sso-secrets.js(LDAP bind password, SMTP,oauth.jwtSecret) andnodejs/test_plugins.js(a hardcoded Proxmox root API token and a UniFi password) were tracked on master. They are now untracked + gitignored (config/*-secrets.js), andtest_plugins.jsis deleted;config/proxy-secrets.js.exampleadded as a placeholder template. The secrets remain in git history — rotation at the providers is the real remediation and is the operator's to perform. OpenBao is now the authoritative store; the local files are seed artifacts only.
Note: releases v1.12.0–v1.15.2 were tagged from merge PRs without corresponding
CHANGELOG.mdentries or GitHub releases; this entry resumes the changelog at v1.16.0.
[1.11.0] - 2026-07-31
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
Fixed
GET /api/discovery/mereturned onlyisPublicresources for every human caller. It resolved the caller's groups fromreq.user.groups, which does not exist —req.useris aUsercarryingmemberOf(DNs). The empty list failed open into "no group-granted resources", so "My Services" on the profile page and the portal's service list were blank for everyone. The same bug madeisDirectoryAdmin()false for real directory admins, silently downgrading them to the public metadata projection. Group CNs now come fromutils/user_groups.js.- The portal's "Discover More Services" was dead for every non-admin. It called the admin-gated
directory-admin/resourcesand swallowed the 403 into an empty array — so the one discovery feature never rendered for the audience it existed for. It now calls/api/discovery/resources. - Services reported no address.
/api/discovery/mehad reimplementedResource.getMyAccesswithout its parent-walking address resolution, leaving clients to guessaddress || ip, which is exactly wrong for a service that is reached at its host's IP. Both paths now shareResource.withResolvedAddress(). - Approving access for a user already in the target group threw a 500 and left the request stuck pending.
groupOfNamesrequires at least one member, so a resource's auto-created groups are seeded with the creator's DN; the grant is now idempotent. DELETE /api/directory-admin/resources/:iddeleted the resource before its edges and group links. With no transaction, a failure mid-way orphaned rows pointing at a nonexistent id — invisible in the UI and poisonous togetGraph(). Dependents go first now.PUT /api/directory-admin/resources/:idvalidated the body only after loading the row, and carried a dead if/else whose branches were identical./api/directory-admin/audit-logsshelled out totailthree times viaexecSync; replaced with a bounded async file read (nochild_process, at most the trailing 256 KB).
Added
- End-user catalog at
/, and the first ungated nav item — previously every nav entry was admin-only and a normal user had no signposted destination. Search/filter, per-kind icons, and a how to reach it block per card: the URL for a service, the SSH invocation for a host (using the jump-hostuid_-_slug@hostgrammar whendirectory.jumpHostis configured). - Self-service access requests —
AccessRequestmodel plus/api/access-requests(create, list own, list decidable, approve, deny, withdraw). Approving performs the LDAP group add, so LDAP remains the access-control truth. Requests target a resource'smember-level group, never its_adminone. Replaces the "coming soon" stub. - Admin access visibility: an Access column on the directory table showing member and group counts (and flagging links whose LDAP group has been deleted), plus a "what can this user reach" lookup — the reverse question, which previously had no UI at all. Backed by
GET /api/directory-admin/access-summaryand/user-access/:uid. conf.directory—jumpHostanddefaultSshPort, the connection conventions the catalog renders.tests/access_request.test.js— the request → approve → grant-is-real loop end to end, including the regression guard for theuser.groupsbug.
Added — nested groups
- A group can now contain another group.
groupOfNames.memberaccepts any DN, so nesting needs no new schema; what it needs is resolution, which no released OpenLDAP performs —memberOfand(member=X)both return direct membership only. Two halves:- Server-side: the all-in-one image now builds slapd from a pinned OpenLDAP master commit (
350e9eb3) to get thenestgroupoverlay (ITS#10161), enabled withmember-filter memberof-filter memberof-values.member-valuesis deliberately omitted — it expandsmemberwhen reading a group, which destroys the distinction between "listed here" and "reachable via nesting" and is not recoverable afterwards.pw-sha2is built from contrib in the same stage; without it every existing{SSHA512}password would be unverifiable. - Client-side:
Group.list(dn)computes the transitive closure itself (cycle-detected, depth-capped) when the server can't, selected byconf.ldap.nestedGroupsServerSide— whichdocker-entrypoint.shderives from probing fornestgroup.sorather than hardcoding. Both paths are covered by the full suite.
- Server-side: the all-in-one image now builds slapd from a pinned OpenLDAP master commit (
PUT/DELETE /api/group/:group/nested/:childandGET /api/group/:group/effective, plus a Nested tab on each group card. Cycles are refused (409) rather than silently depth-truncated.app_super_adminis now seeded (it never was) and nested intoapp_sso_admin/app_sso_invite/app_sso_oauth_admin, so the privilege is real LDAP membership visible to SSSD and sudo — not just a special case inutils/permission.js. Not nested intoapp_sso_service_account, which marks non-person accounts rather than granting anything.- Creating a directory resource nests
app_super_admin → <slug>_adminand<slug>_admin → <slug>_access. Both previously required adding every super admin to every new group by hand, so they drifted. ldap_group_nesting_level = 5in ldap-client's SSSD template, for hosts pointed at a server withoutnestgroup. Against the bundled slapd the existingmemberof=access filter is already transitive, so SSH login inherits nesting for free.
Fixed
PUT /api/group/:group/:uidreturned a bare 500 when the user was already a member — common, sincegroupOfNamesrequires a member and so seeds whoever created the group. Now a 409 that says so.- Un-nesting (or removing) the last member of a group returned a 500
ObjectClassViolationError; now a 409 explaining that a group must keep at least one member. GET /api/user/mederivedisAdminfrommemberOf, which is only transitive whennestgroupis present. Against a stock server an admin holding their group via nesting would getisAdmin=falseand lose the entire admin UI while still passing every server-side permission check.utils/permission.js'sbyGroupcheckedgroup.member.includes(user.dn)per group, seeing only direct membership./api/directory-admin/access-summarycountedmembervalues; it now counts the transitive closure, which matters precisely becauseapp_super_adminis nested into every resource's admin group.- Broken
api.htmllink in the published docs (API.mdlives at the repo root, so Jekyll never rendered one); pointed at the source, and added an API entry to the docs nav.
Changed
@simpleworkjs/directory-schemabumped to^1.1.0, which declares the ten metadata keys the admin form has always written but the schema never listed (port,externalPort,isExternalReachable,os,gitRepo,isCurrentSiteas public;vmid,macAddress,installPath,systemdServiceas admin-only). Undeclared keys are dropped for non-admin callers, which blanked the portal'sOS:field, hid every service's port from users, and left machine tokens unable to read the port mapping the firewall consumer exists to render.- Resource metadata now includes
iconandtagline, collected on the admin form (with a live icon preview) and rendered on the catalog cards.
[1.10.0] - 2026-07-30
Added
app_super_admincross-app group: members are full admins here regardless ofapp_sso_adminmembership. Bypassed centrally inutils/permission.js'sbyGroup, folded intoGET /api/user/me'sisAdminflag, and added to nav/forceLogingates. The same group is now also recognized by proxy and jump-host, and byldap-client's SSSD access filter (SSH login on every host).
Changed
- Renamed the Executive page to Overview (route, view,
/api/metrics/overview, nav label, docs)./executivekept as a 301 redirect alongside the existing/admin,/notifications,/dashboardlegacy redirects.
[1.9.0] - 2026-07-28
Added
- Directory modal's Associated LDAP Groups tab now supports full membership management: view, add, and remove members/owners of each associated group directly from the tab, reusing the same
PUT/DELETE group/:group/:uidroutes and member-mapping pattern already used on the Groups page. app.util.revealItem()(in the sharedapp-base.js, byte-identical across the 3 apps): scrolls a just-added/-edited element into view and flashes its background. Wired into the Directory table, the Groups tab's member list, and the Groups page's create-group flow.
Changed
- Groups page's search/sort bar is now sticky, staying visible while scrolling through a long group list. Introduces
--sw-content-offset(set intop.ejsalongside#spa-shell's margin-top) so an in-page sticky element can offset itself below the fixed navbar/update-banner instead of being hidden behind them. - Directory table: Kind/Name/Env/Host merged into a single "Resource" column.
@simpleworkjs/frontendbumped to^0.2.7.
[1.8.3] - 2026-07-28
Changed
profile.ejs's self-service API-token UI unified ontoapp.modal, matching the pattern already shipped this round indirectory.ejs, proxy, and jump-host: the static#secretModal/#editModalelements are retired in favor of the sharedapp.modalsingleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch frombg-*totext-bg-*.- Checkmark-flash copy feedback (silently broken by FontAwesome's
<i>→<svg>replacement) replaced with toast-basedcopyFieldValue, matching proxy and jump-host.
[1.8.2] - 2026-07-28
Fixed
- Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal —
saveResource()calledapp.modal.close()immediately before conditionally showing the secret viaapp.modal.open().app.modalis a singleton, andclose()immediately followed byopen()collides with Bootstrap's hide-transition guard. An interveningawait loadResources()made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
[1.8.1] - 2026-07-28
Fixed
- The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit —
loadLdapGroups()'s fetch-once cache guard (if (ldapGroupsCache) return;) also skipped repopulating the<datalist>on every call after the first, but the modal body (including that<datalist>) is rebuilt fresh and empty on everyapp.modal.open(). Now the fetch is still cached, but the datalist is always repopulated.
[1.8.0] - 2026-07-28
Added
- Directory resource modal: General / Details / Associated LDAP Groups / Children tabs, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
- Resource audit trail:
created_by/created_on/updated_by/updated_on, shown in the modal's new footer (mirrors the convention already used by proxy'sHostand jump-host'sApiToken). Existing resources predating this change show "—" until next edited. - Linkable resource URLs:
GET /directory/:slugplus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to/directory/{slug}while its modal is open and reverts on close (including via the browser Back button). - Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug (e.g.
site_local_myhost_accessinstead ofmyhost_access), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
Changed
@simpleworkjs/frontendbumped to 0.2.6:app.modalgained thetabs/footer/urloptions (all opt-in, existing callers unaffected) plusshowTab/on/deepLinkSlug/formatAudit/footerButtonshelpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
Fixed
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a
jq-repeat/DOM-rebuild timing race, now rendered manually instead).
Operational note
The new Resource audit fields require a schema migration on any existing deployment: ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER; (adjust types for non-sqlite dialects) — @simpleworkjs/orm's sync() only creates missing tables, it never alters existing ones.
[1.7.0] - 2026-07-28
Fixed
formAJAX's loading indicator showed literal HTML ("<div class=..."), not a spinner — it passed raw markup toapp.messages.action, which HTML-escapes its message by design. Replaced with plain text.POST /api/user/(create) andPUT /api/user/passwordhad nomessagefield in their response, so the success notification rendered empty. Added messages matching every other route's convention.- The user landing on
/loginwith a?redirect=had no explanation why — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through/oauth/authorize. Now shows a contextual banner explaining what's happening.
Changed
- Directory: tree view is now the only view (the list/tree toggle is gone) — simpler, one code path.
- Directory: clicking a resource's name opens its detail modal, not just the pencil/edit icon.
Found via a fresh production install's feedback — see the theta-env v1.13.0 release for the full cross-repo summary.
[1.6.3] - 2026-07-28
Fixed
- Group membership changes (
PUT/DELETE /api/group/:group/:uid) didn't invalidate the User cache, soisServiceAccount(and anything else derived frommemberOf) could stay stale for up to 5 minutes after a change. This is what caused a real "lost user" report — the account had landed inapp_sso_service_account(whichusers.ejs's People tab filters out entirely) and looked exactly like data loss, though nothing was ever deleted.
Added
- A confirmation before adding anyone to
app_sso_service_accountvia the Groups page — that group's whole purpose is to hide an account from the People tab, and there was no guardrail against doing that to a real person by mistake (which is how the bug above happened). Every other group's add-member flow is unchanged.
[1.6.2] - 2026-07-28
Fixed
DELETE /api/oauth/client/:id500'd (client.remove is not a function) —OAuthClientwraps@simpleworkjs/orm'sResourcemodel, whose instance delete method is.delete(), not.remove(). The Directory Management UI was unaffected (its own delete routes already used.delete()correctly); only this legacy/raw API endpoint was broken. Found live against a real deployment's SSO API.
Added
- Regression tests: PUT/DELETE on
/api/oauth/client/:idnow verify persistence with a follow-up GET rather than trusting the mutating response alone (this is what would have caught the bug above). A static check across all views/client-side scripts fails CI if any nativealert()/confirm()/prompt()call appears — these block all further browser events on the page and were fully removed in 1.6.1.
[1.6.1] - 2026-07-27
Fixed
- Removed every native
alert()/confirm()call, replacing them withapp.messages.action/confirm/toast. Nativeconfirm()blocks all further browser events on the page (discovered live, mid browser-verification of the 1.6.0app.messages/app.modaladoption, ondirectory.ejs's "Rotate Client Secret" — it froze the whole tab). Also deletedapp.user.remove/app.oauthClient.removeinpublic/js/app.js, which had nativeconfirm()guards and zero callers anywhere in the app.
[1.6.0] - 2026-07-27
Changed
- Adopted
@simpleworkjs/frontend'sapp.messages,app.modal, andapp.validatemodules, replacing the vendoredapp.util.actionMessage/actionConfirm/alertinpublic/lib/js/app-base.jsand the vendoredpublic/lib/js/val.js. Message content is now HTML-escaped (the vendoredalert()this replaces had no escaping), andapp.messages.actionfalls back to a page-wide toast when there's no inline.actionMessagetarget.app.api/app.auth/app.pubsub/app.socketare untouched — they're app-specific (dual-mode callback/promise API,auth-tokenheader injection) and not something the frontend package's genericapp.jsprovides.
[1.5.1] - 2026-07-27
Fixed
PUT /api/user/:uid500'd withObjectClassViolationError(LDAP0x41) when settingsshPublicKeyon any account created before theldapPublicKeyauxiliary objectClass was added to new-user creation (e.g. the bootstrapadminaccount).User.update'ssshPublicKeyhandling andUser.addSSHkey(nodejs/models/user_ldap.js) now add theldapPublicKeyobjectClass first (ignoringTypeOrValueExistsErrorif already present), the same pattern already used fordateOfBirth/theta42Person.- OAuth Integration parent dropdown was blank.
populateHostDropdowninnodejs/views/directory.ejsonly built options forkind === 'host'andkind === 'service'— there was no branch forkind === 'oauth', so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
[1.5.0] - 2026-07-26
Changed
- Unified the front-end UI shell across the three theta42 apps.
views/top.ejs,views/bottom.ejsandpublic/lib/js/app-base.jsare now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a newnodejs/utils/ui.js, exposed to every render asuiviaapp.locals: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all. - One nav-gating model everywhere.
app-base.jsreveals.group-required-<cn>elements for each group the current user is in, read fromGET /api/user/me. sso-manager-node reports LDAP DNs inmemberOfand the OIDC clients report CNs ingroups; both normalise to CNs client-side, and the clients' effective-rightsisAdminflag is exposed as a syntheticadmingroup — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape. GET /api/user/meis fetched once per page load and cached (app.auth.loadUser). The nav, per-viewforceLoginand every group-gated element read that one promise instead of issuing their own request.app.auth.isLoggedInis dual-mode: it returns a Promise and invokes an optional node-style callback, so the async and callback call styles both work against one sharedtop.ejs.app.auth.forceLoginno longer uses$.holdReady(removed in jQuery 4). An unauthenticated user is redirected to/login?redirect=<path>; group requirements are still enforced, andlogOutnow only clears the session, leaving the destination to the caller (ui.logoutRedirect).- Dependency alignment across all three apps:
jquery^4.0.0andejs^3.1.10.
Fixed
app.api.deletedropped its callback when called byformAJAX.formAJAXalways passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran.deletenow accepts both(url, callback)and(url, data, callback).app.api.post/putreferenced an undefinedcallback2and threw when handed a non-function callback. Both are now dual-mode Promise/callback.- The login page's "reveal the card once we know you're logged out" branch threw (
Cannot read properties of null) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready. logInRedirecton the legacy/login/<path>form kept only the path. The OIDC provider routes an unauthenticated authorization request through/login/oauth/authorize?client_id=…&state=…; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
Fixed (sso-manager-node)
public/lib/js/val.jsshadowedmessagewithletinsidevalidateField, so a custom rule's return value never reachedvalidateMessageand the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings thetarget/hostnamerules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.public/js/app.jsused$.isFunction, removed in jQuery 4.
Added (sso-manager-node)
GET /api/user/menow also reportsisAdmin(membership inapp_sso_admin), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still readsmemberOf.
Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin;
forceLogin's onboarding and group gates fire;val.jsblocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
[1.4.0] - 2026-07-25
Security
- The directory discovery API leaked OAuth
client_secret_hash(and any secret-ish metadata key) to every authenticated caller.Resourcedoesn't overridetoJSON, so the ORM serializedmetadatawholesale — including theclient_secret_hashstored onkind:'oauth'resources — acrossGET /api/discovery/resources,/graph,/me,/resources/:slug, and the directory-adminGET /api/directory-admin/resources. Every discovery read endpoint and the admin list now route throughprojectResource/projectResourcesfrom@simpleworkjs/directory-schema, which unconditionally strips secret keys (anything matching/secret|password|privatekey/i, includingclient_secret_hash) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receiveclient_secret_hasheither.
Fixed
- Directory discovery envelope drift.
routes/discovery.js(theautoRouter(Resource)mounted live atapp.js:87) returned bare arrays, not the{ results: [...] }envelope the directory contract specifies — so jump-host'sdata.results || []collapsed every per-group query to[]and no user could bridge. Discovery is now served by explicit/resources,/resources/:slug,/graph,/mehandlers that all return the{ results }envelope. The deadroutes/api_discovery.js(mounted atapp.js:112, after the 404 catcher) and its mount were removed. GET /api/discovery/resources?group=<cn>now returns 200 with{ results: [...] }instead of 404 (the autoRouter'ssearchsupported?group=, but the route was effectively unreachable for jump-host's call pattern).
Added
- Adopted the shared
@simpleworkjs/*packages published under the simpleworkjs org:@simpleworkjs/directory-schema— the directory contract: thekindenum,Resource/ResourceEdge/ResourceGroupfield defs, the{ results }envelope, the security projection (projectResource/projectResources/isDirectoryAdmin), and the discovery client.models/resource.jsimports the field defs; the discovery + directory-admin routes use the projection.@simpleworkjs/ldap—models/user_ldap.jsandmodels/group_ldap.jsnow takeescapeFilter/escapeDNandmakeClient/withClientfrom the shared package (via local wrappers that passconf); sso keeps its richUser.get/Group.get/User.login/User.addSSHkey(posix/write-side stays app-local). sso'smakeClientpasses notlsOptions, so cert validation is unchanged.@simpleworkjs/app-stack— unifiedbuild_info({buildVersion, buildHash, buildYear}) and thestatic-modulesmounting helper.utils/build_info.jsand the static-modules loop inroutes/index.jsuse the shared helpers.
- New
tests/discovery.test.js(jest + supertest, runs under the docker harness): locks in the{ results }envelope on/resources,/graph,/me,/resources/:slug, the?group=200-regression, and the no-client_secret_hash/no-secret-key guarantee for every caller.
Changed
- Dependency alignment:
ldapts^8.1.2→^8.1.8. The new@simpleworkjs/*deps resolve from the npm registry (^1.0.0); nofile:/link:entries in the lockfile, sonpm ciis clean in docker builds. build_infoexport shape changed from{commit, version}to{buildVersion, buildHash, buildYear}(the shared shape used by all three apps).
[1.3.2] - 2026-07-23
Fixed
- OAuth client management API returned
client_id: undefinedon every GET. The ORM'sModel.toJSON()only serializes schema fields, so the mappedclient_id/scopes/redirect_uris/… thatOAuthClient.get()attaches to the wrapped Resource were stripped fromGET /api/oauth/clientandGET /api/oauth/client/:idresponses. The theta-env bootstrap (which lists clients and rotates by the returnedclient_id) then called/api/oauth/client/undefined/rotateand got a 500, aborting stack bring-up whenproxy-secrets.jshad no usable secret.OAuthClient.get()now emits an explicit public JSON shape (and deliberately omitsclient_secret_hash, so the secret hash no longer leaks over the API). OAuthClient.get()no longer 500s on an unknown/undefinedclient id:Resource.get()returnsnull(it doesn't throw), which was dereferenced asr.kind. It now returns a clean 404.
[1.3.1] - 2026-07-23
Added
- The Directory documentation (
docs/directory.md) is now surfaced: registered in-app at/docs/directory("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (site_<name>,host_<hostname>— as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at/api/directory-admin, read-only graph at/api/discovery).
Changed
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed
sudoviasudoRole, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
[1.3.0] - 2026-07-23
Added
- OAuth client management API at
/api/oauth/client(groupapp_sso_oauth_admin): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separatedredirect_uris/allowed_groups, space-separatedscopes). - Dockerized test suite:
docker-compose -f docker-compose.test.yml up --buildspins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them.tests/globalSetup.jshonorsREDIS_URL.
Fixed
- Completed the model-redis →
@simpleworkjs/ormport that shipped half-finished in 1.2.1:OtpToken.issue/verifycalled nonexistentfind()/listDetail()— every OTP login 500'd.- Impersonation create/revoke called nonexistent
ImpersonationToken.listDetail()— both endpoints 500'd. OAuthClientreadis_validfrom the Resource model, which has no such column — every client evaluated as disabled and all/oauth/authorizerequests were rejected with 400. Client validity now lives inmetadata(absent = valid).OAuthClient.adddidn't set the required-uniqueResource.slug; clients now get a slug derived from the client name.GET /api/token/:name/:tokenreturned{results: null}with 200 for unknown tokens (ormget()returns null instead of throwing); now 404s.
User.loginreturns a clean 401 instead of crashing when neitheruidnorusernameis supplied.- Depend on published
@simpleworkjs/orm^0.2.8 andmodel-redis^1.6.0 instead of a localfile:link that brokenpm ciin docker builds.
Changed
- Removed the Mobile Phone field from the user create/edit form.
[1.2.1] - 2026-07-22
Added
- Actionable Metrics: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
- LDAP Monitor: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
- UI Updates: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
- Directory Management: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
[1.1.18] - 2026-07-21
Added
- N-Way Multi-Master LDAP replication:
LDAP_SERVER_ID+LDAP_REPLICATION_HOSTSconfiguresyncreplpeers in the bundled OpenLDAP, and a new/sitespage (nav: Sites) shows each configured peer's LDAP URL and live reachability. - A
locationproperty on users, editable from the profile and user-edit forms.
Fixed
/sites(added above) 500'd on every load:views/sites.ejsincluded nonexistent partialsheader/footerinstead of this app's actualtop/bottom. Fixed to match every other view.
Changed
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
[1.1.17] - 2026-07-18
Added
conf.ldap.ldapsHostandconf.ldap.ldapsPortconfig options (also settable viaapp_ldap__ldapsHost/app_ldap__ldapsPort). WhenldapsHostis set, the/integrationspage advertises that hostname for direct LDAPS binds instead of deriving it from the public OAuth issuer. This lets operators use an internal-only hostname (e.g.ldap.internal.example.comorsso-manageron the Docker network) and avoid port-forwarding 636 to the internet.- A contextual help panel on
/integrations→ LDAP explaining why LDAPS needs a hostname (not an IP), why 636 should not be publicly forwarded, and the recommended internal-DNS / Docker-internal alternatives.
Changed
routes/index.jsnow computes the displayed LDAPS URL fromconf.ldap.ldapsHost/ldapsPortwith fallback to the OAuth issuer host for backward compatibility.secrets.js.example,docs/configuration.md,docs/ldap.md, andDEPLOYMENT.mddocument the newldapsHost/ldapsPortoptions and recommended network layouts.- Bumped version to
1.1.17innodejs/package.json.
[1.1.16] - 2026-07-18
Security
- Hardened LDAP filter and DN construction against injection. All user-supplied values interpolated into group filters (
models/group_ldap.js) and RDN values used when adding users/groups (models/user_ldap.js) are now escaped before being sent to the LDAP server. - Replaced
Math.random()-based token generation inmodels/token.js,models/oauth_code.js, andmodels/oauth_client.jswithcrypto.randomUUID()for session tokens, OAuth codes, access/refresh tokens, and client IDs. - Replaced
Math.random()-based OTP generation inOtpToken.issue()withcrypto.randomInt(). routes/oauth.jsnow refuses to start ifoauth.jwtSecretis missing or still set to the placeholder value, instead of falling back to a hardcoded public string.- Rendered docs and Terms-of-Service HTML in
routes/docs.jsandroutes/index.jsare now sanitized withxssto prevent stored XSS from malicious markdown. - Removed a
console.logthat wrote new-user data (including password hashes) to the log inmodels/user_ldap.js; reduced login-path error logging toerror.name/error.messageonly.
Changed
- Public-release packaging: removed
"private": truefromnodejs/package.jsonand bumped version to1.1.16. - CI workflow (
.github/workflows/pr-tests.yml) now setsapp_oauth__jwtSecretso the test suite can run against the new startup-time JWT validation.
Fixed
models/email.js: fixed a template bug where the renderedfromaddress usedtemplate.messageinstead oftemplate.from.
1.1.15 - 2026-07-18
Changed
- Rewrote
install.shas an idempotent git-clone installer, replacing the old flag-driven, copy-based one —wget -O - .../install.sh | sudo bashnow works the same way it does for theta42/proxy. Installs to/opt/theta42/sso-manager(was/opt/sso-manager). First run only: bootstraps OpenLDAP with a generated admin password + JWT secret and seeds/etc/sso-manager/secrets.js(was/opt/sso-manager/conf/secrets.js, hand-filled from CLI flags); later runs never touch LDAP or the secrets file again.ops/systemd/sso-manager.servicesetsCONF_SECRETS=/etc/sso-manager/secrets.jsto match. install.shnow prints the version it's updating from/to (or "Already up to date") on every run.
Fixed
install.shcould hang indefinitely on a fresh host if a base package pulled intzdataas a new dependency (no TTY for the interactive timezone prompt), or if the debconfslapd/domainvalue was malformed (a raw DN fragment instead of a dotted domain) — slapd's postinst hangs rather than failing cleanly on a bad domain. Both fixed.ops/ldap-setup.sh's ppolicy-overlay checks used an LDAP substring filter against an attribute that doesn't support substring matching, so they always reported the overlay as unconfigured even when it was correctly set up (stored as{0}ppolicy) — the final verification step always failed as a result. Fixed to filter on(objectClass=olcOverlayConfig)instead, matching every other check in that script.
1.1.14 - 2026-07-17
Changed
- Bumped
@simpleworkjs/confto 1.2.0 andjq-repeatto 2.2.0. The Docker entrypoint now sets the newCONF_SECRETSenv var to point directly at a mountedsso-secrets.jsinstead of symlinking it into/app/conf/secrets.js— the app no longer needs write access to its ownconf/directory to pick up mounted secrets.
1.1.13 - 2026-07-17
Fixed
- The new concept docs' cross-links (
concepts-accounts.htmletc.) are the correct, working URL on the Jekyll/GitHub Pages build (where the page's URL is its filename stem) but didn't resolve in the in-app docs viewer, which serves docs at a separate short slug (/docs/accounts). The in-app renderer now also resolves a doc's real filename as a fallback, so one link written in a doc works on both targets.
1.1.12 - 2026-07-17
Added
- Three new plain-language docs aimed at less technical readers, replacing the schema-level LDAP/OAuth/API docs as the target of most card help links: Accounts, Groups & Managers, Connecting Apps (SSO), and API Tokens. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed) now links to its own doc.
Fixed
- The in-app docs viewer rendered every
docs/*.mdpage with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links (ldap.html,index.html, etc.) never resolved in-app, since this viewer serves docs at/docs/<slug>with no.htmlsuffix — they're now rewritten to the correct in-app URL, the same way image paths already were.
1.1.11 - 2026-07-17
Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Invite User, Add new user, User List, Service Accounts, group cards, OAuth/LDAP integration cards, My groups, Members of
<uid>'s group, New API Token) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
1.1.10 - 2026-07-17
Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (
/docs) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
1.1.9 - 2026-07-17
Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of
<uid>'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standardmemberUidattribute (RFC 2307posixGroup).
1.1.8 - 2026-07-17
Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
Fixed
- The Edit Profile form's Mobile Phone field had a stray
validate=":9"making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed. - A service account's profile always showed
Name: Service Account-- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts. - The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via
User.get()before the route handler marks it as a service account (group membership), so the cached copy hadisServiceAccountstuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking. - A user belonging to exactly one LDAP group had their
memberOfattribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (for(let group of user.memberOf)) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." NormalizedmemberOfto always be an array, same fix already applied tomanager.
1.1.7 - 2026-07-17
Changed
- Service accounts unified to one kind. Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its
/api/service-accountroutes) -- every service account is now a real Unix/POSIX account with a UID, created from the new Users → Service Accounts tab. Email and password are both optional for service accounts; a blank password means nouserPasswordis set at all (the account simply can't bind). - Added a
managerfield to every account. Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needingapp_sso_admin. homeDirectoryandloginShellare now editable from the Edit Profile form (previously view-only).
1.1.6 - 2026-07-16
Changed
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic
jekyll-theme-caymantheme, added a real cross-page nav, SEO (jekyll-seo-tag+jekyll-sitemap, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
1.1.5 - 2026-07-16
Fixed
- Bumped
jq-repeat2.0.1 -> 2.1.0.update()is now trailing-edge throttled (~50ms) even on the first call;profile.ejs's edit-profile flow updated a scope and immediately slid the same element into view, which could briefly show stale/empty data. Deferred the slide by 60ms.
1.1.4 - 2026-07-16
Added
- CI: GitHub Actions now builds the real bundled image, seeds LDAP fixtures, and runs the full Jest suite on every PR (Node 18/20/22) -- this repo had unit tests but nothing ran them automatically until now.
- White-label:
<title>, the navbar brand text, and the favicon were hardcoded "SSO - Theta 42"/"SSO Manager" despiteconf.namealready existing (it was never actually rendered). Newconf.logokey added alongside it. Footer attribution is left as-is. Closes #6.
Fixed
- The bundled default ppolicy entry set
pwdLockout: FALSE, silently making the admin "deactivate user" action not actually block that user's login. Fixed toTRUE, with a drift-correction path inops/ldap-setup.shfor already-deployed instances. A separate, deeper ppolicy-overlay issue remains open as #68. top.ejsreferenced a/static/favicon.svgthat didn't exist inpublic/(a pre-existing 404) -- now uses the existing logo file viaconf.logo.
1.1.3 - 2026-07-16
Added
CHANGELOG.md(this file), backfilled from the release notes for every tag so far and served in-app at/docs/changelog. Closes theta-env#43.
1.1.2 - 2026-07-16
Fixed
- Removed a dead IE<9-only
html5shimscript tag pointing at a domain that no longer resolves.
Added
- In-app documentation:
GET /docsandGET /docs/:slugrender this project's own README, DEPLOYMENT, API.md,docs/{ldap,oauth,configuration}.md, anddirectory_spec.mdserver-side — readable from the running app with no dependency on GitHub Pages, which requires internet access to view. Public, no auth, rate-limited.
1.1.1 - 2026-07-16
Added
- Terms of Service is now editable at runtime by admins.
tos.mdused to be baked into the repo and read once at startup, requiring a code change and deploy to update. It's now a Redis-backed singleton, editable from a new "Terms of Service" card on the admin Dashboard, with the bundledtos.mdused only as a one-time seed for new deployments. Admins can optionally require all users to re-accept the terms after a substantive edit. Closes #39. (#62)
1.1.0 - 2026-07-16
First tagged release. Establishes the vX.Y.Z tag convention that the in-app update-check banner polls against going forward.
Added
- Standalone backup script (
ops/backup.sh) — snapshots LDAP (slapcat), Redis, and./config, with retention. - Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page.
Unreleased
[1.14.0] - 2026-08-01
Added
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
- Added Discovery plugin and Scheduler integration within the Directory.
- Re-routed Vault proxy under
/api/vaultand implemented Vault authentication headers.
[1.19.6] - 2026-08-02
Fixed
- Fixed Vault API returning 403 on the Secrets List due to
http-proxy-middlewarev2 rewriting the path incorrectly (it previously appended the/api/vault/mount path to the proxied Vault request).