@awiki/dsh-plugin 0.3.10 → 0.3.11-rc.1

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.
Files changed (81) hide show
  1. package/README.md +4 -3
  2. package/README.zh.md +4 -3
  3. package/lib/client.js +1351 -158
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +172 -93
  6. package/lib/model-proxy-contract.js +8 -8
  7. package/lib/typert.host.js +56 -56
  8. package/lib/typert.remote-client.js +56 -56
  9. package/lib/types/client/AwikiIdentityAccess.d.ts +6 -4
  10. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
  11. package/lib/types/client/AwikiIdentityAccess.js +111 -43
  12. package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
  13. package/lib/types/client/AwikiOverlay.js +1 -1
  14. package/lib/types/client/AwikiOverlay.js.map +1 -1
  15. package/lib/types/client/AwikiRecoveryForm.d.ts +1 -0
  16. package/lib/types/client/AwikiRecoveryForm.d.ts.map +1 -1
  17. package/lib/types/client/AwikiRecoveryForm.js +3 -2
  18. package/lib/types/client/AwikiRecoveryForm.js.map +1 -1
  19. package/lib/types/client/AwikiSettingsSection.d.ts +1 -1
  20. package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
  21. package/lib/types/client/awiki-client-bridge.d.ts +1 -1
  22. package/lib/types/client/awiki-client-bridge.d.ts.map +1 -1
  23. package/lib/types/client/awiki-client-bridge.js +1 -1
  24. package/lib/types/client/awiki-client-bridge.js.map +1 -1
  25. package/lib/types/client/controller.d.ts +8 -0
  26. package/lib/types/client/controller.d.ts.map +1 -1
  27. package/lib/types/client/controller.js +274 -53
  28. package/lib/types/client/controller.js.map +1 -1
  29. package/lib/types/client/index.d.ts +1 -1
  30. package/lib/types/client/index.d.ts.map +1 -1
  31. package/lib/types/client/index.js +2 -1
  32. package/lib/types/client/index.js.map +1 -1
  33. package/lib/types/client/otp-cooldown.d.ts +1 -0
  34. package/lib/types/client/otp-cooldown.d.ts.map +1 -1
  35. package/lib/types/client/otp-cooldown.js +12 -1
  36. package/lib/types/client/otp-cooldown.js.map +1 -1
  37. package/lib/types/client/settings-controller.d.ts +4 -2
  38. package/lib/types/client/settings-controller.d.ts.map +1 -1
  39. package/lib/types/client/settings-controller.js +24 -8
  40. package/lib/types/client/settings-controller.js.map +1 -1
  41. package/lib/types/client/slots.d.ts +1 -0
  42. package/lib/types/client/slots.d.ts.map +1 -1
  43. package/lib/types/client/store.d.ts +1 -1
  44. package/lib/types/client/store.d.ts.map +1 -1
  45. package/lib/types/client/store.js +1 -1
  46. package/lib/types/client/store.js.map +1 -1
  47. package/lib/types/index.d.ts +1 -0
  48. package/lib/types/index.d.ts.map +1 -1
  49. package/lib/types/index.js +35 -9
  50. package/lib/types/index.js.map +1 -1
  51. package/lib/types/listener.d.ts.map +1 -1
  52. package/lib/types/listener.js +2 -1
  53. package/lib/types/listener.js.map +1 -1
  54. package/lib/types/model-proxy-contract.d.ts +8 -8
  55. package/lib/types/model-proxy-contract.d.ts.map +1 -1
  56. package/lib/types/model-proxy-contract.js +8 -8
  57. package/lib/types/model-proxy-contract.js.map +1 -1
  58. package/lib/types/package-version.generated.d.ts +2 -2
  59. package/lib/types/package-version.generated.d.ts.map +1 -1
  60. package/lib/types/package-version.generated.js +2 -2
  61. package/lib/types/package-version.generated.js.map +1 -1
  62. package/lib/types/registration-policy.d.ts +7 -0
  63. package/lib/types/registration-policy.d.ts.map +1 -0
  64. package/lib/types/registration-policy.js +21 -0
  65. package/lib/types/registration-policy.js.map +1 -0
  66. package/lib/types/settings-rpc-contract.d.ts +14 -14
  67. package/lib/types/settings-rpc-contract.d.ts.map +1 -1
  68. package/lib/types/settings-rpc-contract.js +14 -14
  69. package/lib/types/settings-rpc-contract.js.map +1 -1
  70. package/lib/types/settings-rpc.d.ts.map +1 -1
  71. package/lib/types/settings-rpc.js +2 -2
  72. package/lib/types/settings-rpc.js.map +1 -1
  73. package/lib/types/settings-transport.d.ts +7 -0
  74. package/lib/types/settings-transport.d.ts.map +1 -0
  75. package/lib/types/settings-transport.js +52 -0
  76. package/lib/types/settings-transport.js.map +1 -0
  77. package/lib/types/types.d.ts +1 -1
  78. package/lib/types/types.d.ts.map +1 -1
  79. package/lib/types/update-policy.d.ts +2 -2
  80. package/lib/types/update-policy.d.ts.map +1 -1
  81. package/package.json +82 -73
