Skip to content

feat: first-run launch-token onboarding for unconfigured instances - #116

Merged
TecFancy merged 3 commits into
developmentfrom
fix/115-first-run-bootstrap
Oct 26, 2026
Merged

TecFancy merged 3 commits into
developmentfrom
fix/115-first-run-bootstrap

Conversation

@TecFancy

Copy link
Copy Markdown
Owner

改了什么和为什么

全新安装、未改任何配置时,dsh 自动打开的 http://host:port/?token=<launchToken> 会被插件的门 302 到登录页;默认 token 模式又没有 DSH_AUTH_TOKEN 可填,于是任何人都会停在 Access token 输入框(issue #115)。这个 PR 增加一条只在实例尚未配置时生效的首次引导通道:

  • 两个门只在 GET + pathname / + 恰好一个非空 token query 时做形态重定向到 /auth/bootstrap;令牌校验、未配置探针、会话签发都在端点里完成。冻结的 guard.ts、wrapServer 与 Gate 词表没有改动。
  • 「未配置」的判定:token 模式要求严格读取凭据成功且值为空;password 模式要求 users.yaml 缺失或零用户。凭据服务缺失、读取抛错、文件不可读一律按 unknown 处理,不引导(fail-closed)。
  • bootstrap 会话 TTL 取 min(sessionTtl, 3600),只放行 /auth/*;向导的查看与提交每次都复查探针,配置一成立就吊销全部 bootstrap 会话并拒绝写盘。
  • 向导完成后经 /auth/continue 复用现有 launch-token 桥回到 dsh。token 模式生成的新令牌只在结果页显示一次。

实际跑过的命令与结果

  • npm run verify 退出码 0(format / lint / fsd / slice / no-emdash / lock / decisions / docs / readme / type-check / coverage / build / bundle / host 全链)。
  • 138 个测试文件、1174 个用例通过;覆盖率 94.06 / 88.55 / 93.82 / 96.6,阈值 80。
  • 隔离实例(全新 DSH_HOME + profile,dsh 0.2.1-rc.3):主流程 95 条、已配置加重启 27 条、外部配置后旧会话 22 条、WebSocket 5 条断言全过;打包产物解包后的 lib/ 与仓库逐字节一致。
  • 真实 Chromium(生产默认 cookieSecure=true):打开 launch URL 落在 /auth/setup(First run),页面没有 Access token 输入框;cookie secure / HttpOnly / SameSite=Lax / TTL 约 3600 秒;console 0 条消息。点生成令牌后结果页只显示一次,再开全新 session 访问旧 URL 落到正常登录页。
  • 修复前后 tarball A/B:旧包会让 bootstrap 会话通过 /auth/continue、外部配置后仍渲染向导并改写用户文件;新包三处都拒绝且不写盘。
  • 两轮只读评审抓到两个运行时阻断项(凭据读取失败被当成未配置、配置成立后旧 bootstrap 会话仍能写盘),已在本 PR 内修复并补了回归测试与真栈集成用例。

风险与回滚

  • launch token 会出现在 /auth/bootstrap?token= 的 Location 里,与原始请求 URL 同级暴露;这类 302 带 referrer-policy: no-referrer,不新增暴露类别。
  • 引导通道假设单进程;多副本没有粘滞路由时会回落登录页。旧版 dsh(没有 connection.authenticatedUrl)行为与 0.17.4 一致。
  • 回滚:revert 本 PR 即可;没有新增配置项,向导写入的凭据不受影响。完整边界记在 docs/deployed/known-limitations.md。

关联 issue

Refs #115(由随后的 development → main 提升 PR 关闭)。

@TecFancy
TecFancy merged commit e63e25e into development Oct 26, 2026
6 checks passed
TecFancy added a commit that referenced this pull request Oct 26, 2026
)

* fix(client): show the sign-out on the blank new-session page

A blank session (New Session, no input/response yet) has current set in the session snapshot AND a hidden session header, so both sign-out entries were suppressed there. The hero shell.overlay entry now shows when the current session is undefined OR still blank (byId[id].blank), hiding only for a real non-blank session (header button takes over).

* docs: refresh sign-out screenshots and update README (en/zh)

Replace the stale sidebar-foot screenshots with two real captures from production: the blank new-session page (floating top-right sign-out, dsh workspace collapsed) and a real English conversation (sign-out right of the Session log). Update README.md and README.zh.md to show the new top-right placement; remove the obsolete logout-sidebar-*.png files.

* docs: add npm/github badges to README and clarify package description

* build: align dsh ecosystem pins to rc.7 line

* build: re-resolve lockfile to a consistent caret tree

* feat(client): move sign-out to sidebar footer with localized label

* feat(client): move sign-out into settings as a centered CTA

* docs: document CLI invocation for profile-installed plugins

dsh plugin add installs the package into the profile's node_modules; the dsh-auth bin is not added to PATH, so README and deployment docs now call it through the profile (pnpm --dir ... exec dsh-auth) and gain a Troubleshooting section. Fixes TecFancy/dsh-auth-gate#44.

* feat: authenticated local proxy for the dsh configuration plane + X-Dsh-Proxy deny-list (#47)

* feat(proxy): authenticated local proxy bin

* feat(guard): X-Dsh-Proxy deny-list for host-native methods

* docs: local proxy and deny-list deployment sections (zh+en) with systemd example

* test(proxy): cover CLI parsing and upgrade header/token branches

* docs: README local-proxy section and English design doc

* refactor: move agent skills to .agents/skills

dsh only auto-discovers project skills from .agents/skills/ (and
.dsh/skills/); the skills/ directory was never picked up despite the
AGENTS.md claim. Move both skills there and fix the description.

* ci: add scenario gate runner with change-surface detection (M3)

- scripts/run-gates.mjs (shared from the framework): named scenarios
  derived from the verify chain; auto-detects the change surface and
  runs the minimal evidence set, fail-closed to the full set
- pre-push runs npm run gates -- --base @{u} instead of only type-check
- CI: split the monolithic check job into hygiene/quality/build
  parallel jobs (no path filters - full coverage always)
- docs/development.md: document gates and the new pre-push

* build: sync lockfile root entry with package.json (rc.6 pins, proxy bin)

* feat: make logout CTA slot order configurable via logoutOrder

* feat: ship config quick-reference skill with the package + dsh-auth skill install

* chore: make bundled config skill user-only (disable-model-invocation)

* refactor: restructure src into layered slices with barrel-only imports

* chore: port framework verify gates (bundle/slice/no-emdash/decisions) into verify chain and CI

* docs: land decision-record process with backfilled D1-D5 ADRs, sync structure docs and README

* docs: make bundled config skill bilingual (en/zh)

* docs: record branch hygiene pitfalls (delete-branch on promotion PRs, accidental main commits)

* feat: TOTP two-stage login (RFC 6238, stateless challenge cookie, replay guard, user totp CLI)

* docs: M4 TOTP spec (en/zh), ADRs D6-D9, README zh/en, handoff and config skill sync

* ci: scope GitGuardian test-fixture ignores to test files

* ci: drop GitGuardian integration (app removed; repo gates cover secrets discipline)

* docs: add TOTP verification step screenshot to README

* fix: sign TOTP challenge cookies and fail-close submit path

* docs: amend M4 TOTP spec, record challenge-cookie ADR and skill notes

* docs: reorganize docs by lifecycle, add zh/en mirrors and 0.11.1 upgrade notes

* feat: bridge dsh launch token into password login session

After successful password (or TOTP second-leg) login, redirect to the
dsh launch-token URL when the connection service exposes one, so a new
browser reaching the app through auth-gate never hits the 401 token gate.

- makeLaunchTokenBridge(ctx): soft-read connection service; falls back
  to the plain next URL when the service or authenticatedUrl is missing
- issueSession now awaits the bridge (host from req.headers.host) before
  the 302; failures degrade to the original redirect
- unit tests: bridge hit / undefined / throw / no-host / TOTP two-leg

* docs: add grok-4.6 review of launch-token bridge (19c8431)

* fix: harden launch-token bridge per grok-4.6 review (F1-F6)

- redirect to a relative /?token=, stripping host/scheme from
  authenticatedUrl (avoids TLS http downgrade and Host-confused Location;
  F1/F2)
- two separate warn latches: service missing vs call/parse errors (F4)
- drop req.headers.host from the bridge chain entirely
- move bridge to src/launch-token-bridge.ts (root layer; slice whitelist)
- integration test with a real cordis stack + fake connection locks the
  assembly edge, asserts relative Location and session cookie (F5)
- unit tests assert Set-Cookie still issued and challenge cookie cleared;
  drop the obsolete no-Host case

* docs: record launch-token bridge hardening (b7e48e5)

- impl-launch-token-bridge.md: behavioral contract + frozen decisions
  D-bridge-1..6, deployment notes, test matrix, change log
- reverse-proxy guides (zh/en): 4.2.1 note on the launch-token gate;
  relative redirect works in both topologies, absolute redirects never
- README: signing-in bullet for the /?token= hop on dsh 0.1.2-alpha+

* docs: sync README.zh.md with the launch-token bridge note

* docs: deployment upgrade checklist covers launch-token gate (dsh >= 0.1.2-alpha)

* chore: enforce doc bilingual pairing and size red line (docs:check)

- scripts/verify-doc-pairs.mjs: every doc under docs/ must ship as
  <name>.md (EN) + <name>_zh.md (ZH); decisions/ keeps the official
  (en|zh).md naming; single-language exemptions only for README.md /
  decisions.md / demo/ / design/; totp-fix-plan naming inversion
  frozen as a non-growing exception; 50 KiB size red line with
  frozen-spec exemptions (impl-m3, totp-fix-plan)
- package.json: docs:check wired into the verify chain
- docs/README.md: '文档规范' governance section
- docs/specs/development(_zh).md: document the docs gate

* docs: translate six single-language docs, add doc templates

- English counterparts for src-refactor-plan, login-page-polish-plan,
  pr53-login-page-fix-plan, impl-client-logout, impl-launch-token-bridge,
  grok46-launch-token-bridge-review (naming now follows <name>.md EN +
  <name>_zh.md ZH)
- templates: implemented/ (impl spec skeleton), reviews/ (review record),
  handoff/ (phase handoff) - each paired zh/en
- zh docs link to the _zh variants of impl-launch-token-bridge

* docs: fix color-table column misalignment in pr53 plan (drop redundant threshold column)

* chore: map docs:check to the hygiene scenario in run-gates

* chore: release orchestration script (development→PR→main→npm→sync-back)

- scripts/release.mjs: subcommands check/pr/merge-pr/release-pr/
  wait-publish/sync-back/ship; squash-merge without --delete-branch,
  release-please PR auto-detection, npm publish polling, main merge-back
- npm scripts: release:check / release:pr / release:merge-pr /
  release:ship / release:sync-back

* chore: make release.mjs repo-agnostic (--branch, pkg from package.json)

* fix: align the client inject list with the 0.1.5 host providers

The declared list still named @deepseek-ai/dsh-client-runtime and
@deepseek-ai/dsh-client-ui-slots. Neither package exists in a dsh 0.1.5
tree, and the client loader skips missing inject entries silently, so the
ordering guarantee the list was meant to express had quietly stopped
working (a still-registered id that resolves to nothing never fails).

Align with the providers the 0.1.5 bundles actually install:
- slots service      <- @deepseek-ai/dsh-client-ui-renderer (super(ctx,'slots'))
- settings slot owner <- @deepseek-ai/dsh-client-ui-settings-general
- auth dictionaries   <- @deepseek-ai/dsh-client-locale

Matches the declaration third-party settings UI plugins ship
(dsh-plugin-subscriptions uses renderer + settings + locale). The client
half itself already declares inject ['slots','locale'] at runtime, so no
source change is needed.

* chore: drop the duplicated README section and gate duplicate headings

README.md carried two byte-identical '## Troubleshooting' sections (the
second one was a copy-paste accident). Remove the second copy and extend
docs:check with a duplicate-heading scan (H2-H4, fenced code blocks
ignored) that also covers the repo-root READMEs, because the docs/-only
pairing checks could never see this class of accident.

Negative-tested: injecting a repeated heading fails docs:check with the
offending line numbers.

* test: freeze the TOTP replay assertion against window drift

The replay case derived one code, then logged in (which derived a second code
internally). When the two derivations straddled a 30s window the first code was
never consumed, so the replay returned 302 instead of 401. Reuse the single
derived code for both submissions; the guard now sees the same counter.

* feat: revoke issued sessions when a user is disabled

SessionStore gains revokeBySubject, and a DisabledSessionSweeper polls
users.yaml (revokeSweepMs, default 5000; 0 keeps M3 behaviour) so
dsh-auth user disable takes effect on already-issued cookies and Bearer
tokens instead of waiting out the TTL. The gate itself stays synchronous and
IO-free (P12): the plugin side owns the sweep, so the CLI needs no access to
the session store.

* test: add the unauthenticated entry coverage regression

Discovers every exact/prefix/upgrade/fallback entry registered in the loaded
profile and probes each one without credentials, failing closed on any 2xx.
Verified on dsh 0.1.5-rc.2: 56 entries from 8 packages, 61/61 rejected.

* docs: correct the session persistence claim and document the new gates

Sessions are persisted under $DSH_HOME/storages (0600, sha256 keys); a restart
clears the rate limiter, the TOTP replay guard and in-flight challenges, not
sessions. Also documents revokeSweepMs, the entry-coverage regression step and
the duplicate-heading docs rule.

* fix: keep the disabled-session sweep from rejecting after disposal

A timer tick could still be in flight while the session domain closes during
plugin teardown, surfacing as an unhandled DomainError (reproduced on the
Windows CI job). The sweep now absorbs store failures with a single warning and
the interval callback never leaves a rejection unhandled.

* docs: align the gate docs, CI hygiene and skill list with reality

* refactor: replace slice barrel wildcard re-exports with explicit lists

* refactor: share the duplicated auth endpoint pieces in a new http layer

* build: drop the two unused dsh client dev dependencies

* fix: declare the dsh host requirement and take storage-domain from the host

The plugin carried no host-requirement declaration at all: dshmarket derives
its badge from top-level engines.dsh, dsh.engines.dsh and @deepseek-ai/dsh-*
peer declarations, and this manifest had none of them, so the store listed it
as "host requirement undeclared".

Worse, @deepseek-ai/dsh-storage-domain - a package the host's own dsh-base
depends on - was an ordinary dependency. pnpm then installed a nested
0.1.0-rc.8 copy inside the plugin while the host tree carried 0.1.5-rc.2,
because ^0.1.0-rc.6 can never match a 0.1.5 prerelease under strict semver
(same-prerelease-tuple rule) even though the market's discovery path
evaluates with includePrerelease. Two versions of the same domain contract
lived in one process, and the nested copy never followed a host upgrade.

Declare engines.dsh and the storage-domain peer as
^0.1.0-rc.6 || ^0.1.5-rc.2. The alternative form satisfies both the dev
corridor (0.1.0-rc.8) and the production host (0.1.5-rc.2) under strict
semver, and using the same string in both places keeps the market's
requirement display deduplicated. devDependencies keeps ^0.1.0-rc.6 so the
local build and test tree is unchanged.

Verified: npm run verify; dshmarket's own manifestFacts and
deriveHostCompatibility report "compatible" for this manifest and
"undeclared" for 0.13.0; a pnpm 11.22 A/B (nodeLinker hoisted,
autoInstallPeers false) shows the nested copy gone; an isolated runtime probe
on host-line 0.1.5-rc.2 packages passes 7/7 (gate 302/401, real password
login, session read back through the host domain). Decision record: D12.

* feat(login): redesigned identity-block gate, host-aware rendering and publicHost

Rework the login card into a cool-neutral checkpoint (soft depth, hairline
rules, the domain as the hero) and make the anti-phishing identity block
host-aware end to end.

- src/shared/login-page.ts: new shared style, .head/.mark markup and
  loginPath() for the TOTP "use a different account" fallback
- src/shared/login-page-assets.ts: split CSS/SVG/scripts out so the renderer
  stays under the 250-line file limit
- password-login.ts / password-endpoints.ts / auth-endpoints.ts: render
  kicker + host + who, keep the challenge cookie on a failed code, and route
  every rendered host through resolvePublicHost()
- src/index.ts: new publicHost option (default "" = request Host header),
  wired into both modes
- tests: identity-block rendering, publicHost precedence/fallback on both
  modes, and the TOTP describe split to fit the function-length limit
- docs/demo: login page and TOTP screenshots regenerated at 1280x800

* docs(decisions): record D14 publicHost and D15 login page redesign

- README.md / README.zh.md: publicHost row in the option table
- docs/deployed/{deployment,reverse-proxy}(_zh).md: identity-block note for
  the Host-rewriting reverse-proxy topology (set publicHost, or the card
  shows a loopback address)
- .agents/skills/dsh-auth-gate-config/SKILL.md: option row, patch example
  and a failure entry for the loopback symptom
- docs/decisions: D14 (publicHost) and D15 (login page redesign) records in
  zh/en, plus the index and docs/README updates

* fix(login): clear the challenge cookie before writeHead on the reset branch

GET /auth/login?stage=password ("use a different account") called
setHeader("set-cookie") after writeHead(200). Node throws
ERR_HTTP_HEADERS_SENT, so the request was reset with no response at all and
the fallback was unusable in production (measured live: status 000 before,
200 plus the clearing cookie after). Write the cookie before writeHead.

The fake ServerResponse in the password endpoint tests now mirrors node:http
(setHeader/writeHead after headers are sent throw), which turns the existing
?stage=password case into a real regression guard instead of a false green;
the TOTP harness got the same treatment plus an optional Host header.

* fix(shared): harden publicHost normalisation

- strip userinfo, so https://user:pass@host/path can no longer render
  "user:pass@host" on a page every visitor sees
- strip query and fragment, and accept protocol-relative values (//host)
- fall back to the request Host header when normalisation leaves nothing
  ("/" or "https://" used to erase the identity block entirely)
- cover IPv6 authorities, ports, userinfo, query/fragment and the empty
  fallback in host.test.ts, and assert the configured host on the TOTP page
  and on the 401 reject page (both were untested for D14)

* fix(login): isolate bidi in the identity block and validate the code without JS

- .host gets unicode-bidi:isolate so an RTL override cannot visually
  reorder the domain line the identity block relies on
- the TOTP input gets pattern="[0-9]{6}": with no JS, required alone allowed
  a shorter code to be submitted and only the server rejected it

* test(password): feed the rewritten Host into the TOTP D14 case

The case claimed to prove that a configured publicHost wins over a rewritten
Host header, but the harness never forwarded a Host (so the negative
assertion was near-vacuous). post() now takes an optional host and the case
passes "127.0.0.1:3080" for both the TOTP page and the 401 reject page.

* fix(proxy-cli): stop defaulting --target to a real public domain

The default target pointed at the author's live deployment, so anyone
running dsh-auth-proxy with default arguments proxied their traffic to a
server they do not own. Default to the RFC 2606 reserved dsh.example.com
instead and record the example-domain rule as D16.

* docs: replace the real deployment domain with dsh.example.com

The deployment notes, proxy docs, plan spec, the manifest ADR and the
systemd sample all wrote the live domain verbatim. Use the reserved
example host so the repository, the npm tarball and the screenshots carry
no pointer to the real instance (D16).

* chore(package): sharpen the npm description and keywords

The npm one-liner still described the package only as an
application-layer auth plugin. Say what it is in the search listing
(login gate, password/token sign-in, TOTP two-factor, rate limiting) and
add totp / two-factor / self-hosted keywords.

* fix(proxy-cli): require --target instead of defaulting the upstream

The reserved example.com default still let a forgotten --target connect
somewhere, and the TLS error that followed said nothing about the real
cause. Requiring the flag fails closed with "--target is required", the
option tables now read "required", and D17 records the trade-off. The
systemd sample and every documented command already passed --target.

* fix(shared): retry the users-file rename on a transient Windows lock

CI hit EPERM renaming users.yaml.tmp over users.yaml on Windows while
antivirus or the indexer held the target handle, which made a green tree
flake. Retry EPERM/EBUSY/EACCES with linear backoff, rethrow anything else
immediately, and rethrow the last error once the budget is spent so the
caller-visible failure semantics do not change.

* docs(skill): cover revokeSweepMs and the disable sweep in the quick reference

The bundled option table never mentioned revokeSweepMs and still claimed
disabling only blocks new logins, which stopped being true when issued
sessions started being revoked. List the option, describe the sweep, and
add the "a disabled user keeps browsing" failure row.

* test(skill): lock the single-file bundled skill and its option coverage (D18)

Add a guard that the skill directory has no references/ directory and one
that every Config option appears in the skill body, so a new or renamed
option must land in the skill first. Record D18 with the evidence behind
rejecting the 2026-09-02 split.

* fix(ratelimit): key login buckets on the real client address behind a trusted proxy

Same-host reverse proxies (Caddy, Cloudflare Tunnel, nginx, Docker host networking)
make every request arrive from a loopback peer, so the limiter collapsed a whole
deployment into one bucket: five mistyped passwords from any device returned 429 to
every login and TOTP submission, including a correct password from a device that never
failed, and any successful login wiped the shared failure count (issue #74).

- new shared/ip-address.ts: IP literal normalization (IPv4-mapped folds into dotted
  form, IPv6 canonicalized) and CIDR matching
- new shared/client-ip.ts: clientIpHeader/trustedProxyCidrs policy plus the per-request
  resolver; the header is read only when the peer is in the trusted set, the rightmost
  address that is not a trusted hop wins, and a bad configuration only narrows trust
- password-login takes the bucket key from an injected resolver, so the password path
  and the TOTP second stage share one resolution point
- default stays byte-identical: clientIpHeader "" reads no header at all

* docs(decisions): record D19 for the trusted proxy client address

P10 ("the IP comes from socket.remoteAddress, XFF is not read") becomes an explicit,
fail-closed exception: the header is only believed when the peer is inside
trustedProxyCidrs, and a bad configuration can only narrow trust. The impl-m3 P10 row
and the login walkthrough carry a D19 amendment note.

* docs(deploy): document clientIpHeader and trustedProxyCidrs

The reverse-proxy docs used to state that rate limiting aggregates by the proxy egress
IP and that X-Forwarded-For must not be trusted. Replace that with the D19 rule: set
clientIpHeader (x-forwarded-for, or cf-connecting-ip behind Cloudflare), only peers
inside trustedProxyCidrs may supply it, and the proxy must overwrite it.

* fix(ratelimit): harden client IP resolution (review follow-up)

Reviewing the first cut found two holes:

- An unparsable segment on the right was dropped before picking the rightmost
  non-trusted address, so `X-Forwarded-For: <forged>, unknown` fell back to the
  client-supplied value, reopening the forgery surface whenever a proxy writes
  `unknown`. Any segment that is not a valid IP now invalidates the whole header
  and the socket address is used instead.
- A peer without an address (a Unix socket proxy) never matched a CIDR, so the
  header was ignored and the deployment still collapsed into one bucket. A peer
  with no address is a local transport and is trusted like loopback.

Also from the same review: an explicit empty trustedProxyCidrs list means "trust
nobody" instead of widening back to loopback, header names accept the full HTTP
tchar set, the value limit moved to 1024 B (IPv6 chains), warnings are capped at
one per untrusted peer (ten in total), and the docs state the new rules.

* fix: render the login card for failed logins instead of a bare text page

Wrong credentials keep their 401 but now answer with the HTML login card
(error slot filled with one constant, submitted username echoed, password
cleared and focused, `Error:` title prefix). Lockouts keep 429 + retry-after
and render the same card with a network-scoped message carrying the static
retry seconds; the submit button stays enabled so a browser without
JavaScript can still retry. The TOTP second stage renders its challenge card
on 429 too.

The password form gains a progressive-enhancement submit guard, and its
failure page rewrites its history entry to the GET URL so a refresh no
longer re-submits the POST and silently spends another failure. Usernames
are trimmed before the lookup; passwords never are.

Frozen invariants stay intact: status codes, one identical constant for all
three failure classes, no query reflection, no-JavaScript submit path, the
6KB CSS budget, token mode (M2) and the lock semantics (#81). See D20.

* chore: widen the declared dsh host range to 0.1.7-alpha.1

`engines.dsh` and the `@deepseek-ai/dsh-storage-domain` peer both stopped at
`^0.1.5-rc.2`, so the manifest declared itself incompatible with the 0.1.7
host line even though 0.14.1/0.14.2 run on a 0.1.7-alpha.1 host unchanged
(14/14 runtime checks green in an isolated instance; no source change was
needed).

Add `^0.1.7-alpha.1` to both ranges. Strictly additive: every version the old
range matched is still matched, and the new clause only adds the 0.1.7
prerelease tuple that `^0.1.5-rc.2` can never reach under strict semver
(same-prerelease-tuple rule). Widening the peer also lets pnpm reuse the
host's own storage-domain copy instead of nesting a second one.

Stable `0.1.6`/`0.1.7` remain covered (`^0.1.5-rc.2` and the new clause).
The `0.1.6-alpha.*` prereleases stay out deliberately: the declared range
tracks the tiers that were runtime-verified, not every published prerelease.

Verified: `npm run verify` (format, lint, no-emdash, slice, lock, decisions,
docs, type-check, coverage 52 files / 390 tests, build, bundle:check);
`package-lock.json` root entry synced with `npm install --package-lock-only
--registry=https://registry.npmjs.org/`; README.md + README.zh.md
requirements sections updated in lockstep.

* fix(login): render the login card for a failed token login

A wrong shared access token answered POST /auth/login with 401 text/plain
"invalid token", so a browser navigation rendered a blank page with no form,
no error and an empty title (issue #85). The password flow already got the
login card in 0.14.2 (D20); this aligns token mode with it.

Recorded as D21, which supersedes D20's "token mode keeps its text/plain 401"
clause (impl-m2 and impl-m3 P14 carry the amendment lines):

- 401 + no-store keep their status/headers; body is now the token card with the
  error slot filled by the single constant INVALID_TOKEN ("Invalid access
  token."); the submitted token is never echoed, the field keeps autofocus.
- 415 (browser-unreachable) and 503 (operator fault) stay text/plain; 413 stays
  text/plain with M19's connection: close and is recorded as a known residual
  (reachable by pasting a body over the 16 KB form limit).
- D20's SUBMIT_SCRIPT is deliberately not injected: token mode has no failure
  budget to spend; the history.replaceState hygiene value is a deferred follow-up.
- TOTP second stage stops rendering the leftover "invalid credentials" and uses
  INVALID_TOTP_CODE ("Invalid or expired code.") instead of the credential constant.

Verification: token tests split from a shared harness (test/token-endpoints-harness.ts)
to respect the line budgets; the real-entry integration test now asserts the HTML
failure body, the constant, no-store and non-reflection, and the 413 lock pins
text/plain. npm run verify green (coverage, slice, decisions, docs, build, bundle).
Independent grok-4.6 review: approve, findings F1-F3 landed
(docs/reviews/grok46-d21-token-failure-card-review.md).

* feat: self-service password change in the settings panel (#91)

* feat: self-service password change in the settings panel (P1)

Adds POST /auth/password (password mode only, 4th exact route) so a signed-in
user can rotate their own password from a new "Account" settings section,
plus the CLI equivalents and the store hardening they both need.

Server:
- password-policy.ts: >=14 chars, upper/lower/digit/special, maxLength 256,
  non-equal to the old password (length -> classes -> old hash, cheapest first)
- password-change.ts: frozen order (405 -> 415/413 -> session 401 -> own rate
  bucket 429 -> users read 503 -> constant-time current check 401 -> TOTP 401
  -> policy 400 -> hash -> locked CAS write 503 -> revoke every session
  including the current one -> clear cookie -> 200 {"ok":true})
- files are written before sessions are revoked; a failed write never revokes
- TOTP shares the login replay guard singleton (same-window reuse = 401
  invalid_totp, documented); every TOTP failure counts toward the change bucket
- success clears must_change_password; CLI user passwd deliberately does not

Store:
- users.yaml mutations go through mutateUsersFile: separate users.yaml.lock
  (owner token + compare-and-unlink, stale/corrupt takeover), locked
  read-modify-write, pre-write mtime/size CAS with <=3 reruns, .bak, and the
  last-admin invariant; CLI (incl. user totp) shares this single entry point

CLI: user passwd (reads two lines in one readline session, refuses --password),
user role <name> <admin|user>, user add --admin, list shows (admin)

Client: settings.section page (id dsh-auth-gate-account, order 500) with a
four-field form, submission state machine, a11y (autocomplete, aria-live,
aria-invalid) and --dsw-* tokens only

Docs: D22/D23 bilingual ADRs, decisions.md, impl-m2 (3 -> 4 exact routes),
impl-m3 (CLI amendments), README/deployment notes incl. known residuals

Verified: npm run verify (10 gates) green, real-HTTP integration tests,
TOTP real-stack tests, and a 15/15 Playwright E2E against an isolated instance

* feat: leave the panel for the login page after a password change

P1.1 follow-ups on top of 8204ce8 (no change to the frozen
`POST /auth/password` contract):

- success keeps its copy for 2.5s, then the device is sent back with
  `location.replace("/auth/login?next=%2F&notice=password-changed")`;
  the button becomes "Sign in again" and takes focus; the pending
  redirect lives in module scope, so the host closing the panel (or the
  section unmounting) no longer cancels it
- `GET /auth/login` maps a whitelisted `notice` key to a compiled-in
  copy in its own `role="status"` slot (password card only: the TOTP
  page, 401/429 pages and every POST re-render stay untouched)
- settings nav identity: label -> "账号安全 / Account security",
  order 500 -> 900, section id unchanged (the host's 0.1.7 cloud-account
  section owns `id: "account"`)
- the plugin now draws its own shield+keyhole mark inside the panel
  (host `settings.section` has no icon option; we do not patch the
  host's nav DOM)
- README (zh/en) screenshots, ADR D24 with migration conditions, and
  source-level pins for the cross-half literals

* test: pass a file URL to --import so the real-CLI smoke test runs on Windows

Node treats a bare `C:\…` specifier as scheme `c:` and aborts with
ERR_UNSUPPORTED_ESM_URL_SCHEME, so both real-subprocess cases failed on
windows-latest while Linux stayed green. The entry script keeps taking a
path (Node converts it itself; a URL there trips the resolve hook).

* feat: mark our settings row and leave for the login page at once

D24.1, after the owner reviewed PR #91:

- success navigates to /auth/login?notice=password-changed as soon as the 200
  arrives (no 2.5 s grace period); the success panel and its "Sign in again"
  button stay as the fallback when navigation is refused, and a refused
  navigation can no longer be reported as a failed change
- account-nav-icon.ts: a temporary DOM stopgap that replaces the host gear in
  our nav row only (label match plus a direct-child svg), self-heals across host
  re-renders, observes childList plus the inline style attribute, and restores
  the row on dispose. It is the one piece of host-DOM coupling; it is deleted
  once upstream ships a settings.section icon option (migration table in D24.1)
- account-redirect.ts shrinks to the URL constant plus redirectToLogin()
- README assets (nav-rail shot), ADR D24.1 in both languages, decisions index

Evidence: npm run verify exit 0 (76 files / 605 tests, client bundle 45.1 KiB);
isolated instance 14/14 with a per-frame rAF probe (the first frame that shows
the row already carries our shield, zero gear frames) and
responseEnd -> navigationStart = 0 ms; grok two rounds, both must-fixes closed.

* docs: split README images by locale and make the two READMEs mirror each other

D24.2, after the owner reviewed the bilingual READMEs:

- screenshots are locale-suffixed (*.en.png / *.zh.png) and re-shot in the light
  theme: the dashboard, the settings nav rail and the change-password panel now
  exist in both languages; the server-rendered login / TOTP / post-change cards
  stay a single shared asset because the plugin renders them in English
- both READMEs are rebuilt on one skeleton (15 sections): pitch, maintenance
  commitment, contents, what it does, what it does NOT do, quick start,
  screenshots, configuration, CLI, bundled skill, troubleshooting, deployment,
  local proxy, requirements, limitations, development, license
- the full limitation list (mechanisms + ADR references) moves to
  docs/deployed/known-limitations.{md,_zh.md}; the Chinese "safe by default"
  bullet regains the behaviour details the English one always had
- new gate scripts/verify-readme-parity.mjs (npm run readme:parity, wired into
  npm run verify): section count, per-section shape (list items, code fences,
  h3), image count + existence + locale discipline, link targets with language
  suffixes collapsed, and the contents list against the heading slugs

Evidence: npm run verify exit 0 (docs 105 files, README parity 15 sections /
6 images / 11 link targets, 76 test files / 605 tests, bundle 45.1 KiB).

* docs: use one English screenshot set and rewrite the Chinese README

D24.3, after the owner reviewed the bilingual READMEs:

- screenshots are English-only: both READMEs reference the same light-theme
  assets, the four *.zh.png files are deleted (including the archived
  fallback-state shot), and the parity gate now requires identical image sets
  and rejects any *.zh.png under docs/demo/
- README.zh.md is rewritten in native Chinese technical prose instead of a
  sentence-by-sentence translation (same skeleton, same facts, same links);
  the literal transfers are gone (version corridors, fix regressions, login
  card, issued sessions, orthogonal, tunneling, slot index)
- reviewed by grok-4.6 in two rounds: 28 Chinese must-fixes + 13 English
  suggestions + 12 fact checks; all applied except two items with stated
  reasons (keep the term 垫片 with a stopgap gloss; keep the maintenance
  commitment sentence, only the dash becomes a comma)
- the parity gate also compares table rows and column counts now, which
  immediately caught the Chinese configuration table missing the
  clientIpHeader and trustedProxyCidrs rows
- fact-checked against the implementation, fixed on both sides: the proxy
  strips Secure from Set-Cookie, login and the self-service change have
  separate lockout buckets, a failed revoke is logged at error level, and
  user disable drops issued sessions through the periodic sweep
  (revokeSweepMs, 5 s by default) instead of leaving them until the TTL
- package.json: the description now names DeepSeek Harness and the
  self-service password change, plus the deepseek-harness keyword (the
  registry text changes on the next publish)

npm run verify: 12 gates green (parity 15 sections / 6 images / 11 link
targets, docs 105 files, 605 tests).

* feat: admin password reset and the restricted session that follows it (#94)

Adds the admin plane to the password login mode and the restricted session
a reset forces on the victim.

Admin plane (password mode only; token mode still registers three routes):
- GET /auth/users lists local users (name, role, disabled, totpEnabled,
  mustChangePassword) in dictionary order, never hashes, salts, scrypt
  parameters or session counts, with cache-control: no-store
- POST /auth/users/password enforces one documented order: method,
  content type and size, Origin, session, admin role re-read from
  users.yaml per request, restricted-session refusal, rate-limit bucket,
  target validation, policy, conditional TOTP, read-modify-write under
  the users lock, revocation of the target's sessions (never the actor),
  then {ok, sessionsRevoked}
- reset refuses a self-reset before the TOTP step, and a failed revoke
  still answers 200 while the password change is already durable

Restricted session:
- a must_change_password login issues kind "password-change-only"
  (15 minutes, no renewal) and drops `next` plus the launch-token bridge
- the gate trusts session.kind alone, never the live yaml flag: clearing
  the flag while a revoke fails cannot promote the cookie to a full session
- the allow set is GET|POST /auth/password, GET /auth/status and
  POST /auth/logout; GET /auth/login redirects to the password page,
  POST /auth/login is 403, the host plane is denied with a navigation-aware
  decision (302 for documents, 401 for API clients, refused WS upgrade)

Origin and audit:
- checkRequestOrigin guards the two authenticated POSTs using
  Sec-Fetch-Site: same-origin or an exact match against the configured
  publicHost, and never falls back to Host; Origin: null, subdomains and
  same-site are refused, and /auth/login stays out of scope by design
- navigation detection uses Sec-Fetch-Mode/Dest rather than Accept
  substrings, and Location stays an in-site relative path
- audit.user.password_reset and .denied carry a fixed ten-key payload; an
  unauthenticated actor is always info and target is clipped to 64
  characters, so anonymous requests cannot raise error-level alerts or
  inject unbounded text into the audit log
- the CLI gains `user enable`, so an operator can undo a lockout without
  hand-editing users.yaml

Docs: ADR D25 in en and zh, the impl-m2 admin contract (including the
TLS-terminating-proxy requirement that publicHost carries a scheme),
known limitations, and both READMEs. The admin-plane material is sliced out
of impl-m2 into a new impl-m2-admin pair so the frozen spec stays under the
50 KiB documentation red line.

* feat(client): settings panel user management block (#95)

Ship the client half of the P2 admin plane: the Settings panel's Account
security page now lists local users and resets another user's password, for
an administrator on a full session only. No server code is touched.

- render gate: role=admin, disabled=false, sessionKind=full and a string name,
  all read from the additive /auth/status fields. Anything else (older server,
  restricted session, disabled admin, partial payload) renders no block and
  issues zero /auth/users requests: the panel never probes the admin API to
  find out whether it may use it.
- list: server order, name/role/disabled/must-change/two-factor badges, the
  actor's own row marked, and a target picker that excludes the actor
- reset form: new password twice with autocomplete=new-password, a code field
  rendered only when the acting admin has TOTP, success clears every field in
  place and re-fetches the list so the must-change badge stays truthful; a
  failed session revocation is an assertive alert, not a green success
- failure mapping is a total function over (status, error) with a generic
  fallback for the text/plain 413/415/503 responses, and 200 {ok:true} is the
  only accepted success shape (a proxy page or {ok:false} is reported as a
  failure instead of wiping the on-screen password)
- fixes from both review tracks: React StrictMode double-mount dropped every
  submit result and hung the button, res.ok accepted any 2xx, the t-less
  fallback dictionary lacked the admin keys, an unknown totpEnabled flag
  omitted the code, and Enter inside the target select submitted the form

Docs: impl-m2-admin 4.5c/5c plus the D25 slices that now live there,
known-limitations, both READMEs and two English screenshots.

* fix: declare dsh 0.2.x support in engines and the storage-domain peer

dsh 0.2.0's plugin gate reads only @deepseek-ai/dsh* peerDependencies (not engines.dsh);
an undeclared 0.2.x host is refused at install and silently skipped at boot (login gate
fail-open). Declare the corridor in both places, move the dev/test host cohort to 0.2.0-rc2,
pin it with the gate's own semver predicate, and make the entry-coverage probe assert
302 -> /auth/login. No runtime source change. Evidence: 968 tests green, isolated no-exemption
boot, 67/67 guarded entries denied; grok-4.6 review (2 rounds) in docs/reviews.

Full detail: #96

* docs: retake the settings-panel screenshots the user management block obsoleted

The Account security panel shot predates the user management block (#95), so it ended at the
Change password button while the README right below it says an administrator sees the block
below that form: the image and the prose disagreed. Retaken in the isolated dsh 0.2.0-rc.2
instance (English UI, publicHost dsh.example.com, no test port in frame) as a single
full-panel shot carrying the form, the user list and the reset form.

- account-change-password.en.png: whole panel, administrator view (752x1387)
- admin-reset-success.en.png: the block after a reset, now on the 0.2.x host shell
- account-nav-icon.en.png: 0.2.x nav rail, same 188px width as before
- login-password-changed.png: host is dsh.example.com like the other two login cards;
  the 127.0.0.1:3086 test port is gone
- delete admin-users-panel.en.png: the full-panel shot already covers its content

Docs only: src/, lib/ and the published runtime behaviour are untouched.

* docs(specs): record how a batched promotion reaches the release notes

release-please builds the CHANGELOG entry and the GitHub Release body from the subject line of
each commit on main, so a promotion PR squashed into one commit yields exactly one entry (and
docs/chore commits never appear). Spell out the two ways out - promote one change per PR, or
curate the release page after the tag - and mark the release body as the only sanctioned
hand-edit, since CHANGELOG.md itself stays generated.

* chore(release): keep the PR number in the squash subject

release-please renders a trailing (#n) as the discussion link in the generated changelog entry,
and a batched promotion gets exactly one entry, so that link is the only context a reader can
follow. Guard against a title that already carries the number.

* feat(icon): give the Plugins page a shield mark (#100)

The package declared no icon, so the plugin manager drew its own default
artwork for this row. The harness reads a relative `icon` path from the
exported package.json, admits five extensions under 256 KiB that stay inside
the package after realpath, and inlines the file as a data URL; the manager
renders it in an <img> and falls back to its own artwork, silently, whenever
any of that is wrong.

- icon.svg: the account row's shield and keyhole, redrawn as a filled badge so
  it reads at the 30px row and 36px card sizes. Colours are explicit on
  purpose: a data URL in an <img> inherits neither currentColor nor the theme.
- package.json: declare the icon, and ship it in `files`.
- src/shared/manifest-icon.test.ts: the four ways this fails quietly, as red
  tests (absolute path, unknown extension, outside the package or over the cap,
  missing from `files`, and a file that decodes but draws nothing).

* fix(proxy-cli): start the proxy when the bin is a symlink (#101)

The entry guard compared `import.meta.url`, which is the real file, with
`process.argv[1]`, which is the symlink the package manager puts in `bin/`.
For a packaged install the two strings never matched, so `main()` was never
called, and `dsh-auth-proxy` exited 0 while printing nothing: no listening
socket, no error, no clue for the user.

Resolve the argv path with `realpathSync` before comparing. A path that does
not resolve now counts as "not this entry" instead of throwing, because the
guard runs on module import, so an importer (a test runner, or `node -e`)
must not crash over its own argv.

The dsh-auth CLI resolved symlinked entries in 0.6.1; this bin arrived in
0.8.0 without that resolution.

* feat(meta): show "Auth Gate" in the Plugins page, not the package name (#102)

The manager falls back to the package name because this package declares no
display metadata. The harness resolves `<package>/locale/en.json` (required
before any scanning happens), reads `meta.title` and `meta.description` from it
and from every other `<language>.json` beside it, and falls back to the package
name and description for whichever language is missing. The reader goes through
the package's exports map, so an unexported locale path is reported to it as
missing, and `files` decides what npm actually publishes.

- locale/en.json ("Auth Gate") and locale/zh.json ("登录门禁"), each with a
  one-line summary.
- package.json: export and publish `./locale/*.json`.
- src/shared/manifest-locale.test.ts: the ways this fails quietly as red tests
  (no en.json, a filename that is not a language id, an empty title or
  description, an unexported or unpublished locale directory).

* fix(token): bridge the dsh launch token after a token-mode sign-in (#103)

* fix(token): bridge the dsh launch token after a token-mode sign-in

* fix(token): sanitize the bridged location, share the fallback rule, and lock the assembly seam

* test(token): close the coverage gaps the second review pass listed

* docs: explain the market's credentials badge in both READMEs

* docs(skill): refresh the bundled config skill for 0.15.0-0.17.0

* fix(gate): roll back guarded routes per entry on unwrap

The wrapServer disposer used to clear the route tables and write back a
snapshot taken at wrap time. Reloading dsh-auth-gate alone (a config
change in the Plugins page) therefore dropped routes other plugins had
registered after the wrap, resurrected routes whose owner had been
disposed, and made that owner's next load throw "duplicate exact route".

Unwrap now swaps an entry back to its original handler only while it
still holds a guard this wrap produced (tracked in per-wrap WeakMaps).
Deleted entries stay deleted, foreign replacements are left alone, and
post-wrap routes survive with the guard removed.

Adds a real cordis + webserver reload repro and per-entry unit tests,
records the change as decision D27 (superseding impl-m1 D6's snapshot
clause), and updates the code-review skill rule that asked for an exact
pre-wrap restore.

* chore: keep the session-memory buffer out of format:check

* test: give the scrypt-heavy admin hardening scenarios a 30s budget

* fix(compat): floor the host corridor instead of appending alternatives

`engines.dsh` and the `@deepseek-ai/dsh-storage-domain` peer both end in
`>=0.2.0-rc.1` now; the three caret alternatives stay as the record of the
host lines that were actually verified. A hand-added alternative always
lags a dsh release, and a rejected bundle is skipped silently, so the login
gate would disappear rather than fail loudly. The guard test now asserts the
floor covers every later host version, and the readme, specs and D27 record
the trade-off. No source change (D27).

* test(http): unit-test the shared auth endpoints

* docs(decisions): propose D29 FSD layering for host and client

The host half moves into src/host/ with FSD's lower three layers
(shared, entities, features) and the client half takes the five-layer
layout, both checked by Steiger, matching dsh-lingualab. Entries and
assembly stay at the src/ root; integration tests move to test/.
Once implemented it supersedes the directory parts of D5 and D11.

* build: empty lib before each build and move test-only code out of src

The build never emptied lib/, so outputs of moved or deleted sources
stayed tracked (lib/features/session/, lib/skill-install.*), and the
CI parity diff cannot see leftovers. scripts/clean-lib.mjs now removes
lib/ first; the rebuild deletes 36 stale or test-only files.

tsconfig.build.json excludes only *.test.ts, so test helpers under src/
were compiled into lib/ and shipped. The 14 integration tests and their
4 helpers move to test/integration/, and the admin and password-login
harnesses move to test/ beside the existing ones. Vitest, ESLint,
run-gates, the slice-check whitelist, the development docs and the
bundled skills follow the new paths. Step 1 of the D29 plan.

* refactor: split session endpoints and dsh home out ahead of the D29 move

* refactor(host): move the host half into three FSD layers

* refactor(client): lay out the client half in FSD layers

Move src/client into shared, entities/account, four features
(change-password, admin-users, logout, nav-icon) and the
account-section widget, each slice behind an index.ts barrel.
index.tsx stays at the client root; there is no pages or app layer.

lint:fsd now runs Steiger over both halves, and slice:check only
accepts client files under the five layer folders plus index.tsx and
its test. lib/ is rebuilt from the new layout.

* docs(decisions): implement D29 and archive D5 and D11

Move D29 to implemented/ with its text describing the landed layout
and register it in docs/decisions.md. D5 (layered src with barrels)
and D11 (HTTP endpoint layer) are superseded by D29 and move to
archived/ unchanged; their hashes seed archived/.manifest.json. D9
stays implemented because its injection rule still applies.

The old src-refactor-plan docs gain a note that D29 supersedes them.

* style(client): reformat imports left unformatted by the FSD move

* build: stop shipping client test declarations in lib/

tsconfig.client.build.json excluded only *.test.tsx, so the five
client *.test.ts files emitted .d.ts and .d.ts.map into lib/client/
and shipped with the package. Exclude *.test.ts as well.

bundle:check now fails if any *.test.* or *.spec.* file appears
anywhere under lib/, so the leak cannot come back unnoticed.

* fix(gate): bounce cross-site navigations so dsh's Strict cookie is sent

A top-level GET that arrives cross-site (Sec-Fetch-Site: cross-site,
Sec-Fetch-Dest: document) carries the gate's Lax cookie but not dsh's
SameSite=Strict session cookie, so dsh answered 401 and a reload kept the
cross-site context. For requests the gate allows, serve a static bounce page
that re-navigates via location.replace(location.href); the retry is
same-origin, so the Strict cookie is sent and the bounce cannot loop.

* refactor(protocol): move the /auth paths into a cross-half contract

The route paths, the password-changed notice key and the URLs built from
them were written more than twenty times across both halves, with two
separate LOGIN_PATH definitions on the host side. src/protocol/index.ts
now defines them once (pure constants and functions, no imports). The
host enters through src/host/shared/protocol/ and the client through
src/client/shared/api/protocol.ts. slice:check allows only those two
bridges to import the contract.

The p11 source pin for the client redirect now asserts that the client
uses the contract constant instead of comparing literals. Records D31
and updates the structure docs.

* build: add host:check contract gate

scripts/check-host.mjs checks what the dsh host relies on and no type
checker sees: the package name, the cordis.patch.yml Loader row, the
client registration id and the host entry name agree; every manifest
target exists after a build; every require() in lib/client.js and every
CLIENT_EXTERNALS entry is a module the shell seeds (mirrors dsh
PLATFORM_MODULES, logged as debt in .rsp/TechDebt.md); no route literal
lives outside src/protocol, every registration names a protocol
constant, every protocol path is registered, and every route the READMEs
mention exists.

--self-test runs eighteen broken fixture trees, one per rule, and fails
if any of them passes. The gate runs in verify after bundle:check, in
the CI build job and in the build scenario of run-gates.

* refactor(protocol): share the /auth payload shapes across halves

Extend the D31 cross-half contract from route paths to the request,
response and error bodies of the /auth endpoints. src/protocol/payloads.ts
holds them as pure types (StatusResponse, AdminUserRow, UsersResponse,
the password-change and admin-reset request/response/error bodies,
UserRole, SessionKind, PasswordRule, Unchecked<T>), re-exported by
src/protocol/index.ts and reached through the two existing bridges.

Host: the status, users, reset and password-change writers are typed
with the contract bodies; form fields are read through the typed
formField<Request>() helper; the user and session entities take
UserRole, SessionKind and PasswordRule from shared/protocol, so no @x
import is needed.

Client: the hand-written admin-types.ts mirror is deleted.
AccountStatusView derives from StatusResponse, AdminUserRow comes from
shared/api, form and result types move to features/admin-users/model,
request bodies are built from contract-typed objects via encodeForm,
and parsers read keys through Unchecked<T>. Runtime behaviour is
unchanged.

Docs: D31 records, the decisions index, the D30 placement table and
docs/specs/development(_zh).md describe the payload half.

* feat: first-run launch-token onboarding for unconfigured instances (#116)

* feat: add first-run launch-token onboarding for unconfigured instances

* docs: document the first-run bootstrap channel and rewrite quick start

* build: regenerate lib/client.js with the locked rolldown
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant