51 Commits

Author SHA1 Message Date
wmantly f357c89ac7 chore: sync package.json + lockfile to v1.18.0 tag
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m5s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:51:36 -04:00
wmantly 65ba1b16e3 feat: error page + navbar active styling (v1.18.0)
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m2s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m9s
Pull Request Tests / Test Summary (push) Failing after 4s
- Add SSO-style error page (views/error.ejs) and render it for browser
  navigation in the error handler (API still returns JSON).
- Navbar: username not underlined; only the active nav link is bold+underlined.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:48 -04:00
wmantly 36e7dbf8aa fix(jump-host): Filter SSH connection targets to managed hosts only v1.17.2
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m4s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 13:57:11 -04:00
wmantly d533a94718 chore: bump package.json version to 1.17.1 2026-08-03 02:35:26 -04:00
wmantly 16ab12a61c feat: use machine identity for access queries
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m16s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m11s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 18:49:00 -04:00
wmantly 0f6be51c35 feat: downstream host key pinning (#1) 2026-08-02 18:19:35 -04:00
wmantly 463111dfd6 fix: remove DEPLOYMENT.md from Docker build context 2026-08-02 11:52:55 -04:00
wmantly 4874544955 chore: release v1.16.0 2026-08-02 01:54:00 -04:00
wmantly 256476268f feat: implement OpenBao PKI certificate authentication 2026-08-02 01:54:00 -04:00
wmantly 49bf0fa1d3 chore: release v1.15.0 2026-08-02 00:16:18 -04:00
wmantly 1b4764e328 feat: Rename SSO Manager to Jump in UI 2026-08-02 00:16:18 -04:00
wmantly db3333e26d v1.14.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes init() fail-soft on a
missing token (warn + continue from CONF_SECRETS). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:47:51 -04:00
wmantly 1092031a9f v1.14.0: load secrets from OpenBao at boot via @simpleworkjs/bao-conf
bin/www now runs bao-conf.init({ path: 'jump-host' }) before
require('../models'), so the OIDC clientSecret captured at require time
inside createOidcClient sees the OpenBao-merged config. Authenticates to
OpenBao with a scoped VAULT_TOKEN (policy jump-host), never the root
token; fail-soft to CONF_SECRETS if OpenBao is unreachable.
config/jump-secrets.js becomes an operator-edit seed artifact (OpenBao
authoritative). README gains a Secrets section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:28:46 -04:00
wmantly f386a5f9c3 Add ANSI colors to TUI picker
- Box-drawing header with cyan/magenta/green color treatment
- Per-row coloring with alternating cyan shades
- Environment badges (PROD in red, DEV in dim)
- Green inverse selection with '◄ SELECTED ►' indicator
- Yellow filter text and footer separator
- Title changed to 'SSO Manager'
2026-07-31 14:24:54 -04:00
wmantly 14784266b3 Release 1.11.0: super/jump admin, per-host connection tracking, Audit metrics 2026-07-30 12:02:25 -04:00
wmantly dfafffe154 Add super/jump admin, per-host connection tracking; move stats to Audit
- app_super_admin (cross-app, also recognized by sso-manager-node/proxy)
  and a new app_jump_admin group are added to conf.auth: super admins are
  full admins here same as app_sso_admin; jump admins get audit page/data
  access without other admin rights (isJumpAdmin/requireJumpAdmin in
  middleware/auth.js, wired into routes/api.js's audit-data gate and the
  /audit page's client-side forceLogin -- previously the page shell
  rendered for any logged-in user, only the data was gated).
- Dashboard: moved the stat boxes and Top hosts/Top users cards to the
  Audit page (audit is now the admin-facing metrics home; dashboard stays
  focused on "hosts I can reach"). Renamed "All hosts" to "My hosts".
- Host list now shows Last connection/Last failed connection columns and
  highlights rows green (live session, from session_registry) or yellow
  (most recent attempt failed) -- backed by new per-host last-success/
  last-fail timestamps in models/metrics.js, populated by ssh_server.js
  (which now attributes grammar/TUI connect failures to the resolved host
  when one was found, not just aggregate counters) and surfaced through
  GET /api/user/hosts (routes/user.js).
2026-07-30 11:58:28 -04:00
wmantly 061a044a70 Release 1.10.2: page width standardization, Audit admin-gating 2026-07-30 09:50:33 -04:00
wmantly 0ef451e15d Match page width to sso-manager-node; gate Audit nav to admins
- Dashboard, Sessions, and Audit pages now wrap their content in
  <div class="container mt-4">, matching sso-manager-node/proxy's width
  instead of rendering full-bleed inside the fluid shell.
- Audit's nav entry now carries groups: ['admin'] (utils/ui.js), reusing
  the existing synthetic-admin-group nav-gating convention -- the API
  route was already server-side admin-gated, this closes the last gap by
  hiding the nav link/page for non-admins too.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem() and the --sw-content-offset sticky-positioning
  variable, carried over from the same round of changes in
  sso-manager-node/proxy. Not yet called anywhere in this app -- no
  sticky/reveal use case here yet -- but keeps the shared file in sync.
2026-07-29 22:20:56 -04:00
wmantly c002afe043 Release 1.10.1: fix API-token reveal modal race 2026-07-28 21:21:01 -04:00
wmantly a15decb6f7 Fix API-token reveal modal silently not showing after create
app.modal.close() called immediately before showToken()'s app.modal.open()
in the same tick collides with Bootstrap's hide-transition guard on the
singleton modal, so the reveal never appears. open() alone already
overwrites the already-visible modal's content in place. Same root cause
as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the
create-token race fixed in proxy (v1.7.0), found while auditing this
exact pattern across all 3 apps this round.
2026-07-28 21:19:28 -04:00
wmantly 599136e4dc Release 1.10.0: unify API-token UI (card grid, Edit modal, description field) (#25) 2026-07-28 20:20:33 -04:00
wmantly a7d5efc764 Unify API-token UI: card grid, Edit modal, description field (#24)
The self-service API-token UI was inconsistent across all 3 apps
(sso-manager-node/proxy used a card grid with Edit/Rotate/Revoke and a
description field; jump-host used a bare table with no Edit action, no
description field anywhere in the UI, and icon-only buttons -- even though
its model and PUT route already fully supported both). jump-host is first
since it needed the least backend work (none -- description and the PUT
handler already existed, just unexposed) and the most view work, proving
the pattern before porting it to proxy/sso-manager-node.

- Card grid (jq-repeat="apiTokenCard") replacing the table, matching
  sso-manager-node's exact template: name + truncated token-id, optional
  description, a <dl> of Token ID/Created/Last used/Expires, and labeled
  Edit/Rotate/Revoke buttons.
- New Edit modal (app.modal, footer shows "Created by X on Y" via the
  token's existing created_by/created_on) -- net-new UI on top of the
  already-existing PUT /:id route.
- Create modal gained a Description field and now uses
  app.modal.footerButtons() for its Cancel/Create pair.
- Standardized status badges on Bootstrap 5's text-bg-* classes.
- Bumped @simpleworkjs/frontend to ^0.2.6 (footer/footerButtons support;
  this app was still on ^0.2.5) and added the missing app.apiToken.update()
  client wrapper (list/add/remove/rotate already existed).

Found and fixed a real bug along the way: the planned "flash a checkmark on
copy" touch (porting sso-manager-node's copyField pattern) silently does
nothing once FontAwesome replaces <i> icons with inline <svg> -- there's no
<i> left to swap classes on. Renamed the existing copySshCommand() (already
used by the Quick Jump feature, toast-based, unaffected by that FA
behavior) to copyFieldValue() and reused it for the token-reveal copy
button instead of introducing a second, broken copy mechanism.

Verified live: card grid renders with truncated token ID; Edit modal shows
real created-by/on data, saves a description change, and the card
refreshes; Create modal's new description field round-trips; secret-reveal
copy button fires the toast correctly for a real (non-programmatic) click.
2026-07-28 20:07:48 -04:00
wmantly 8a76f71edd Release 1.9.0: Quick Jump copy-to-clipboard section (#23) 2026-07-28 18:11:40 -04:00
wmantly e482f52f10 Add a Quick Jump copy-to-clipboard section to the dashboard (#22)
The uid_-_target grammar-mode SSH command was documented in the README but
nowhere in the UI itself -- users had to remember/reconstruct the format by
hand. Adds a "Quick Jump" card with a one-click-copy command for the
interactive-picker form, plus a copy button on every row of "Hosts you can
reach" that copies the exact grammar-mode command for that specific host
(using the logged-in user's own uid, so it's ready to paste and run as-is).

conf.ssh.listenPort is now passed to the dashboard view so the command can
include the right -p flag when the SSH front door isn't on the default port
22 (theta-env, for example, exposes it on 2222).

Verified live: logged in as the local admin user, confirmed the Quick Jump
command and a per-host command both populate correctly and copy to the
clipboard (toast confirmation), and that the per-host command matches the
exact uid_-_target grammar the SSH server's parseUsername expects.
2026-07-28 18:10:17 -04:00
wmantly a6af160627 Release 1.8.2: audit records carry the real upstream-connect error as failDetail (#21) 2026-07-28 17:57:28 -04:00
wmantly 8c9646b65c Thread real upstream-connect errors into audit records as failDetail (#19)
resolveAndConnect discarded the actual error from connectUpstream
(ECONNREFUSED, ETIMEDOUT, an ssh2 auth failure, ...) and replaced it with
the generic reason string 'upstream-unreachable', so the audit log gave no
way to tell a network-layer failure from an auth failure -- which is why
"Could not reach 192.168.1.206" for the emby host couldn't be root-caused
without live host-shell access. Now the real error message is captured and
surfaced as failDetail, shown as a tooltip on the audit table's fail badge.
2026-07-28 15:50:25 -04:00
wmantly 1d09f243dd Release 1.8.1: Redis persistence fix (#20) 2026-07-28 15:50:11 -04:00
wmantly a5bef2980b Release 1.8.0: fix TUI-mode SSH connection drops, HTML-escaped loading indicator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:28 -04:00
wmantly ab2ee0fed3 Merge pull request #16 from theta42/fix/tui-session-listener-race
Fix: TUI-mode SSH connections could drop with PTY/shell request failed
2026-07-28 13:07:55 -04:00
wmantly a3a6787776 Fix: TUI-mode SSH connections could drop with "PTY allocation
request failed" / "shell request failed"

runTuiSession awaited audit.create() (a Redis round-trip) and then
accessibleHosts() (a directory API call) BEFORE calling runTui(), which
is where the pty/shell/exec/subsystem listeners actually get attached to
the session. The client sends its pty-req and shell requests immediately
after opening the session -- if either await took long enough for those
requests to arrive first, ssh2 auto-rejects any channel request with no
listener (CHANNEL_FAILURE), which is exactly what OpenSSH reports as
"PTY allocation request failed on channel 0" / "shell request failed on
channel 0". The connection then just sat there, since nothing was left
to drive it.

runGrammar already has this exact fix (see its own comment); runTuiSession
never got the equivalent treatment. Fixed the same way: register the
session listeners synchronously, before any await, by having runTui take
a Promise for the hosts list instead of the resolved list -- the shell
handler awaits it internally once the client actually sends a shell
request, which by construction happens only after the listener already
exists.

Verified: publickey auth against a real deployment succeeds (confirms
the earlier ldaps:// fix holds), and the failure reproduces with the
exact reported error strings for a bare `ssh user@host` (TUI/picker mode,
no target) connection. Full suite: 50/50 passing, no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:58:49 -04:00
wmantly 0ead0199a3 Fix HTML-escaped loading indicator in formAJAX
Same fix as sso-manager-node/proxy: formAJAX's loading indicator passed a
raw <div class="spinner-border"> string to app.messages.action, which
HTML-escapes its message by design (@simpleworkjs/frontend). Replaced
with plain text ("Saving…").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:53:08 -04:00
wmantly bc2180116f Release 1.7.1: add no-native-dialogs regression test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:19:25 -04:00
wmantly 98e1e0e279 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on sso-manager-node's equivalent
secret-rotate flow -- it froze the tab). This app has no such call sites
(never did); this static check (scans views/ and public/js|lib/js for
bare alert(/confirm(/prompt() calls) keeps it that way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 23:57:41 -04:00
wmantly 3474482f6f Release 1.7.0: self-service API tokens
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:25:40 -04:00
wmantly 4326d5588e Add self-service API tokens (PATs) — the UI had no way to create one
jump-host had zero API-token support: no model, no route, no UI, and
Auth.checkApiToken was explicitly absent from the createOidcClient() call
(per the comment it left behind). proxy and sso-manager-node both have
this; jump-host didn't.

Ports proxy's models/api_token.js + routes/api_token.js pattern (jmp_
prefix instead of prx_), wires checkApiToken into createOidcClient(), adds
Bearer-token support to middleware/auth.js, and adds a token management
card to dashboard.ejs (create/list/rotate/revoke) using app.modal/
app.messages.

Scope note: a jump-host token carries no group claims (unlike proxy's,
which snapshots the creator's groups), so it authenticates as its creator
for non-admin routes (e.g. GET /api/user/hosts) but can never pass
requireAdmin — a deliberate, conservative default rather than recomputing
live admin status per-request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:44:04 -04:00
wmantly 43caac13d5 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:17:13 -04:00
wmantly 782a829cb9 Adopt @simpleworkjs/frontend's messages/modal/validate modules
Same swap as sso-manager-node/proxy: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm; vendored val.js replaced by the package's app.validate.js.
jump-host's views don't call actionMessage/actionConfirm/alert directly,
so no view changes are needed beyond the script includes.

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as the other two apps' PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:02:15 -04:00
wmantly 4fb4e77007 Dashboard: list hosts you can reach; bump @simpleworkjs/ldap to 1.0.1
- New GET /api/user/hosts (auth-only): all hosts for admins, group-filtered
  list for everyone else.
- accessibleHosts() accepts a pre-resolved user.groups, so the web UI's
  OIDC session skips a redundant LDAP getGroups(dn) call.
- Dashboard shows a "Hosts you can reach" / "All hosts" table.
- @simpleworkjs/ldap 1.0.1 fixes addSshKey's ObjectClassViolationError on
  accounts predating the ldapPublicKey objectClass -- was aborting key
  injection (and the SSH connection) on affected accounts.
- Bump to 1.5.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 23:11:23 -04:00
wmantly d33e324b31 docs: document standalone mode; bump to 1.4.0
Add README/docs/secrets.js.example coverage for the new
@simpleworkjs/orm-backed standalone mode (no LDAP/SSO), and promote the
CHANGELOG's Unreleased entry to 1.4.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 21:35:03 -04:00
wmantly 4879769cc7 Add standalone mode with @simpleworkjs/orm-backed user/host stores
- Add standalone.enabled config flag to switch between LDAP+SSO and
  ORM-backed backends without changing the production code path
- New ORM models: StandaloneUser (uid, passwordHash, sshPublicKeys,
  groups) and StandaloneHost (slug, displayName, kind, metadata)
- user_file.js and hosts_file.js implement the same interfaces as the
  LDAP client and accessibleHosts() respectively
- models/user_ldap.js and utils/access.js become conditional facades
  that delegate based on conf.standalone.enabled at require time
- Zero changes to ssh_server.js core logic, bridge.js, key_inject.js,
  tui_picker.js, or any other consumer
- Fix ssh_server.js: use ?? instead of || for listenPort (0 is falsy)
- Fix ssh_server.js: register session listeners before awaiting
  audit.create() so client exec/shell requests aren't rejected
- Patch StringField.toSequelize() and IntegerField.toSequelize() to
  pass through primaryKey (the ORM's UUIDField already does this)
- 47 tests pass (24 existing + 15 new unit + 3 existing integration
  + 5 new standalone integration)
- Defaults to SQLite; any Sequelize dialect works via conf.orm

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 21:34:53 -04:00
wmantly fe6306b7d3 Release 1.3.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly 240563e093 logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly 047e54ce50 app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly ee76088f86 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

jump-host specifics:
- .group-required base rule added to styles.css (no gated nav items yet).
- #spa-shell drops its inline margin-top; styles.css already sets it, and
  the shared shell adjusts it when a banner is shown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly 67e2fc54c2 Release 1.2.0: adopt shared @simpleworkjs/* packages; fix directory envelope drift
Rewire onto the shared @simpleworkjs/oidc-client, /directory-schema, /ldap, and
/app-stack packages (deleting the byte-identical local forks). utils/access.js
now fetches reachable hosts through the shared directory client, which
validates the {results} envelope and treats envelope drift as a failed group
rather than silently returning []. models/user_ldap.js is a thin wrapper over
createLdapClient (loose TLS default preserved). build_info moves to utils/ with
the shared {buildVersion,buildHash,buildYear} shape. Align ldapts ^8.1.8 and
redis ^6.1.0. Lockfile regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 15:55:03 -04:00
wmantly aa3b3ed515 feat: rebuild web UI on the shared theta42 app stack; OIDC + local admin auth
The web management UI was a bespoke minimal theme with LDAP-bind login.
Rebuild it to match the SSO Manager and Proxy — same stack, same look/feel,
same auth model. The SSH bridge, audit, metrics, and access logic are
unchanged; this is purely the web layer.

Frontend (mirrors proxy/sso):
- Express + EJS with the shared top.ejs/bottom.ejs shell, Bootstrap 5,
  jQuery, jq-repeat, FontAwesome, Socket.IO, and the shared app-base.js /
  val.js client framework (copied verbatim). Vendor libs served from
  node_modules via /static-modules; app assets via /static.
- Dashboard / Sessions / Audit pages render in the common look/feel,
  loading data through the authenticated /api/* endpoints.

Auth (mirrors proxy):
- OIDC against the SSO (utils/oidc.js + routes/auth.js + models/oidc_state)
  plus a local anti-lockout admin (models/user_redis.js, bootstrapped from
  auth.adminUsers[0] / auth.localAdminPass). AuthToken sessions carry the
  group snapshot; middleware gates the data API on adminGroups or the local
  admin. New config: oidc{} + auth.adminUsers/localAdminPass.
- /api/user/me drives the client login state; "Log in with SSO" hidden when
  oidc.enabled is false.

Verified end to end: local admin login -> token -> /api/user/me isAdmin,
metrics/sessions/audit 200 with token / 401 without / 401 bad password;
static + page shells serve; 26 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:57:34 -04:00
wmantly eb8e5b409e Release 1.0.1 — CI portability (test glob + redis service)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:26:50 -04:00
wmantly 9ee5b4bbe0 fix: use shell-expanded globs in test scripts (CI portability)
node's own '**' glob expansion for --test worked on local node 22.23 but
not in CI (node 20/22 runners) — 'Could not find test/**/*.test.js'. Use
plain single-star globs the shell expands instead; test files are exactly
one level under test/unit and test/integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:20:58 -04:00
wmantly 6e6f42e891 test: loosen integration timeouts for CI robustness
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:50:35 -04:00
wmantly 34b3f9f22a ci: PR test workflow + lockfile; require node >=20.14
node --test --test-force-exit needs Node 20.14+/22, so the matrix is
[20.x, 22.x] and engines is bumped to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:49:59 -04:00