@riceawa/dsh-lan-gateway 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1897,33 +1897,91 @@ const name = "dsh-lan-gateway";
1897
1897
  /** Requires the web server service (binds before this row's apply runs) and the tool registry. */
1898
1898
  const inject = ["webServer", "tools"];
1899
1899
  /**
1900
- * The `lan-gateway` user-settings namespace, mirroring the composition schema.
1901
- * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
1902
- * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
1903
- * shape works against both that release line and the older branded one.
1900
+ * Schemastery configuration validated by the Loader.
1901
+ *
1902
+ * Every field is `.volatile()`, which is what lets the Settings service write
1903
+ * it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
1904
+ * other path (`not volatile`). The mark also changes the runtime shape — a
1905
+ * volatile field arrives as a reference (see `ConfigRefs`), never as the plain
1906
+ * value the rest of this file expects — so read it through `readConfig`.
1904
1907
  */
1905
- const NS = "lan-gateway";
1906
- /** Schemastery configuration validated by the Loader. */
1907
1908
  const Config = z.object({
1908
- enabled: z.boolean().default(false),
1909
- gatewayPort: z.natural().min(1).max(65535).default(3081),
1910
- dshTargetPort: z.natural().min(1).max(65535),
1911
- lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
1912
- lanPasswordless: z.boolean().default(false),
1913
- authRequired: z.boolean().default(true),
1914
- cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
1915
- cookieName: z.string().default("dsh_gw_auth"),
1916
- tlsEnabled: z.boolean().default(false),
1917
- tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed"),
1918
- tlsCertPath: z.string(),
1919
- tlsKeyPath: z.string(),
1920
- tlsSelfSignedHosts: z.string().default("localhost"),
1921
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
1922
- allowInsecurePlaintext: z.boolean().default(false),
1923
- trustedTerminator: z.string(),
1924
- secureCookies: z.boolean()
1909
+ enabled: z.boolean().default(false).volatile(),
1910
+ gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
1911
+ dshTargetPort: z.natural().min(1).max(65535).volatile(),
1912
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
1913
+ lanPasswordless: z.boolean().default(false).volatile(),
1914
+ authRequired: z.boolean().default(true).volatile(),
1915
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
1916
+ cookieName: z.string().default("dsh_gw_auth").volatile(),
1917
+ tlsEnabled: z.boolean().default(false).volatile(),
1918
+ tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed").volatile(),
1919
+ tlsCertPath: z.string().volatile(),
1920
+ tlsKeyPath: z.string().volatile(),
1921
+ tlsSelfSignedHosts: z.string().default("localhost").volatile(),
1922
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
1923
+ allowInsecurePlaintext: z.boolean().default(false).volatile(),
1924
+ trustedTerminator: z.string().volatile(),
1925
+ secureCookies: z.boolean().volatile()
1925
1926
  });
