Repository navigation
feat: first-run launch-token onboarding for unconfigured instances - #116
Merged
Merged
Conversation
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¬ice=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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
改了什么和为什么
全新安装、未改任何配置时,dsh 自动打开的
http://host:port/?token=<launchToken>会被插件的门 302 到登录页;默认 token 模式又没有DSH_AUTH_TOKEN可填,于是任何人都会停在 Access token 输入框(issue #115)。这个 PR 增加一条只在实例尚未配置时生效的首次引导通道:GET+ pathname/+ 恰好一个非空tokenquery 时做形态重定向到/auth/bootstrap;令牌校验、未配置探针、会话签发都在端点里完成。冻结的guard.ts、wrapServer与Gate词表没有改动。users.yaml缺失或零用户。凭据服务缺失、读取抛错、文件不可读一律按 unknown 处理,不引导(fail-closed)。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 全链)。DSH_HOME+ profile,dsh 0.2.1-rc.3):主流程 95 条、已配置加重启 27 条、外部配置后旧会话 22 条、WebSocket 5 条断言全过;打包产物解包后的lib/与仓库逐字节一致。cookieSecure=true):打开 launch URL 落在/auth/setup(First run),页面没有 Access token 输入框;cookie secure / HttpOnly / SameSite=Lax / TTL 约 3600 秒;console 0 条消息。点生成令牌后结果页只显示一次,再开全新 session 访问旧 URL 落到正常登录页。/auth/continue、外部配置后仍渲染向导并改写用户文件;新包三处都拒绝且不写盘。风险与回滚
/auth/bootstrap?token=的 Location 里,与原始请求 URL 同级暴露;这类 302 带referrer-policy: no-referrer,不新增暴露类别。connection.authenticatedUrl)行为与 0.17.4 一致。docs/deployed/known-limitations.md。关联 issue
Refs #115(由随后的 development → main 提升 PR 关闭)。