@@ -1,6 +1,33 @@
1
1
  import { AwikiDraftStore } from "./drafts.js";
2
+ import { SHORT_HANDLE_INVITE_MESSAGE } from "../registration-policy.js";
2
3
  import { IndexedDbAwikiBrowserImageCache, } from "./image-cache.js";
3
4
  import { clearMailBrowserCache } from "./mail-list-cache.js";
5
+ const GENERIC_BUSINESS_FAILURE_MESSAGE = 'AWiki 暂时无法完成此操作,请稍后重试。';
6
+ const GENERIC_CARRIER_FAILURE_MESSAGE = '暂时无法连接 AWiki Host,请稍后重试。';
7
+ const GENERIC_THROW_FAILURE_MESSAGE = 'AWiki 调用暂时失败,请稍后重试。';
8
+ /** Project only a stable business code into Browser copy; provider messages remain Host-only. */
9
+ function genericFailureMessage(failure) {
10
+ switch (failure.code) {
11
+ case 'network':
12
+ return '无法连接 AWiki 服务,请检查网络后重试。';
13
+ case 'rate-limited':
14
+ return 'AWiki 请求过于频繁,请稍后重试。';
15
+ case 'not-registered':
16
+ return '请先注册 AWiki 身份后重试。';
17
+ case 'signed-out':
18
+ return '当前 AWiki 身份已退出,请重新进入后重试。';
19
+ case 'identity-recovery-required':
20
+ return '当前设备的 AWiki 身份凭证已失效,请重新恢复身份。';
21
+ case 'device-rejoin-required':
22
+ return '当前设备已被撤销,请重新申请加入。';
23
+ default:
24
+ return GENERIC_BUSINESS_FAILURE_MESSAGE;
25
+ }
26
+ }
27
+ /** Carrier diagnostics are not a Browser contract, including for unknown carrier codes. */
28
+ function genericCarrierFailureMessage(_failure) {
29
+ return GENERIC_CARRIER_FAILURE_MESSAGE;
30
+ }
4
31
  /** Turn a registration rejection into an actionable message without exposing remote response text. */
