@kukonce/dsh-loomy 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Host HTTP surface for the Settings panel.
3
+ *
4
+ * Routes live under a top-level `/loomy` namespace — the same convention
5
+ * `dsh-host-open-in-app` uses with `/open-in-app` — deliberately outside `/api`,
6
+ * which the client-connection bridge owns as a prefix route. A prefix route under
7
+ * `/api` would be shadowed for every path the bridge also answers.
8
+ *
9
+ * Every route passes the connection trust fence first: only peers the running Web GUI
10
+ * already admits can read account state or start a login.
11
+ *
12
+ * Nothing here ever answers with a session string. The account table on disk holds
13
+ * them; every payload below goes through `accountView()`, which is the only shape
14
+ * that reaches the browser.
15
+ */
16
+
17
+ import { createHash } from 'node:crypto'
18
+ import { readFileSync, statSync } from 'node:fs'
19
+ import { readFile } from 'node:fs/promises'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ import {
23
+ accountOf,
24
+ assertAccountId,
25
+ fetchUserInfo,
26
+ importDesktopAccount,
27
+ loginBySmsCode,
28
+ logoutRemote,
29
+ readAccounts,
30
+ removeAccount,
31
+ renameAccount,
32
+ sendSmsCode,
33
+ upsertAccount
34
+ } from '../account/auth.js'
35
+ import { describeSearchPaths, findDesktopSession } from '../account/desktop-session.js'
36
+ import { anchorLedger, consumedOnDay, dailyGrantState, ensureDailyGrant, fetchCredits, grantedToday } from '../account/credits.js'
37
+ import { loadCatalog, readCachedCatalog } from '../account/models.js'
38
+ import { clearExpired, expiredInfo, sessionForId, withSession } from '../account/session.js'
39
+ import { CLIENT_VERSION, GATEWAY_BASE_URL } from '../core/endpoints.js'
40
+ import { localDayKey } from '../core/http.js'
41
+
42
+ const API_PREFIX = '/loomy'
43
+ const MAX_BODY_BYTES = 16 * 1024
44
+
45
+ /**
46
+ * The plugin's artwork, resolved from the package root (this file lives in `src/panel/`).
47
+ *
48
+ * Resolved through `import.meta.url` rather than `process.cwd()`: the host may run from
49
+ * any directory, and a relative path here fails at request time, not at import time —
50
+ * which is exactly how a missing logo reaches a release unnoticed.
51
+ */
52
+ const ICON_PATH = fileURLToPath(new URL('../../icon.png', import.meta.url))
53
+ const MASK_PATH = fileURLToPath(new URL('../../icon-mask.png', import.meta.url))
54
+ let iconBytes
55
+ let maskBytes
56
+
57
+ /**
58
+ * Which build of this file the running host actually loaded.
59
+ *
60
+ * A reload does not replace routes an earlier load registered — the host keeps serving
61
+ * the first registration it was given — so a panel talking to a stale instance answers
62
+ * `not found` on newer routes. Printing a content hash on mount makes the running build
63
+ * identifiable in the host log. A content hash rather than a timestamp: the host process
64
+ * may print in a different time zone than the file's mtime suggests.
65
+ */
66
+ const CODE_STAMP = (() => {
67
+ try {
68
+ const self = fileURLToPath(import.meta.url)
69
+ const hash = createHash('sha256').update(readFileSync(self)).digest('hex').slice(0, 8)
70
+ return `api.js #${hash} (${statSync(self).mtime.toISOString().slice(0, 16)}Z)`
71
+ } catch {
72
+ return 'api.js (unidentified)'
73
+ }
74
+ })()
75
+
76
+ /** JSON answer with the two headers every panel payload needs. */
77
+ function sendJson(res, status, payload) {
78
+ res.statusCode = status
79
+ res.setHeader('content-type', 'application/json; charset=utf-8')
80
+ // Account state and balances are per-user and change under the panel's feet: a cached
81
+ // copy in the WebView would keep showing a balance the user just spent.
82
+ res.setHeader('cache-control', 'no-store')
83
+ res.end(JSON.stringify(payload))
84
+ }
85
+
86
+ /** Collect a bounded JSON body; `undefined` when it is oversized or not a JSON object. */
87
+ async function readJsonBody(req) {
88
+ const chunks = []
89
+ let size = 0
90
+ for await (const chunk of req) {
91
+ size += chunk.byteLength
92
+ if (size > MAX_BODY_BYTES) return undefined
93
+ chunks.push(chunk)
94
+ }
95
+ if (size === 0) return {}
96
+ try {
97
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
98
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined
99
+ } catch {
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /** `133****3805` — enough to tell two accounts apart, not enough to be a phone number. */
105
+ function maskPhone(phone) {
106
+ const digits = String(phone ?? '').trim()
107
+ if (digits.length === 0) return ''
108
+ if (digits.length < 8) return '****'
109
+ return `${digits.slice(0, 3)}****${digits.slice(-4)}`
110
+ }
111
+
112
+ /** A number the panel can print; NaN must never reach the UI. */
113
+ const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
114
+
115
+ /**
116
+ * The credit fields the panel shows, or `null` when nothing has been read yet.
117
+ *
118
+ * Kept narrow on purpose: the full `fetchCredits` answer also carries the invite code
119
+ * and the cycle bookkeeping, none of which the card renders, and every extra field is
120
+ * one more thing to widen by accident later.
121
+ */
122
+ function creditsSummary(credits) {
123
+ if (credits === null || credits === undefined || typeof credits !== 'object') return null
124
+ return {
125
+ ok: credits.ok !== false,
126
+ balance: finite(credits.balance),
127
+ permanentBalance: finite(credits.permanentBalance),
128
+ dailyBalance: finite(credits.dailyBalance),
129
+ dailyQuota: finite(credits.dailyQuota),
130
+ dailyConsumed: finite(credits.dailyConsumed),
131
+ availableBalance: finite(credits.availableBalance),
132
+ dailyCycleDate: typeof credits.dailyCycleDate === 'string' ? credits.dailyCycleDate : '',
133
+ ...(typeof credits.error === 'string' ? { error: credits.error } : {})
134
+ }
135
+ }
136
+
137
+ /**
138
+ * One account as the panel may see it.
139
+ *
140
+ * The session is intentionally absent — this function is the boundary — and the phone
141
+ * arrives masked. Everything else is stored metadata or a count read from the catalog
142
+ * cache: no field here costs a network round trip, because `/state` is called on every
143
+ * panel open and after every action.
144
+ */
145
+ function accountView(account, adapters) {
146
+ const cached = readCachedCatalog(account.id)
147
+ const grant = dailyGrantState(account.id)
148
+ const expired = expiredInfo(account.id)
149
+ const day = localDayKey()
150
+ return {
151
+ id: account.id,
152
+ label: account.label,
153
+ phone: maskPhone(account.phone),
154
+ userid: account.userid,
155
+ source: account.source,
156
+ loggedInAt: finite(account.loggedInAt),
157
+ signedIn: account.session.length > 0,
158
+ expired: expired !== undefined,
159
+ expiredAt: finite(expired?.at),
160
+ expiredReason: typeof expired?.reason === 'string' ? expired.reason : '',
161
+ provider: `@loomy/dsh-loomy/${account.id}`,
162
+ mounted: adapters[account.id] !== undefined,
163
+ models: Array.isArray(cached?.entries) ? cached.entries.length : 0,
164
+ modelsCachedAt: finite(cached?.savedAt),
165
+ // The grant ledger is the last recorded claim, not a live balance: it is what the
166
+ // panel needs to word the button ("今日已领取"), not what it prints as the balance.
167
+ dailyGrant: grant === undefined ? null : {
168
+ day: grant.day,
169
+ at: finite(grant.at),
170
+ granted: grant.granted === true,
171
+ balance: finite(grant.balance),
172
+ quota: finite(grant.quota),
173
+ reason: typeof grant.reason === 'string' ? grant.reason : '',
174
+ ...(typeof grant.error === 'string' ? { error: grant.error } : {})
175
+ },
176
+ grantedToday: grantedToday(account.id),
177
+ credits: grant === undefined ? null : creditsSummary(projectedCredits(grant, account.id, day)),
178
+ // Milliseconds, for clients that must judge staleness against one clock:
179
+ // Date.now() in the WebView can drift from this process by minutes.
180
+ serverNow: Date.now()
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The ledger's claim-time pot minus what chat has spent since, as far as this
186
+ * process knows.
187
+ *
188
+ * The adapter books each turn's `points_consumed` into `usage.json` the moment the
189
+ * gateway's accounting frame arrives, so this projection tracks chatting in real
190
+ * time. It is still an estimate between sync points — a turn billed outside this
191
+ * process (onboarding tasks, another machine on the account) or a failed spend
192
+ * write is only corrected when the next claim re-reads the server. `spent` is
193
+ * clamped to the pot so a stale spend file can never push the display negative.
194
+ */
195
+ function projectedCredits(grant, accountId, day) {
196
+ const pot = finite(grant.balance)
197
+ const spent = grant.day === day ? consumedOnDay(accountId, day) : 0
198
+ const remaining = Math.max(0, pot - Math.min(spent, pot))
199
+ return {
200
+ ok: grant.granted === true,
201
+ balance: grant.balance,
202
+ dailyBalance: remaining,
203
+ dailyQuota: grant.quota,
204
+ // The server's number for this is quota - dailyBalance; the same arithmetic here
205
+ // also counts what chat spent after the claim.
206
+ dailyConsumed: Math.max(0, finite(grant.quota) - remaining),
207
+ availableBalance: remaining,
208
+ dailyCycleDate: day,
209
+ ...(typeof grant.error === 'string' ? { error: grant.error } : {})
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The model the picker should offer by default, from cached catalogs only.
215
+ *
216
+ * No network here: `/state` is on the panel's critical path, and a default model is a
217
+ * display detail that a stale answer still gets right.
218
+ */
219
+ function defaultModelOf(adapters) {
220
+ for (const id of Object.keys(adapters)) {
221
+ const declared = adapters[id]?.defaultModel
222
+ if (typeof declared === 'string' && declared.length > 0) return declared
223
+ const entries = readCachedCatalog(id)?.entries
224
+ const preferred = Array.isArray(entries) ? entries.find((entry) => entry?.isDefault === true) : undefined
225
+ if (preferred !== undefined) return preferred.id
226
+ }
227
+ for (const id of Object.keys(adapters)) {
228
+ const entries = readCachedCatalog(id)?.entries
229
+ if (Array.isArray(entries) && entries.length > 0) return entries[0].id
230
+ }
231
+ return ''
232
+ }
233
+
234
+ /** Whether the Loomy desktop app has a session parked on this machine. */
235
+ function desktopAvailable() {
236
+ try {
237
+ return findDesktopSession() !== undefined
238
+ } catch {
239
+ return false
240
+ }
241
+ }
242
+
243
+ /** The panel's full snapshot. */
244
+ function statePayload(adapters) {
245
+ return {
246
+ accounts: readAccounts().map((account) => accountView(account, adapters)),
247
+ defaultModel: defaultModelOf(adapters),
248
+ gateway: GATEWAY_BASE_URL,
249
+ version: CLIENT_VERSION,
250
+ desktop: {
251
+ available: desktopAvailable(),
252
+ // The import button is a dead end on a machine that never ran Loomy, and its
253
+ // failure message has to name where the plugin looked.
254
+ paths: describeSearchPaths()
255
+ }
256
+ }
257
+ }
258
+
259
+ /** Map one catalog entry to the subset the panel renders. */
260
+ function modelView(entry) {
261
+ return {
262
+ id: entry.id,
263
+ name: entry.name ?? entry.id,
264
+ // 'image' entries share the chat URL but 404 on it, so the panel lists them apart
265
+ // from the ones it offers for a conversation.
266
+ type: entry.type ?? 'chat',
267
+ chat: entry.type !== 'image',
268
+ protocol: entry.protocol,
269
+ contextLength: finite(entry.contextLength),
270
+ maxOutputTokens: finite(entry.maxOutputTokens),
271
+ input: Array.isArray(entry.input) ? entry.input : ['text'],
272
+ vision: entry.vision === true,
273
+ toolCalls: entry.toolCalls === true,
274
+ reasoning: entry.reasoning === true,
275
+ efforts: Array.isArray(entry.efforts) ? entry.efforts : [],
276
+ defaultEffort: typeof entry.defaultEffort === 'string' ? entry.defaultEffort : '',
277
+ isDefault: entry.isDefault === true
278
+ }
279
+ }
280
+
281
+ /**
282
+ * The live model catalog for one account.
283
+ *
284
+ * The mounted adapter wins when there is one: its list is the merged one the model
285
+ * picker reads, so the panel and the picker can never disagree about what is offered.
286
+ * The account-level catalog is the fallback for a composition where the adapter for
287
+ * this id is not mounted (an account added to config without a restart).
288
+ */
289
+ async function modelsPayload(accountId, adapters, options = {}) {
290
+ const adapter = adapters[accountId]
291
+ if (adapter !== undefined && typeof adapter.models === 'function') {
292
+ if (options.refresh === true && typeof adapter.invalidateCatalog === 'function') adapter.invalidateCatalog()
293
+ const entries = await adapter.models()
294
+ return {
295
+ id: accountId,
296
+ source: typeof adapter.catalogSource === 'string' ? adapter.catalogSource : 'adapter',
297
+ // `catalogCachedAt` is what the WorkBuddy adapter calls it; this one calls its
298
+ // in-memory timestamp `catalogAt`. Both are read so neither adapter shape breaks
299
+ // the panel's "as of" line.
300
+ cachedAt: finite(adapter.catalogCachedAt ?? adapter.catalogAt),
301
+ models: (Array.isArray(entries) ? entries : []).map(modelView)
302
+ }
303
+ }
304
+ const { session } = sessionForId(accountId)
305
+ const catalog = await loadCatalog(accountId, session, { force: options.refresh === true })
306
+ return {
307
+ id: accountId,
308
+ source: catalog.source,
309
+ cachedAt: finite(catalog.savedAt),
310
+ models: (Array.isArray(catalog.entries) ? catalog.entries : []).map(modelView)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Validate an account id a route was handed, with an error the panel can show.
316
+ *
317
+ * An omitted id means "the account this panel is about", which for every caller so far
318
+ * is the first one in the table. Requiring it would make a single-account install pass
319
+ * an id on every request to say nothing — and the panel's own default fetch does not.
320
+ */
321
+ function accountIdParam(raw) {
322
+ const value = String(raw ?? '').trim()
323
+ if (value.length === 0) {
324
+ const first = readAccounts()[0]
325
+ if (first === undefined) throw new Error('还没有登录任何 Loomy 账号')
326
+ return first.id
327
+ }
328
+ return assertAccountId(value)
329
+ }
330
+
331
+ /**
332
+ * Register the panel's routes. Call inside `ctx.inject(['webServer'], …)` so a
333
+ * composition without an HTTP carrier keeps the provider routes and the command.
334
+ *
335
+ * The second argument accepts either shape the plugin has been wired with: the bare
336
+ * adapter map (`{ [accountId]: adapter }`) or the host bundle
337
+ * `{ adapters, mountAccount, unmountAccount }`. Both were live at different points while
338
+ * this plugin was being written, and a mismatch fails only at the moment a route runs —
339
+ * so the normalization is here, once, rather than trusting the caller.
340
+ *
341
+ * @param {object} webCtx - host plugin context that carries `webServer`.
342
+ * @param {Record<string, object>|{ adapters: Record<string, object>, mountAccount?: Function, unmountAccount?: Function }} wiring - per-account LLM adapters, or the host bundle.
343
+ * @returns {() => void} the disposer the caller registers with `webCtx.effect`.
344
+ */
345
+ export function registerLoomyApi(webCtx, wiring) {
346
+ const bundle = wiring !== null && typeof wiring === 'object' && typeof wiring.adapters === 'object' && wiring.adapters !== null
347
+ ? wiring
348
+ : { adapters: wiring ?? {} }
349
+ const catalogue = bundle.adapters
350
+ const mountAccount = typeof bundle.mountAccount === 'function' ? bundle.mountAccount : undefined
351
+ const unmountAccount = typeof bundle.unmountAccount === 'function' ? bundle.unmountAccount : undefined
352
+
353
+ /**
354
+ * The trust fence, identical to the one every other panel route in this host uses:
355
+ * ask the connection service whether it would reject this request, and let it answer
356
+ * with the status code it wants.
357
+ */
358
+ /**
359
+ * Mount the provider route for an account a route just created.
360
+ *
361
+ * Failures are logged and swallowed: the account is already stored at this point, so
362
+ * turning a mount problem into a login failure would make the user retry a login that
363
+ * in fact succeeded. The panel's `mounted` flag says the route is missing.
364
+ */
365
+ const mountSafely = (account) => {
366
+ if (mountAccount === undefined || account === undefined) return
367
+ if (catalogue[account.id] !== undefined) return
368
+ try {
369
+ mountAccount(account)
370
+ } catch (error) {
371
+ webCtx.logger?.warn?.(`[loomy] ${account.id}: 挂载 provider 路由失败 — ${error instanceof Error ? error.message : String(error)}`)
372
+ }
373
+ }
374
+
375
+ const rejected = (req, res) => {
376
+ const connection = webCtx.get('connection')
377
+ if (connection === undefined || typeof connection.requestRejection !== 'function') return false
378
+ const rejection = connection.requestRejection(req)
379
+ if (rejection === undefined) return false
380
+ res.statusCode = rejection
381
+ res.end()
382
+ return true
383
+ }
384
+
385
+ const handler = async (req, res) => {
386
+ const url = new URL(String(req.url ?? '/'), 'http://localhost')
387
+ const method = req.method ?? 'GET'
388
+ if (rejected(req, res)) return
389
+ const path = url.pathname
390
+
391
+ try {
392
+ if (path === `${API_PREFIX}/state` && method === 'GET') {
393
+ sendJson(res, 200, statePayload(catalogue))
394
+ return
395
+ }
396
+
397
+ // The packaged desktop app has no devtools, so the panel reports its load stages
398
+ // here and they land in the host log. That is how "the bundle never loaded" stays
399
+ // distinguishable from "loaded, but apply() threw".
400
+ if (path === `${API_PREFIX}/client-log` && method === 'POST') {
401
+ const body = await readJsonBody(req)
402
+ webCtx.logger?.info?.(`[loomy] client half: ${JSON.stringify(body ?? {})}`)
403
+ sendJson(res, 200, { ok: true })
404
+ return
405
+ }
406
+
407
+ /**
408
+ * The artwork, twice: the opaque mark for surfaces that want a fixed colour, and
409
+ * the alpha-only silhouette the panel paints through a CSS mask so one asset works
410
+ * in both themes.
411
+ */
412
+ if ((path === `${API_PREFIX}/icon.png` || path === `${API_PREFIX}/icon-mask.png`) && method === 'GET') {
413
+ const isMask = path.endsWith('icon-mask.png')
414
+ try {
415
+ if (isMask) maskBytes ??= await readFile(MASK_PATH)
416
+ else iconBytes ??= await readFile(ICON_PATH)
417
+ res.statusCode = 200
418
+ // Both assets are PNG (the encoder in scripts/ only writes PNG); serving one as
419
+ // image/webp would make the browser refuse to render it.
420
+ res.setHeader('content-type', 'image/png')
421
+ // The artwork never changes within a build, so it may be cached; the payloads
422
+ // above may not.
423
+ res.setHeader('cache-control', 'public, max-age=3600')
424
+ res.end(isMask ? maskBytes : iconBytes)
425
+ } catch {
426
+ // No artwork in this build is a 404, not an error: the panel renders without a
427
+ // logo rather than refusing to open.
428
+ res.statusCode = 404
429
+ res.end()
430
+ }
431
+ return
432
+ }
433
+
434
+ if (path === `${API_PREFIX}/models`) {
435
+ if (method !== 'GET') {
436
+ sendJson(res, 405, { error: 'method not allowed' })
437
+ return
438
+ }
439
+ try {
440
+ const id = accountIdParam(url.searchParams.get('id'))
441
+ const payload = await modelsPayload(id, catalogue, { refresh: url.searchParams.get('refresh') === '1' })
442
+ sendJson(res, 200, payload)
443
+ } catch (error) {
444
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
445
+ }
446
+ return
447
+ }
448
+
449
+ if (path === `${API_PREFIX}/credits`) {
450
+ if (method !== 'GET') {
451
+ sendJson(res, 405, { error: 'method not allowed' })
452
+ return
453
+ }
454
+ try {
455
+ const id = accountIdParam(url.searchParams.get('id'))
456
+ // `fetchCredits` is the per-day idempotent first-login claim: reading the
457
+ // balance also renews the daily pot, so a balance refresh is never a wasted call.
458
+ const credits = await withSession(id, (session) => fetchCredits(session))
459
+ // Re-anchor the ledger from the live answer so the pill's projection agrees
460
+ // the moment the user has checked the real number against it.
461
+ anchorLedger(id, credits, '刷新积分')
462
+ sendJson(res, 200, {
463
+ id,
464
+ credits: creditsSummary(credits),
465
+ dailyGrant: dailyGrantState(id) ?? null,
466
+ grantedToday: grantedToday(id)
467
+ })
468
+ } catch (error) {
469
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
470
+ }
471
+ return
472
+ }
473
+
474
+ /**
475
+ * The manual fallback for the daily 5000. The host claims on startup and at local
476
+ * midnight; this route exists because those timers cannot run while the app was
477
+ * closed across a day boundary, and because a failed automatic claim is invisible
478
+ * until somebody asks.
479
+ */
480
+ if (path === `${API_PREFIX}/credits/claim` && method === 'POST') {
481
+ const body = await readJsonBody(req)
482
+ if (body === undefined) {
483
+ sendJson(res, 400, { error: 'invalid JSON body' })
484
+ return
485
+ }
486
+ try {
487
+ const id = accountIdParam(body.id)
488
+ const outcome = await withSession(id, (session) => ensureDailyGrant(id, session, { force: true, reason: '手动' }))
489
+ webCtx.logger?.info?.(`[loomy] ${id}: 手动领取每日积分 ${outcome.granted === true ? '成功' : '未生效'}${outcome.error === undefined ? '' : `(${outcome.error})`}`)
490
+ sendJson(res, 200, {
491
+ id,
492
+ granted: outcome.granted === true,
493
+ skipped: outcome.skipped === true,
494
+ credits: creditsSummary(outcome),
495
+ dailyGrant: dailyGrantState(id) ?? null,
496
+ grantedToday: grantedToday(id)
497
+ })
498
+ } catch (error) {
499
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
500
+ }
501
+ return
502
+ }
503
+
504
+ if (path === `${API_PREFIX}/login/send` && method === 'POST') {
505
+ const body = await readJsonBody(req)
506
+ if (body === undefined) {
507
+ sendJson(res, 400, { error: 'invalid JSON body' })
508
+ return
509
+ }
510
+ try {
511
+ const result = await sendSmsCode(body.phone)
512
+ sendJson(res, 200, { phone: maskPhone(result.phone), msgid: result.msgid, expire: result.expire })
513
+ } catch (error) {
514
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
515
+ }
516
+ return
517
+ }
518
+
519
+ /**
520
+ * Exchange the SMS code for a session. The answer is the stored account record —
521
+ * never the session string, not even masked.
522
+ */
523
+ if (path === `${API_PREFIX}/login/verify` && method === 'POST') {
524
+ const body = await readJsonBody(req)
525
+ if (body === undefined) {
526
+ sendJson(res, 400, { error: 'invalid JSON body' })
527
+ return
528
+ }
529
+ try {
530
+ const label = typeof body.label === 'string' ? body.label : ''
531
+ const loggedIn = await loginBySmsCode({ phone: body.phone, code: body.code, msgid: body.msgid, label })
532
+ const record = upsertAccount({
533
+ session: loggedIn.session,
534
+ userid: loggedIn.userid,
535
+ phone: loggedIn.phone,
536
+ label,
537
+ source: 'sms'
538
+ })
539
+ // The profile read only fills in a display name and a canonical phone; a
540
+ // failure here must not turn a successful login into an error.
541
+ try {
542
+ const info = await fetchUserInfo(loggedIn.session)
543
+ if (info.phone.length > 0 && info.phone !== record.phone) {
544
+ upsertAccount({
545
+ session: record.session,
546
+ userid: record.userid,
547
+ phone: info.phone,
548
+ label: record.label,
549
+ source: record.source
550
+ })
551
+ }
552
+ } catch {
553
+ // keep the login, lose the profile
554
+ }
555
+ clearExpired(record.id)
556
+ const stored = accountOf(record.id) ?? record
557
+ // The provider route is mounted per account, so a login has to create one now:
558
+ // otherwise the panel reports a working account that the model picker cannot
559
+ // offer until the host restarts.
560
+ mountSafely(stored)
561
+ webCtx.logger?.info?.(`[loomy] ${stored.id}: 短信登录成功(${maskPhone(stored.phone)})`)
562
+ sendJson(res, 200, { account: accountView(stored, catalogue) })
563
+ } catch (error) {
564
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
565
+ }
566
+ return
567
+ }
568
+
569
+ /** Adopt the login the Loomy desktop app already has on this machine. */
570
+ if (path === `${API_PREFIX}/login/import` && method === 'POST') {
571
+ const body = await readJsonBody(req)
572
+ if (body === undefined) {
573
+ sendJson(res, 400, { error: 'invalid JSON body' })
574
+ return
575
+ }
576
+ try {
577
+ const imported = await importDesktopAccount({ label: typeof body.label === 'string' ? body.label : '' })
578
+ clearExpired(imported.record.id)
579
+ mountSafely(imported.record)
580
+ webCtx.logger?.info?.(`[loomy] ${imported.record.id}: 已导入桌面端登录态(来自 ${imported.path})`)
581
+ sendJson(res, 200, {
582
+ account: accountView(imported.record, catalogue),
583
+ // A filesystem location, not a credential: the panel prints it as the
584
+ // confirmation of where the session came from.
585
+ path: imported.path
586
+ })
587
+ } catch (error) {
588
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
589
+ }
590
+ return
591
+ }
592
+
593
+ if (path === `${API_PREFIX}/logout` && method === 'POST') {
594
+ const body = await readJsonBody(req)
595
+ if (body === undefined) {
596
+ sendJson(res, 400, { error: 'invalid JSON body' })
597
+ return
598
+ }
599
+ try {
600
+ const id = accountIdParam(body.id)
601
+ const account = accountOf(id)
602
+ if (account === undefined) {
603
+ sendJson(res, 404, { error: `未找到账号 "${id}"` })
604
+ return
605
+ }
606
+ // Server-side retirement is best effort: a session the server already forgot is
607
+ // still a session this plugin should drop locally, and a network failure must
608
+ // not leave the user unable to sign out.
609
+ const remote = await logoutRemote(account.session)
610
+ removeAccount(id)
611
+ clearExpired(id)
612
+ // Retire the route too: leaving it registered would keep offering a provider
613
+ // whose session this plugin just deleted.
614
+ try {
615
+ unmountAccount?.(id)
616
+ } catch (error) {
617
+ webCtx.logger?.warn?.(`[loomy] ${id}: 卸载 provider 路由失败 — ${error instanceof Error ? error.message : String(error)}`)
618
+ }
619
+ webCtx.logger?.info?.(`[loomy] ${id}: 已退出登录(远端${remote.ok === true ? '已注销' : '未确认'})`)
620
+ sendJson(res, 200, { id, removed: true, remote })
621
+ } catch (error) {
622
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
623
+ }
624
+ return
625
+ }
626
+
627
+ /** Rename one account. The id stays: routes and cache files hang off it. */
628
+ if (path === `${API_PREFIX}/label` && method === 'POST') {
629
+ const body = await readJsonBody(req)
630
+ if (body === undefined) {
631
+ sendJson(res, 400, { error: 'invalid JSON body' })
632
+ return
633
+ }
634
+ try {
635
+ const id = accountIdParam(body.id)
636
+ const updated = renameAccount(id, typeof body.label === 'string' ? body.label : '')
637
+ if (updated === undefined) {
638
+ sendJson(res, 404, { error: `未找到账号 "${id}"` })
639
+ return
640
+ }
641
+ sendJson(res, 200, { account: accountView(updated, catalogue) })
642
+ } catch (error) {
643
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
644
+ }
645
+ return
646
+ }
647
+
648
+ // Naming the path matters: the host keeps serving the *first* registration it got,
649
+ // so after a reload a request can still land on an older build that simply does not
650
+ // know the route.
651
+ webCtx.logger?.warn?.(`[loomy] 未匹配的面板请求 ${method} ${path}(当前挂载的是 ${CODE_STAMP})`)
652
+ sendJson(res, 404, { error: 'not found' })
653
+ } catch (error) {
654
+ webCtx.logger?.warn?.(`[loomy] API ${method} ${path} failed — ${error instanceof Error ? error.message : String(error)}`)
655
+ if (!res.headersSent) sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
656
+ }
657
+ }
658
+
659
+ webCtx.logger?.info?.(`[loomy] 面板路由已挂载(${CODE_STAMP})`)
660
+ return webCtx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
661
+ }