@linxin666/dsh-usage 0.4.8 → 0.4.9

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.
@@ -16,6 +16,11 @@
16
16
  * request. Forwarding headers are the signal that a hop stands in between: a
17
17
  * request that carries CF-Connecting-IP or X-Forwarded-For is answered through
18
18
  * a proxy and is never treated as a local one (issue #1906).
19
+ *
20
+ * An operator who runs that proxy or tunnel themselves can opt into the
21
+ * narrower case: isForwardedLoopbackRequest describes a forwarded request that
22
+ * still landed on this machine's loopback upstream, and a route family admits
23
+ * it only behind its own default-off switch (issue #1918).
19
24
  * @module dsh-web-shared/host/loopback
20
25
  */
21
26
 
@@ -85,3 +90,30 @@ export function isLoopbackRequest(request: IncomingMessage): boolean {
85
90
  return false
86
91
  }
87
92
  }
93
+
94
+ /**
95
+ * Whether a forwarded request landed on this machine's loopback upstream: a
96
+ * forwarding header (the proxy's own stamp), a loopback socket address (the
97
+ * proxy dials 127.0.0.1:<port>), and a loopback Host header (the proxy rewrote
98
+ * the authority to its upstream literal).
99
+ *
100
+ * This is a fact about the route, not a trust decision. A public request to a
101
+ * tunnel satisfies all three by construction, because the proxy is the client
102
+ * of this process: only a caller that operates the proxy can decide it stands
103
+ * for the browser, which is why every consumer keeps this behind an explicit
104
+ * default-off switch and its own browser markers. isLoopbackRequest is
105
+ * unchanged and still refuses a forwarded request (issue #1906).
106
+ * @param request - node HTTP request.
107
+ * @returns true when a forwarding header, a loopback socket, and a loopback Host all hold.
108
+ */
109
+ export function isForwardedLoopbackRequest(request: IncomingMessage): boolean {
110
+ if (!isProxiedRequest(request)) return false
111
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
112
+ const host = request.headers.host
113
+ if (typeof host !== 'string') return false
114
+ try {
115
+ return isLoopbackHostname(new URL('http://' + host).hostname)
116
+ } catch {
117
+ return false
118
+ }
119
+ }
@@ -37,9 +37,32 @@ interface LookupCtx {
37
37
  */
38
38
  export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
39
39
  if (isLoopbackRequest(request)) return true
40
- const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
41
- const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
42
- return pairing?.isPairedDevice(request) === true
40
+ return lookupPairing(ctx)?.isPairedDevice(request) === true
41
+ }
42
+
43
+ /**
44
+ * The pairing service, or undefined when this composition does not expose it.
45
+ *
46
+ * Two lookups exist because the fence is shared with test harnesses that hand
47
+ * over a bare object: a real cordis context resolves services through `get`, a
48
+ * harness may carry the property directly. `get(name, false)` is the
49
+ * non-strict form, so an unloaded service answers undefined rather than
50
+ * throwing. Reading an un-injected service as a PROPERTY throws instead
51
+ * (`cannot get property "remoteWebUiPairing" without inject`); a trust fence
52
+ * must answer "not paired" in that case, never leak the error into the route
53
+ * handler, where the host web server turns it into an empty-body 400 (#1942).
54
+ */
55
+ function lookupPairing(ctx: LookupCtx): PairingAccess | undefined {
56
+ if (typeof ctx.get === 'function') {
57
+ const viaGet = ctx.get('remoteWebUiPairing', false)
58
+ return isPairingAccess(viaGet) ? viaGet : undefined
59
+ }
60
+ try {
61
+ const viaProperty = ctx.remoteWebUiPairing
62
+ return isPairingAccess(viaProperty) ? viaProperty : undefined
63
+ } catch {
64
+ return undefined
65
+ }
43
66
  }
44
67
 
45
68
  function isPairingAccess(value: unknown): value is PairingAccess {
@@ -22,6 +22,7 @@ import { deepseekModelSpend } from '../core/pricing.ts'
22
22
  import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
23
23
  import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageDayView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
24
24
  import { addTotals, emptyTotals } from '../core/types.ts'
25
+ import { resolveSpendDisplay, type SpendDisplay } from '../core/spend.ts'
25
26
 
26
27
  /**
27
28
  * Persisted accrual state for the official DeepSeek family's real spend:
@@ -60,6 +61,12 @@ interface ProviderRoute {
60
61
  export interface UsageServiceOptions {
61
62
  pollIntervalSec: number
62
63
  retainDays: number
64
+ /**
65
+ * How the browser halves should read the stored CNY costs. Optional so a
66
+ * caller that predates the display setting keeps the CNY rendering (issue
67
+ * #1937); the overview stamps the resolved value on every document.
68
+ */
69
+ display?: SpendDisplay
63
70
  }
64
71
 
65
72
  /** Probe timeout per HTTP call. */
@@ -336,6 +343,8 @@ export class UsageService {
336
343
  const all = summarizeDays(allKeys.map((key) => this.ledger.days[key] ?? {}))
337
344
  return {
338
345
  updatedAt: Date.now(),
346
+ // Display only: costs stay stored in CNY (issue #1937).
347
+ display: resolveSpendDisplay(this.options.display),
339
348
  providers,
340
349
  current: this.currentView(),
341
350
  usage: {
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
4
4
  import z from '@deepseek-ai/schemastery'
5
5
  import { mountOnce } from './mount-once.ts'
6
6
  import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
7
+ import { DEFAULT_CNY_PER_USD, resolveSpendDisplay, type SpendDisplay } from './core/spend.ts'
7
8
  import { makeUsageDayRoute, makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
8
9
 
9
10
  declare module '@deepseek-ai/cordis' {
@@ -46,6 +47,10 @@ export interface UsageConfigFields {
46
47
  pollIntervalSec?: ConfigField<number>
47
48
  /** Ledger retention in local days. */
48
49
  retainDays?: ConfigField<number>
50
+ /** Currency the spend figures are DISPLAYED in; the ledger itself stays CNY. */
51
+ spendCurrency?: ConfigField<'CNY' | 'USD'>
52
+ /** CNY per USD, consulted only while the display currency is USD. */
53
+ cnyPerUsd?: ConfigField<number>
49
54
  }
50
55
 
51
56
  /**
@@ -66,10 +71,18 @@ export const Config = z.object({
66
71
  enabled: z.boolean().default(true).volatile(),
67
72
  pollIntervalSec: z.number().min(30).max(3600).default(60).volatile(),
68
73
  retainDays: z.number().min(7).max(730).default(180).volatile(),
74
+ // Display only: the ledger keeps every cost estimate in CNY, and switching
75
+ // this field never rewrites history (issue #1937). The plugin fetches no live
76
+ // rate, so an unset or unusable rate renders with the documented default
77
+ // instead of failing the card.
78
+ spendCurrency: z.union(['CNY', 'USD'] as const).default('CNY').volatile(),
79
+ cnyPerUsd: z.number().min(0.01).max(1000).default(DEFAULT_CNY_PER_USD).volatile(),
69
80
  })
70
81
 
71
82
  export interface ResolvedConfig extends UsageServiceOptions {
72
83
  enabled: boolean
84
+ /** The resolved spend display; always present, unlike the optional service option. */
85
+ display: SpendDisplay
73
86
  }
74
87
 
75
88
  /** Read one config field, following the live reference the schema produces. */
@@ -99,6 +112,10 @@ export function resolveConfig(config?: UsageConfigFields): ResolvedConfig {
99
112
  enabled: readConfigField(config?.enabled) ?? true,
100
113
  pollIntervalSec: readNumber(config?.pollIntervalSec, 60),
101
114
  retainDays: readNumber(config?.retainDays, 180),
115
+ display: resolveSpendDisplay({
116
+ currency: readConfigField(config?.spendCurrency),
117
+ cnyPerUsd: readNumber(config?.cnyPerUsd, DEFAULT_CNY_PER_USD),
118
+ }),
102
119
  }
103
120
  }
104
121