1926
1927
  /**
1928
+ * Unwrap the config references into the plain values every other function in
1929
+ * this file reads. Called on each access rather than once, because a settings
1930
+ * write updates the references in place.
1931
+ * @param refs - the config object handed to `apply`.
1932
+ * @returns one detached plain snapshot.
1933
+ */
1934
+ function readConfig(refs) {
1935
+ const dshTargetPort = refs.dshTargetPort?.get();
1936
+ const authRequired = refs.authRequired?.get();
1937
+ const tlsCertPath = refs.tlsCertPath?.get();
1938
+ const tlsKeyPath = refs.tlsKeyPath?.get();
1939
+ const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get();
1940
+ const trustedTerminator = refs.trustedTerminator?.get();
1941
+ const secureCookies = refs.secureCookies?.get();
1942
+ return {
1943
+ enabled: refs.enabled.get(),
1944
+ gatewayPort: refs.gatewayPort.get(),
1945
+ lanCidrs: [...refs.lanCidrs.get()],
1946
+ lanPasswordless: refs.lanPasswordless.get(),
1947
+ cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
1948
+ cookieName: refs.cookieName.get(),
1949
+ tlsEnabled: refs.tlsEnabled.get(),
1950
+ tlsMode: refs.tlsMode.get(),
1951
+ tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
1952
+ allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
1953
+ ...dshTargetPort !== void 0 ? { dshTargetPort } : {},
1954
+ ...authRequired !== void 0 ? { authRequired } : {},
1955
+ ...tlsCertPath !== void 0 ? { tlsCertPath } : {},
1956
+ ...tlsKeyPath !== void 0 ? { tlsKeyPath } : {},
1957
+ ...tlsSelfSignedHosts !== void 0 ? { tlsSelfSignedHosts } : {},
1958
+ ...trustedTerminator !== void 0 ? { trustedTerminator } : {},
1959
+ ...secureCookies !== void 0 ? { secureCookies } : {}
1960
+ };
1961
+ }
1962
+ /**
1963
+ * Build the reference-shaped config `apply` receives, exactly as the Loader
1964
+ * builds it. Exported for tests that drive `apply` directly.
1965
+ * @param raw - a config object; missing fields take their schema defaults.
1966
+ * @returns one reference per volatile field.
1967
+ */
1968
+ function configRefs(raw) {
1969
+ return Config(raw);
1970
+ }
1971
+ /**
1972
+ * Validate a raw config object the way the Loader does, and unwrap it.
1973
+ *
1974
+ * `Config` marks every field volatile, so a validation hands the values back as
1975
+ * references (typed deeply-readonly by schemastery); this returns the plain
1976
+ * shape the rest of the file reads. Used to judge a config the Settings card is
1977
+ * about to save, before it is persisted.
1978
+ * @param raw - a config object; missing fields take their schema defaults.
1979
+ * @returns the validated plain config.
1980
+ */
1981
+ function validateConfig(raw) {
1982
+ return readConfig(configRefs(raw));
1983
+ }
1984
+ /**
1927
1985
  * The fail-closed problems that prevent a config from enabling the listener.
1928
1986
  * Returns every problem (not just the first) so the operator sees the full
1929
1987
  * migration at once. Exported for tests.
@@ -2101,16 +2159,18 @@ function apply(ctx, config) {
2101
2159
  let connectionGeneration = 0;
2102
2160
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
2103
2161
  let makeRelay;
2104
- /** The authoritative config: settings section when attached, else composition. */
2105
- let configSource = () => config;
2106
- /** Whether writes go to the settings section rather than staying in memory. */
2162
+ /** Whether the settings service is attached, so writes reach the profile entry. */
2107
2163
  let settingsAttached = false;
2108
- /** The settings scope for the `lan-gateway` namespace, while one is attached. */
2109
- let settingsScope;
2110
2164
  /**
2111
- * The settings provider, for the one write a scope cannot express: a section
2112
- * key must be *removed* to re-inherit the composition layer, and only the
2113
- * provider's path-addressed `mutate` can unset one.
2165
+ * This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
2166
+ * the *entry id* — the `lan-gateway` namespace this plugin used to register
2167
+ * with is gone along with `settingsScope`.
2168
+ */
2169
+ let settingsEntryId;
2170
+ /**
2171
+ * The settings service, for the one write a merge patch cannot express: a key
2172
+ * must be *removed* to re-inherit the composition layer, and only its
2173
+ * path-addressed `mutate` can unset one.
2114
2174
  */
2115
2175
  let settingsProvider;
2116
2176
  /**
@@ -2121,7 +2181,7 @@ function apply(ctx, config) {
2121
2181
  let lifecycle = Promise.resolve();
2122
2182
  /** Set by the dispose hook; a start that completes after it must undo itself. */
2123
2183
  let disposed = false;
2124
- const effective = () => configSource();
2184
+ const effective = () => readConfig(config);
2125
2185
  /** Queue one lifecycle action behind every action already running. */