5
32
  function registrationFailureMessage(failure) {
6
33
  switch (failure.code) {
@@ -14,6 +41,8 @@ function registrationFailureMessage(failure) {
14
41
  return '验证码状态已失效,请重新获取验证码后再注册。';
15
42
  case 'handle-unavailable':
16
43
  return '该 Handle 刚刚已被注册。请返回身份入口,再按恢复流程重新获取验证码。';
44
+ case 'short-handle-invite-required':
45
+ return SHORT_HANDLE_INVITE_MESSAGE;
17
46
  case 'conflict':
18
47
  return '注册冲突:服务端可能已收到上次注册请求,或该手机号 / Handle 已绑定其他身份。请保留当前页面并再次提交;若仍失败,请勿清除本机身份数据,联系管理员并提供失败时间。';
19
48
  case 'rate-limited':
@@ -27,12 +56,14 @@ function registrationFailureMessage(failure) {
27
56
  case 'remote':
28
57
  return 'AWiki 服务暂时无法完成注册,请稍后重试;若持续失败,请联系管理员并提供失败时间。';
29
58
  default:
30
- return `${failure.code}:${failure.message}`;
59
+ return 'AWiki 服务暂时无法完成注册,请稍后重试;若持续失败,请联系管理员并提供失败时间。';
31
60
  }
32
61
  }
33
62
  /** Turn a verification-code request failure into a safe next action. */
34
63
  function registrationOtpFailureMessage(failure) {
35
64
  switch (failure.code) {
65
+ case 'short-handle-invite-required':
66
+ return SHORT_HANDLE_INVITE_MESSAGE;
36
67
  case 'rate-limited':
37
68
  return '验证码发送过于频繁,请等待限流解除后再重新获取。';
38
69
  case 'invalid-request':
@@ -62,7 +93,7 @@ function recoveryPreparationFailureMessage(failure) {
62
93
  case 'remote':
63
94
  return 'AWiki 服务暂时无法验证恢复信息,请稍后重试。';
64
95
  default:
65
- return `${failure.code}:${failure.message}`;
96
+ return 'AWiki 服务暂时无法验证恢复信息,请稍后重试。';
66
97
  }
67
98
  }
68
99
  function recoveryContinuationFailureMessage(failure, phase) {
@@ -84,7 +115,7 @@ function recoveryContinuationFailureMessage(failure, phase) {
84
115
  case 'forbidden':
85
116
  return 'AWiki 服务暂时未能完成恢复。可以重新检查结果,或返回入口稍后继续。';
86
117
  default:
87
- return `${failure.code}:${failure.message}`;
118
+ return '身份恢复暂未完成。请保留当前恢复操作并重新检查状态,不要重新获取验证码或创建新身份。';
88
119
  }
89
120
  }
90
121
  function identityAccessInspectionFailureMessage(failure) {
@@ -96,9 +127,10 @@ function identityAccessInspectionFailureMessage(failure) {
96
127
  case 'remote':
97
128
  return 'AWiki 服务暂时无法确认该 Handle 的状态,请稍后重试。';
98
129
  default:
99
- return `${failure.code}:${failure.message}`;
130
+ return 'AWiki 服务暂时无法确认该 Handle 的状态,请稍后重试。';
100
131
  }
101
132
  }
133
+ const IDENTITY_ACCESS_FAILURE_MESSAGE = '暂时无法读取本机身份状态,请稍后重新检查。';
102
134
  const INITIAL_VIEW = Object.freeze({
103
135
  status: 'cold',
104
136
  sessionStatus: 'unregistered',
@@ -126,6 +158,7 @@ const INITIAL_VIEW = Object.freeze({
126
158
  accessError: null,
127
159
  recoveryOperationId: null,
128
160
  recoveryProgress: null,
161
+ recoveryOtpRetryAt: null,
129
162
  });
130
163
  /** Turn a closed Host summary failure into one actionable Chinese message. */
131
164
  function summaryFailureMessage(failure) {
@@ -135,7 +168,7 @@ function summaryFailureMessage(failure) {
135
168
  case 'summary-cancelled': return 'AI 总结已取消,请重新生成。';
136
169
  case 'summary-invalid-output': return '模型没有返回有效的结构化摘要,请重新生成。';
137
170
  case 'summary-failed': return '暂时无法生成 AI 总结,请检查模型连接后重试。';
138
- default: return `${failure.code}:${failure.message}`;
171
+ default: return '暂时无法生成 AI 总结,请检查模型连接后重试。';
139
172
  }
140
173
  }
141
174
  /** Turn closed mail failures into safe, actionable browser messages. */
@@ -163,7 +196,7 @@ function groupCreateFailureMessage(failure) {
163
196
  case 'network': return '无法连接 AWiki 服务,请检查网络后重试。';
164
197
  case 'conflict': return '群聊创建状态发生冲突,请刷新群聊列表后确认是否已经创建。';
165
198
  case 'remote': return 'AWiki 服务暂时无法创建群聊,请稍后重试。';
166
- default: return `${failure.code}:${failure.message}`;
199
+ default: return 'AWiki 服务暂时无法创建群聊,请稍后重试。';
167
200
  }
168
201
  }
169
202
  /** Keep group authorization failures actionable without exposing raw service text. */
@@ -182,7 +215,7 @@ function groupReadFailureMessage(failure) {
182
215
  case 'remote':
183
216
  return 'AWiki 暂时无法读取这个群聊,请稍后重试。';
184
217
  default:
185
- return `${failure.code}:${failure.message}`;
218
+ return 'AWiki 暂时无法读取这个群聊,请稍后重试。';
186
219
  }
187
220
  }
188
221
  function conversationPreferenceFailureMessage(_failure) {
@@ -217,21 +250,18 @@ async function callGroupRead(operation) {
217
250
  }
218
251
  }
219
252
  /** Flatten the carrier and business result once for every controller caller. */
220
- async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`, carrierFailureMessage = message => `连接 AWiki Host 失败:${message}`) {
253
+ async function call(operation, failureMessage = genericFailureMessage, carrierFailureMessage = genericCarrierFailureMessage) {
221
254
  try {
222
255
  const carried = await operation();
223
256
  if (!carried.ok)
224
- return { ok: false, error: carrierFailureMessage(carried.error.message) };
257
+ return { ok: false, error: carrierFailureMessage(carried.error) };
225
258
  if (!carried.value.ok) {
226
259
  return { ok: false, error: failureMessage(carried.value.error) };
227
260
  }
228
261
  return { ok: true, value: carried.value.value };
229
262
  }
230
- catch (error) {
231
- return {
232
- ok: false,
233
- error: error instanceof Error ? `AWiki 调用失败:${error.message}` : 'AWiki 调用失败',
234
- };
263
+ catch {
264
+ return { ok: false, error: GENERIC_THROW_FAILURE_MESSAGE };
235
265
  }
236
266
  }
237
267
  /** Flatten the carrier and the isolated Guest Integration business result. */
@@ -239,9 +269,22 @@ async function callIntegration(operation) {
239
269
  try {
240
270
  const carried = await operation();
241
271
  if (!carried.ok)
242
- return { ok: false, error: `连接 AWiki Host 失败:${carried.error.message}` };
243
- if (!carried.value.ok)
244
- return { ok: false, error: carried.value.error.message };
272
+ return { ok: false, error: genericCarrierFailureMessage(carried.error) };
273
+ if (!carried.value.ok) {
274
+ const failure = carried.value.error;
275
+ const error = failure.code === 'not-found'
276
+ ? '尚未创建 Integration。'
277
+ : failure.code === 'conflict'
278
+ ? 'Integration 已在其他位置更新,请重新加载后再试。'
279
+ : failure.code === 'invalid-request'
280
+ ? 'Integration 信息不完整或格式不正确。'
281
+ : failure.code === 'unauthorized' || failure.code === 'forbidden'
282
+ ? '当前 AWiki 身份无权管理此 Integration。'
283
+ : failure.code === 'rate-limited'
284
+ ? 'Integration 操作过于频繁,请稍后重试。'
285
+ : '临时消息服务暂时不可用,请稍后重试。';
286
+ return { ok: false, error, failureCode: failure.code };
287
+ }
245
288
  return { ok: true, value: carried.value.value };
246
289
  }
247
290
  catch {
@@ -249,31 +292,28 @@ async function callIntegration(operation) {
249
292
  }
250
293
  }
251
294
  /** Preserve only the stable business code for controller-level state transitions. */
252
- async function callWithFailureCode(operation) {
295
+ async function callWithFailureCode(operation, failureMessage = genericFailureMessage) {
253
296
  try {
254
297
  const carried = await operation();
255
298
  if (!carried.ok)
256
- return { ok: false, error: `连接 AWiki Host 失败:${carried.error.message}` };
299
+ return { ok: false, error: genericCarrierFailureMessage(carried.error) };
257
300
  if (!carried.value.ok) {
258
301
  return {
259
302
  ok: false,
260
- error: `${carried.value.error.code}:${carried.value.error.message}`,
303
+ error: failureMessage(carried.value.error),
261
304
  failureCode: carried.value.error.code,
262
305
  };
263
306
  }
264
307
  return { ok: true, value: carried.value.value };
265
308
  }
266
- catch (error) {
267
- return {
268
- ok: false,
269
- error: error instanceof Error ? `AWiki 调用失败:${error.message}` : 'AWiki 调用失败',
270
- };
309
+ catch {
310
+ return { ok: false, error: GENERIC_THROW_FAILURE_MESSAGE };
271
311
  }
272
312
  }
273
- function recoveryCarrierFailureMessage(message) {
274
- return message.includes('business result failed boundary validation')
313
+ function recoveryCarrierFailureMessage(failure) {
314
+ return failure.code === 'invalid-response'
275
315
  ? '恢复信息已验证,但暂时无法读取恢复状态。请稍后重试。'
276
- : `连接 AWiki Host 失败:${message}`;
316
+ : '暂时无法连接 AWiki Host,无法完成身份恢复。请稍后重试。';
277
317
  }
278
318
  /** Append unique values while retaining existing references. */
279
319
  function appendUnique(current, incoming, id) {
@@ -373,11 +413,15 @@ const GROUP_HISTORY_RETRY_DELAYS_MS = [250, 750, 1_500, 2_500];
373
413
  /** Runtime-only decoded-byte budget that prevents repeat Host calls while browsing. */
374
414
  const BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES = 32 * 1024 * 1024;
375
415
  const RECOVERY_OPERATION_STORAGE_KEY = 'awiki.handle-recovery.operation.v1';
416
+ const RECOVERY_OTP_RETRY_STORAGE_KEY = 'awiki.handle-recovery.retry-at.v1';
376
417
  function recoveryOperationStorageKey(tenantId) {
377
418
  return tenantId === undefined || tenantId === ''
378
419
  ? RECOVERY_OPERATION_STORAGE_KEY
379
420
  : `${RECOVERY_OPERATION_STORAGE_KEY}.${encodeURIComponent(tenantId)}`;
380
421
  }
422
+ function recoveryTenantKey(tenantId) {
423
+ return tenantId === undefined || tenantId === '' ? 'default' : tenantId;
424
+ }
381
425
  function storeRecoveryOperation(operationId, tenantId) {
382
426
  try {
383
427
  const key = recoveryOperationStorageKey(tenantId);
@@ -390,6 +434,49 @@ function storeRecoveryOperation(operationId, tenantId) {
390
434
  // Recovery remains durable in Core even when browser storage is unavailable.
391
435
  }
392
436
  }
437
+ function recoveryOtpRetryStorageKey(operationId, tenantId) {
438
+ return `${RECOVERY_OTP_RETRY_STORAGE_KEY}.${encodeURIComponent(recoveryTenantKey(tenantId))}.${encodeURIComponent(operationId)}`;
439
+ }
440
+ function recoveryOtpRetryAt(result) {
441
+ const advertised = Date.parse(result.retryAt);
442
+ const deadline = Number.isFinite(advertised)
443
+ ? advertised
444
+ : Date.now() + Math.max(0, result.retryAfterSeconds) * 1_000;
445
+ return new Date(deadline).toISOString();
446
+ }
447
+ function storedRecoveryOtpRetryAt(operationId, tenantId) {
448
+ if (operationId === null)
449
+ return null;
450
+ try {
451
+ const key = recoveryOtpRetryStorageKey(operationId, tenantId);
452
+ const value = globalThis.localStorage?.getItem(key);
453
+ if (value === null || value === undefined)
454
+ return null;
455
+ const deadline = Date.parse(value);
456
+ if (!Number.isFinite(deadline) || deadline <= Date.now()) {
457
+ globalThis.localStorage?.removeItem(key);
458
+ return null;
459
+ }
460
+ return new Date(deadline).toISOString();
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ function storeRecoveryOtpRetryAt(operationId, retryAt, tenantId) {
467
+ try {
468
+ globalThis.localStorage?.setItem(recoveryOtpRetryStorageKey(operationId, tenantId), retryAt);
469
+ }
470
+ catch { }
471
+ }
472
+ function clearRecoveryOtpRetryAt(operationId, tenantId) {
473
+ if (operationId === null)
474
+ return;
475
+ try {
476
+ globalThis.localStorage?.removeItem(recoveryOtpRetryStorageKey(operationId, tenantId));
477
+ }
478
+ catch { }
479
+ }
393
480
  /** Wait between group-history readiness probes without retaining controller state. */
394
481
  function delay(milliseconds) {
395
482
  return new Promise(resolve => setTimeout(resolve, milliseconds));
@@ -439,6 +526,8 @@ export class AwikiController {
439
526
  drafts = new AwikiDraftStore();
440
527
  view = INITIAL_VIEW;
441
528
  listeners = new Set();
529
+ /** Runtime-only selected operations survive tenant round trips without reopening stale browser hints after reload. */
530
+ recoveryOperationsByTenant = new Map();
442
531
  config = null;
443
532
  conversationsCursor;
444
533
  historyCursor;
@@ -521,6 +610,9 @@ export class AwikiController {
521
610
  && previous.sessionStatus === session.value.status && previous.identity?.did === identity?.did;
522
611
  this.drafts.setScope(config.value.tenantId ?? 'default', identity?.did ?? session.value.status);
523
612
  this.activatePresentationCache(identity);
613
+ const selectedRecovery = previousTenant === config.value.tenantId
614
+ ? previous.recoveryOperationId
615
+ : this.recoveryOperationsByTenant.get(recoveryTenantKey(config.value.tenantId)) ?? null;
524
616
  this.publish({
525
617
  ...(sameOwner ? this.view : INITIAL_VIEW),
526
618
  status: 'ready',
@@ -529,7 +621,8 @@ export class AwikiController {
529
621
  error: null,
530
622
  attachmentMaxBytes: config.value.attachmentMaxBytes,
531
623
  handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled,
532
- recoveryOperationId: previousTenant === config.value.tenantId ? previous.recoveryOperationId : null,
624
+ recoveryOperationId: selectedRecovery,
625
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, config.value.tenantId),
533
626
  });
534
627
  await this.refreshIdentityAccess();
535
628
  if (!this.current(generation))
@@ -684,7 +777,10 @@ export class AwikiController {
684
777
  * @returns challenge retry metadata or one display-safe failure.
685
778
  */
686
779
  async sendRegistrationOtp(request) {
687
- return this.withPending('发送验证码', () => call(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
780
+ const result = await this.withPending('发送验证码', () => callWithFailureCode(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
781
+ return !result.ok && result.failureCode !== 'short-handle-invite-required'
782
+ ? { ok: false, error: result.error }
783
+ : result;
688
784
  }
689
785
  /** Discover Core-owned work without selecting an account or activating a session. */
690
786
  refreshIdentityAccess() {
@@ -705,28 +801,36 @@ export class AwikiController {
705
801
  if (!this.recoveryCurrent(generation, revision))
706
802
  return this.recoveryLeft();
707
803
  if (!result.ok) {
708
- this.publish({ ...this.view, accessLoading: false, accessError: result.error });
709
- return result;
804
+ this.publish({ ...this.view, accessLoading: false, accessError: IDENTITY_ACCESS_FAILURE_MESSAGE });
805
+ return { ok: false, error: IDENTITY_ACCESS_FAILURE_MESSAGE };
710
806
  }
711
807
  const candidates = result.value.recoveries;
712
808
  const selected = this.view.recoveryOperationId;
713
- this.publish({ ...this.view, identityAccess: result.value });
809
+ this.publish({ ...this.view, identityAccess: result.value, recoveryOperationId: selected,
810
+ recoveryProgress: selected === this.view.recoveryOperationId ? this.view.recoveryProgress : null,
811
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, this.config?.tenantId) });
714
812
  if (selected !== null) {
715
813
  const status = await callWithFailureCode(() => this.remote.getRecoveryStatus({ operationId: selected }));
716
814
  if (!this.recoveryCurrent(generation, revision))
717
815
  return this.recoveryLeft();
718
816
  if (!status.ok) {
719
817
  if (status.failureCode === 'not-found' && candidates.length === 0) {
720
- storeRecoveryOperation(null, this.config?.tenantId);
721
- this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null });
818
+ clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
819
+ this.rememberRecoveryOperation(null, this.config?.tenantId);
820
+ this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, recoveryOtpRetryAt: null });
722
821
  }
723
822
  else {
724
- this.publish({ ...this.view, accessLoading: false, accessError: status.error });
725
- return { ok: false, error: status.error };
823
+ this.publish({ ...this.view, accessLoading: false, accessError: IDENTITY_ACCESS_FAILURE_MESSAGE });
824
+ return { ok: false, error: IDENTITY_ACCESS_FAILURE_MESSAGE };
726
825
  }
727
826
  }
728
827
  else {
729
- this.publish({ ...this.view, recoveryProgress: status.value });
828
+ const complete = status.value.phase === 'applied';
829
+ if (complete)
830
+ clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
831
+ this.publish({ ...this.view, recoveryProgress: status.value,
832
+ recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, this.config?.tenantId),
833
+ identityAccess: result.value });
730
834
  }
731
835
  }
732
836
  this.publish({ ...this.view, accessLoading: false, accessError: null });
@@ -740,7 +844,9 @@ export class AwikiController {
740
844
  this.leaveRecovery();
741
845
  else
742
846
  this.recoveryRevision++;
743
- this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null });
847
+ this.rememberRecoveryOperation(operationId, this.config?.tenantId);
848
+ this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null,
849
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, this.config?.tenantId) });
744
850
  const result = await this.refreshRecoveryStatus();
745
851
  return result.ok ? { ok: true, value: undefined } : result;
746
852
  }
@@ -752,6 +858,16 @@ export class AwikiController {
752
858
  recoveryLeft() {
753
859
  return { ok: false, error: '已离开此恢复流程;进度保留在本机。' };
754
860
  }
861
+ rememberRecoveryOperation(operationId, tenantId) {
862
+ const key = recoveryTenantKey(tenantId);
863
+ if (operationId === null) {
864
+ this.recoveryOperationsByTenant.delete(key);
865
+ storeRecoveryOperation(null, tenantId);
866
+ }
867
+ else {
868
+ this.recoveryOperationsByTenant.set(key, operationId);
869
+ }
870
+ }
755
871
  /** Navigation only. Never cancel or delete the Core-owned operation. */
756
872
  leaveRecovery() {
757
873
  this.recoveryRevision++;
@@ -761,7 +877,7 @@ export class AwikiController {
761
877
  this.pendingRequest = undefined;
762
878
  this.pendingRecovery = false;
763
879
  this.drafts.clearFlow();
764
- storeRecoveryOperation(null, this.config?.tenantId);
880
+ this.rememberRecoveryOperation(null, this.config?.tenantId);
765
881
  this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, accessError: null,
766
882
  accessLoading: false, pending: wasPending ? null : this.view.pending, error: null });
767
883
  }
@@ -839,6 +955,68 @@ export class AwikiController {
839
955
  return result.ok ? { ok: true, value: undefined } : result;
840
956
  });
841
957
  }
958
+ /** Cancel one exact Join choice and send Recovery OTP only while its original browser scope remains current. */
959
+ async beginRecoveryFromDeviceJoin(request) {
960
+ const generation = this.generation;
961
+ const tenantId = this.config?.tenantId;
962
+ const flowScope = this.drafts.getScope('recovery:retryDeadline');
963
+ const flowEpoch = this.drafts.epoch(flowScope);
964
+ const changed = () => ({
965
+ ok: false,
966
+ error: '恢复能力或身份环境已改变,请重新检查后再选择。',
967
+ });
968
+ const scopeCurrent = () => this.current(generation)
969
+ && this.config?.tenantId === tenantId
970
+ && this.drafts.getScope('recovery:retryDeadline') === flowScope
971
+ && this.drafts.epoch(flowScope) === flowEpoch;
972
+ if (!scopeCurrent())
973
+ return changed();
974
+ const latest = await call(() => this.remote.getConfig());
975
+ if (!scopeCurrent())
976
+ return changed();
977
+ if (!latest.ok)
978
+ return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
979
+ if (latest.value.tenantId !== tenantId)
980
+ return changed();
981
+ this.config = latest.value;
982
+ this.publish({ ...this.view, handleRecoveryPhoneEnabled: latest.value.handleRecoveryPhoneEnabled });
983
+ if (latest.value.tenantOnline === false) {
984
+ return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
985
+ }
986
+ if (!latest.value.handleRecoveryPhoneEnabled) {
987
+ return { ok: false, error: '当前服务器未启用手机号恢复,请加入新设备或联系管理员。' };
988
+ }
989
+ const current = () => scopeCurrent() && this.config?.handleRecoveryPhoneEnabled === true;
990
+ const result = await this.withPending('开始恢复身份', async () => {
991
+ const discarded = await call(() => this.remote.cancelDeviceJoin());
992
+ if (!discarded.ok)
993
+ return discarded;
994
+ if (!current())
995
+ return changed();
996
+ this.publish({ ...this.view, identityAccess: { choice: null, joining: false, recoveries: [] } });
997
+ if (request.phone === '')
998
+ return { ok: true, value: null };
999
+ return call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage);
1000
+ }, { publishFailure: false, recovery: true });
1001
+ if (!result.ok) {
1002
+ if (current())
1003
+ await this.continueRecoveryForHandle(request.fullHandle);
1004
+ return result;
1005
+ }
1006
+ if (result.value === null)
1007
+ return result;
1008
+ this.recordRecoveryOtp(result.value, tenantId);
1009
+ const sameFlow = this.config?.tenantId === tenantId
1010
+ && this.drafts.getScope('recovery:retryDeadline') === flowScope
1011
+ && this.drafts.epoch(flowScope) === flowEpoch;
1012
+ if (!sameFlow)
1013
+ return result;
1014
+ const retryAt = recoveryOtpRetryAt(result.value);
1015
+ this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
1016
+ recoveryOtpRetryAt: retryAt });
1017
+ await this.refreshRecoveryStatus();
1018
+ return result;
1019
+ }
842
1020
  retireDeviceIdentityForRejoin() {
843
1021
  return this.withPending('准备重新加入设备', async () => {
844
1022
  const result = await call(() => this.remote.retireDeviceIdentityForRejoin());
@@ -912,17 +1090,29 @@ export class AwikiController {
912
1090
  async sendRecoveryOtp(request) {
913
1091
  const generation = this.generation;
914
1092
  const revision = this.recoveryRevision;
1093
+ const tenantId = this.config?.tenantId;
1094
+ const flowScope = this.drafts.getScope('recovery:retryDeadline');
915
1095
  const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false, recovery: true });
916
- if (!this.recoveryCurrent(generation, revision))
917
- return this.recoveryLeft();
918
1096
  if (!result.ok) {
1097
+ if (!this.recoveryCurrent(generation, revision))
1098
+ return this.recoveryLeft();
919
1099
  await this.continueRecoveryForHandle(request.fullHandle);
920
1100
  return result;
921
1101
  }
922
- this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null });
1102
+ this.recordRecoveryOtp(result.value, tenantId);
1103
+ if (!this.recoveryCurrent(generation, revision)
1104
+ || this.config?.tenantId !== tenantId
1105
+ || this.drafts.getScope('recovery:retryDeadline') !== flowScope)
1106
+ return result;
1107
+ this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
1108
+ recoveryOtpRetryAt: recoveryOtpRetryAt(result.value) });
923
1109
  await this.refreshRecoveryStatus();
924
1110
  return this.recoveryCurrent(generation, revision) ? result : this.recoveryLeft();
925
1111
  }
1112
+ recordRecoveryOtp(result, tenantId) {
1113
+ this.rememberRecoveryOperation(result.operationId, tenantId);
1114
+ storeRecoveryOtpRetryAt(result.operationId, recoveryOtpRetryAt(result), tenantId);
1115
+ }
926
1116
  /** Publish results only into the exact selected flow; Core work survives navigation. */
927
1117
  async runRecovery(label, operation, enterWhenApplied = false) {
928
1118
  const generation = this.generation;
@@ -935,14 +1125,16 @@ export class AwikiController {
935
1125
  return this.recoveryLeft();
936
1126
  if (!result.ok) {
937
1127
  // Reconcile authority after an ambiguous failure, never infer whether Commit occurred.
938
- const status = await call(() => this.remote.getRecoveryStatus({ operationId }));
1128
+ const status = await call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage);
939
1129
  if (!this.recoveryCurrent(generation, revision))
940
1130
  return this.recoveryLeft();
941
1131
  this.publish({ ...this.view, recoveryProgress: status.ok ? status.value : this.view.recoveryProgress,
942
- accessError: status.ok ? null : status.error });
1132
+ accessError: status.ok ? null : IDENTITY_ACCESS_FAILURE_MESSAGE });
943
1133
  return result;
944
1134
  }
945
1135
  this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1136
+ if (result.value.phase === 'applied')
1137
+ clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
946
1138
  if (enterWhenApplied && result.value.phase === 'applied')
947
1139
  return this.enterRecoveredSession();
948
1140
  return result;
@@ -960,7 +1152,25 @@ export class AwikiController {
960
1152
  }
961
1153
  /** Read-only even for applied operations. Entering the identity is a separate action. */
962
1154
  async refreshRecoveryStatus() {
963
- return this.runRecovery('刷新恢复状态', operationId => call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage));
1155
+ const generation = this.generation;
1156
+ const revision = this.recoveryRevision;
1157
+ const operationId = this.view.recoveryOperationId;
1158
+ if (operationId === null)
1159
+ return { ok: false, error: '没有可查询的身份恢复操作' };
1160
+ const result = await this.withPending('刷新恢复状态', async () => {
1161
+ const status = await call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage);
1162
+ return status.ok ? status : { ok: false, error: IDENTITY_ACCESS_FAILURE_MESSAGE };
1163
+ }, { publishFailure: false, recovery: true });
1164
+ if (!this.recoveryCurrent(generation, revision))
1165
+ return this.recoveryLeft();
1166
+ if (!result.ok) {
1167
+ this.publish({ ...this.view, accessError: IDENTITY_ACCESS_FAILURE_MESSAGE });
1168
+ return result;
1169
+ }
1170
+ this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1171
+ if (result.value.phase === 'applied')
1172
+ clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1173
+ return result;
964
1174
  }
965
1175
  async resumeRecovery() {
966
1176
  const progress = this.view.recoveryProgress;
@@ -980,6 +1190,7 @@ export class AwikiController {
980
1190
  return this.recoveryLeft();
981
1191
  if (!result.ok)
982
1192
  return result;
1193
+ clearRecoveryOtpRetryAt(progress.operationId, this.config?.tenantId);
983
1194
  this.leaveRecovery();
984
1195
  const opened = await this.open();
985
1196
  return opened.ok ? result : opened;
@@ -999,6 +1210,7 @@ export class AwikiController {
999
1210
  return this.recoveryLeft();
1000
1211
  if (!result.ok)
1001
1212
  return result;
1213
+ clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1002
1214
  this.leaveRecovery();
1003
1215
  void this.refreshIdentityAccess();
1004
1216
  return { ok: true, value: undefined };
@@ -1132,10 +1344,15 @@ export class AwikiController {
1132
1344
  return this.selectConversation(existing.id);
1133
1345
  }
1134
1346
  const generation = this.generation;
1135
- const resolved = await this.withPending('查找用户', () => call(() => this.remote.resolvePeer({ peer })));
1136
- if (!resolved.ok) {
1137
- return resolved.error.startsWith('not-found') ? this.fail('该 Handle 不存在') : resolved;
1138
- }
1347
+ const resolved = await this.withPending('查找用户', async () => {
1348
+ const result = await callWithFailureCode(() => this.remote.resolvePeer({ peer }));
1349
+ if (!result.ok) {
1350
+ return { ok: false, error: result.failureCode === 'not-found' ? '该 Handle 不存在' : result.error };
1351
+ }
1352
+ return result;
1353
+ });
1354
+ if (!resolved.ok)
1355
+ return resolved;
1139
1356
  if (!this.current(generation))
1140
1357
  return { ok: true, value: undefined };
1141
1358
  if (resolved.value.did === identity.did)
@@ -1811,18 +2028,22 @@ export class AwikiController {
1811
2028
  }
1812
2029
  catch { }
1813
2030
  }
1814
- storeRecoveryOperation(null, tenantId);
2031
+ clearRecoveryOtpRetryAt(this.view.recoveryOperationId, tenantId);
2032
+ this.rememberRecoveryOperation(null, tenantId);
1815
2033
  if (!this.current(generation))
1816
2034
  return result;
1817
2035
  this.drafts.clearScope();
1818
2036
  this.close();
1819
- this.config = null;
2037
+ // Preserve tenantId for the confirm-time getConfig scope check without reopening.
1820
2038
  this.conversationsCursor = undefined;
1821
2039
  this.historyCursor = undefined;
1822
2040
  this.unreadAtOpen.clear();
1823
2041
  this.summaryBaselines.clear();
1824
2042
  this.clearPresentationCache();
1825
- this.publish({ ...INITIAL_VIEW, status: 'ready', accessLoading: false, identityAccess: { choice: null, joining: false, recoveries: [] } });
2043
+ this.publish({ ...INITIAL_VIEW, status: 'ready', accessLoading: false,
2044
+ attachmentMaxBytes: this.config?.attachmentMaxBytes ?? INITIAL_VIEW.attachmentMaxBytes,
2045
+ handleRecoveryPhoneEnabled: this.config?.handleRecoveryPhoneEnabled ?? false,
2046
+ identityAccess: { choice: null, joining: false, recoveries: [] } });
1826
2047
  return result;
1827
2048
  }
1828
2049
  /** Read the Integration without coupling Guest Gateway health to the main AWiki view. */