2126
2186
  const enqueue = (reason, action) => {
2127
2187
  lifecycle = lifecycle.then(action).catch((error) => {
@@ -2198,26 +2258,26 @@ function apply(ctx, config) {
2198
2258
  }
2199
2259
  });
2200
2260
  };
2201
- /** Record the run intent where it will survive: the settings section, or memory. */
2261
+ /** Record the run intent where it will survive: the profile entry, or memory. */
2202
2262
  const setRunIntent = async (enabled) => {
2203
- if (settingsAttached && settingsScope !== void 0) {
2204
- await settingsScope.update({ enabled });
2263
+ if (settingsAttached && settingsProvider !== void 0 && settingsEntryId !== void 0) {
2264
+ await settingsProvider.update(settingsEntryId, { enabled });
2205
2265
  return;
2206
2266
  }
2207
2267
  manualOverride = enabled;
2208
2268
  };
2209
2269
  ctx.inject(["settings"], (sctx) => {
2210
- const scope = sctx.settings.register(NS, Config, { base: config });
2211
- settingsScope = scope;
2270
+ const entryId = ctx.fiber.entry?.options.id;
2271
+ if (entryId === void 0) return;
2272
+ settingsEntryId = entryId;
2212
2273
  settingsProvider = sctx.settings;
2213
2274
  settingsAttached = true;
2214
- configSource = () => scope.get();
2215
- sctx.effect(() => scope.watch(() => {
2275
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
2276
+ sctx.effect(() => ctx.on("loader/volatile-update", () => {
2216
2277
  syncGateway("settings change");
2217
2278
  }));
2218
2279
  sctx.effect(() => () => {
2219
- configSource = () => config;
2220
- settingsScope = void 0;
2280
+ settingsEntryId = void 0;
2221
2281
  settingsProvider = void 0;
2222
2282
  settingsAttached = false;
2223
2283
  syncGateway("settings detach");
@@ -2282,12 +2342,14 @@ function apply(ctx, config) {
2282
2342
  send(400, { error: "body must be a config object" });
2283
2343
  return;
2284
2344
  }
2285
- if (settingsProvider === void 0) {
2345
+ const settings = settingsProvider;
2346
+ const entryId = settingsEntryId;
2347
+ if (settings === void 0 || entryId === void 0) {
2286
2348
  send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
2287
2349
  return;
2288
2350
  }
2289
2351
  const { patch, clear, unknown } = buildConfigPatch(submitted);
2290
- const candidate = Config({
2352
+ const candidate = validateConfig({
2291
2353
  ...effective(),
2292
2354
  ...patch
2293
2355
  });
@@ -2306,7 +2368,7 @@ function apply(ctx, config) {
2306
2368
  op: "unset",
2307
2369
  path: [key]
2308
2370
  }))];
2309
- if (ops.length > 0) await settingsProvider.mutate(NS, ops);
2371
+ if (ops.length > 0) await settings.mutate(entryId, ops);
2310
2372
  await syncGateway("config route save");
2311
2373
  const next = { ...snapshot() };
2312
2374
  if (unknown.length > 0) next["ignored"] = unknown;
@@ -2434,4 +2496,4 @@ function apply(ctx, config) {
2434
2496
  }, "dsh-lan-gateway: listener lifecycle");
2435
2497
  }
2436
2498
  //#endregion
2437
- export { Config, apply, buildConfigPatch, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
2499
+ export { Config, apply, buildConfigPatch, configRefs, gatewayStartProblems, inject, isTrustedConfigRequest, name, readConfig, resolveSecureCookies, validateConfig };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@riceawa/dsh-lan-gateway",
3
3
  "description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
4
- "version": "0.5.5",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -42,22 +42,34 @@
42
42
  }
43
43
  },
44
44
  "peerDependencies": {
45
- "@deepseek-ai/cordis": "^4.0.2",
46
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1 || ^0.1.5-rc.2",
47
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1 || ^0.1.5-rc.2",
48
- "@deepseek-ai/schemastery": "^3.18.2"
45
+ "@deepseek-ai/cordis": "^4.0.4",
46
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
47
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1",
48
+ "@deepseek-ai/schemastery": "^3.18.4"
49
49
  },
50
50
  "devDependencies": {
51
- "@deepseek-ai/cordis": "4.0.2",
52
- "@deepseek-ai/dsh-brand": "0.1.5-rc.2",
51
+ "@deepseek-ai/cordis": "4.0.4",
52
+ "@deepseek-ai/cordis-plugin-loader": "1.0.5",
53
+ "@deepseek-ai/cosmokit": "1.8.5",
54
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.2",
58
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
53
63
  "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
54
- "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
55
- "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
56
- "@deepseek-ai/dsh-scope": "0.1.5-rc.2",
57
- "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
58
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
59
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
60
- "@deepseek-ai/schemastery": "3.18.2",
64
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
68
+ "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
69
+ "@deepseek-ai/dsh-settings": "0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
72
+ "@deepseek-ai/schemastery": "3.18.4",
61
73
  "@types/node": "^22.0.0",
62
74
  "@types/react": "~18.3.1",
63
75
  "react": "^18.2.0",
@@ -7,10 +7,9 @@
7
7
  * installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
8
8
  * module scope. With TLS enabled the origin is secure and the shim is a
9
9
  * no-op.
10
- * 2. Settings card: registers the LAN gateway card into the official
11
- * Settings → Plugins page (`settings.plugin.item` slot), editing the
12
- * `lan-gateway` settings namespace so port, CIDRs, auth, and TLS are
13
- * adjustable from the GUI.
10
+ * 2. Settings card: registers the LAN gateway card into the official Plugins
11
+ * page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
12
+ * from the GUI.
14
13
  */
15
14
 
16
15
  /** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
@@ -62,12 +61,25 @@ export function installRandomUuidShim(): boolean {
62
61
  installRandomUuidShim()
63
62
 
64
63
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
65
- import { LanGatewayCard } from './lan-gateway-card.tsx'
64
+ // Type-only, and never bundled: the Plugins page's slot contract (`plugins.item`)
65
+ // and the settings domain's `configForms` service both resolve from the web
66
+ // shell's frozen module table. Importing them is what subjects the registration
67
+ // below to the platform's own contract instead of a local copy that drifts the
68
+ // next time upstream renames a slot.
69
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
70
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
71
+ import { LanGatewayCard, cardTitle } from './lan-gateway-card.tsx'
66
72
 
67
73
  export const name = 'dsh-lan-gateway'
68
74
 
69
- /** Only the slots service: the card itself is self-loading (ModLens-style). */
70
- export const inject = ['slots']
75
+ /**
76
+ * The profile entry id this plugin's bundle patch composes it under, and the
77
+ * settings namespace dsh ≥ 0.1.7 addresses every write by.
78
+ */
79
+ const ENTRY_ID = 'dsh-lan-gateway'
80
+
81
+ /** The slots service the card rides, and the settings mirror the gate reads. */
82
+ export const inject = ['slots', 'configForms']
71
83
 
72
84
  /**
73
85
  * Mount the settings card and the UUID shim.
@@ -76,28 +88,26 @@ export const inject = ['slots']
76
88
  export function apply(ctx: ClientContext): void {
77
89
  installRandomUuidShim()
78
90
 
79
- // The card rides the official Plugins → Configurable tab. Like ModLens, it
80
- // registers with no inject face and fetches its own loopback config route,
81
- // so it has no settings/locale/connection service dependencies.
91
+ // The card rides the official Plugins page. Like ModLens it registers with no
92
+ // inject face and fetches its own loopback config route, so it depends on no
93
+ // settings, locale, or connection service.
82
94
  //
83
- // The `settings.plugin.item` slot is keyed BY the settings namespace the
84
- // card edits (rc.8 contract): the configurable tab only dispatches entries
85
- // whose `options.key` is both present and served by the Host's settings
86
- // describe mirror. Registering with `id` alone throws
87
- // `keyed slot "settings.plugin.item" requires options.key` and the card
88
- // silently disappears from Settings → Plugins.
95
+ // dsh 0.1.7 replaced the namespace-keyed `settings.plugin.item` slot with the
96
+ // list slot `plugins.item`, which is where a host-plane plugin's own
97
+ // configuration page belongs ("one companion package per host-plane
98
+ // namespace"); the page renders the contribution as the card's one-liner and,
99
+ // once opened, as the body of the plugin's own page. Registering into the
100
+ // retired slot left the card invisible on 0.1.7.
89
101
  //
90
- // `id`/`order` ride the legacy list-slot shape (older DSH versions
91
- // dispatched this slot by id): harmless metadata on the keyed slot, and
92
- // what keeps the card mounting if this plugin ever loads into an older
93
- // deployment. Spread from a typed constant so the keyed registration type
94
- // stays exact.
95
- const legacyListOptions = { id: 'lan-gateway', order: 30 } as const
96
- ctx.slots.inject('settings.plugin.item', function* () {
97
- yield ctx.slots.register({
98
- name: 'settings.plugin.item',
99
- key: 'lan-gateway',
100
- ...legacyListOptions,
101
- }, LanGatewayCard)
102
- })
102
+ // `whileServed` keeps the entry off the page until the Host's settings mirror
103
+ // serves this plugin's entry. That is the one gate worth having: without a
104
+ // Loader entry there is nothing to write to, and the card would appear only
105
+ // to fail every save with the route's 409.
106
+ ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () =>
107
+ ctx.slots.inject('plugins.item', () => ctx.slots.register({
108
+ name: 'plugins.item',
109
+ id: ENTRY_ID,
110
+ order: 30,
111
+ label: () => cardTitle(),
112
+ }, LanGatewayCard))))
103
113
  }
@@ -1,17 +1,23 @@
1
1
  /**
2
- * The lan-gateway settings card shown in the official DSH Settings → Plugins
3
- * page (the `settings.plugin.item` slot).
2
+ * The lan-gateway settings card, rendered by the official DSH Plugins page
3
+ * through its `plugins.item` slot.
4
4
  *
5
5
  * ModLens-style: the card carries NO injected services. It reads and writes
6
6
  * the loopback-only `/lan-gateway/config` host route (the browser never sees
7
- * the settings seam or any secret), so the client bundle's only dependency is
8
- * the `slots` service that every plugin already has.
7
+ * the settings seam or any secret), so the only platform service it needs is
8
+ * the `slots` service every plugin already has.
9
9
  *
10
10
  * @module @riceawa/dsh-lan-gateway/client/card
11
11
  */
12
12
 
13
13
  import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
14
14
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
15
+ // Type-only. The Plugins page owns the `plugins.item` contract, and its own
16
+ // doc says a registrant merges that contract with `import type` instead of
17
+ // importing the package at runtime. Taking the contract from its owner is also
18
+ // what turns the next upstream rename of this slot into a compile error here,
19
+ // rather than a card that quietly stops rendering.
20
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
15
21
  import {
16
22
  FIELDS,
17
23
  TRISTATE_OPTIONS,
@@ -22,23 +28,12 @@ import {
22
28
  } from '../config-fields.ts'
23
29
 
24
30
  /**
25
- * The official Settings → Plugins page declares the `settings.plugin.item`
26
- * slot keyed by the settings namespace each card edits (newer DSH releases;
27
- * older releases dispatched it as a list slot by `id`). The published package
28
- * ships no `src/`, so the entry is re-declared here — the runtime slot is
29
- * real; this only restores the compile-time table.
31
+ * Props the renderer binds for this card. The Plugins page asks for either the
32
+ * card's one-liner (`summary`) or the body of its own page (`page`), and draws
33
+ * the page's title, icon, and crumb itself. The card needs no injected face —
34
+ * it fetches its own route.
30
35
  */
31
- declare module '@deepseek-ai/dsh-client-ui-slots' {
32
- interface SlotMap {
33
- /** One plugin's card inside the plugin configuration section. */
34
- 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: { children?: never } }
35
- }
36
- }
37
-
38
- /**
39
- * Props the renderer binds for this card (unused — the card is self-loading).
40
- */
41
- export type LanGatewayCardProps = PropsRuntime<'settings.plugin.item'>
36
+ export type LanGatewayCardProps = PropsRuntime<'plugins.item'>
42
37
 
43
38
  /**
44
39
  * The card's field table and value codecs live in `config-fields.ts`, shared
@@ -192,6 +187,15 @@ function labels(): Labels {
192
187
  return lang.startsWith('zh') ? LABELS.zh : LABELS.en
193
188
  }
194
189
 
190
+ /**
191
+ * The card's title in the browser's language, for the Plugins page's list
192
+ * entry. A thunk so the label follows the page's locale without re-registering.
193
+ * @returns the localized card title.
194
+ */
195
+ export function cardTitle(): string {
196
+ return labels().title
197
+ }
198
+
195
199
  /* ------------------------------------------------------------------ */
196
200
  /* Card */
197
201
  /* ------------------------------------------------------------------ */
@@ -199,10 +203,14 @@ function labels(): Labels {
199
203
  /**
200
204
  * Render the LAN gateway card. Self-loading: fetches the config route on
201
205
  * mount, posts the edited config on save.
202
- * @param _props - unused; the card needs no injected face.
203
- * @returns the card, or nothing while the route is unreachable.
206
+ *
207
+ * `view` swaps between the card's one-liner and its page body, so the branch
208
+ * sits after the hooks: the Plugins page re-renders one contribution under the
209
+ * other view when the card is opened.
210
+ * @param props - the view the Plugins page is asking for.
211
+ * @returns the one-liner, the card, or nothing while the route is unreachable.
204
212
  */
205
- export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
213
+ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
206
214
  const t = labels()
207
215
  const [open, setOpen] = useState(false)
208
216
  const [route, setRoute] = useState<RouteState | null>(null)
@@ -225,6 +233,12 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
225
233
  return () => { cancelled = true }
226
234
  }, [])
227
235
 
236
+ // The Plugins page lists this plugin as one card and opens its own page on
237
+ // demand: `summary` is the one-liner the list shows, `page` the body. The
238
+ // hooks above run for both views, because the same contribution flips
239
+ // between them.
240
+ if (props.view === 'summary') return t.description
241
+
228
242
  // A remote browser reaches this card through the gateway, which answers 403
229
243
  // for the plugin's own prefix by design, so the route is unreachable exactly
230
244
  // where a user is most likely to go looking for the setting. Rendering
@@ -232,7 +246,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
232
246
  // a broken one; say what is wrong and where the card does work instead.
233
247
  if (loadFailed) {
234
248
  return (
235
- <li style={styles.card}>
249
+ <div style={styles.card}>
236
250
  <div style={styles.header}>
237
251
  <span style={styles.headerTop}>
238
252
  <span style={styles.name}>{t.title}</span>
@@ -242,7 +256,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
242
256
  <div style={styles.body}>
243
257
  <p style={styles.hint}>{t.readOnly}</p>
244
258
  </div>
245
- </li>
259
+ </div>
246
260
  )
247
261
  }
248
262
  if (route === null) return null
@@ -407,7 +421,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
407
421
  const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
408
422
 
409
423
  return (
410
- <li style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
424
+ <div style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
411
425
  <button
412
426
  type="button"
413
427
  style={styles.header}
@@ -449,7 +463,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
449
463
  </div>
450
464
  )
451
465
  : null}
452
- </li>
466
+ </div>
453
467
  )
454
468
  }
455
469