dsh-mizar 0.8.6 → 0.8.8

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/README.md CHANGED
@@ -135,10 +135,30 @@ DSH 自己的 Web 接口暴露出去(那是 DSH 的安全边界,插件不碰
135
135
  漂到 19391、19392,手机配对里保存的直连地址就永远失效了。现在一个进程只保留**一个**挂载:
136
136
  新挂载要等旧挂载完全释放监听之后才绑端口;网络变化导致监听地址改变时,隧道也会立刻用新
137
137
  地址重拨,并把监听端口的变化同步给隧道(否则隧道健康、手机请求却全部 502)。
138
+ - **0.8.7 修了 0.8.6 单实例的漏洞。** 0.8.6 的注册表是模块级的,而 DSH 会在同一进程里多次
139
+ 加载插件模块,每份副本各有一张空注册表——实测一个进程因此挂了 8 个监听(19390–19397)。
140
+ 0.8.7 把注册表放到 `globalThis`(`Symbol.for('dsh-mizar.mount-registry')`),所有模块副本
141
+ 共用同一个槽位,一个进程只剩一个监听。
138
142
 
139
143
  日志里对应 `relay: the tunnel has been silent for …s — redialling it`、
140
144
  `relay: the network changed — redialling the tunnel` 和 `relay: the listener moved to port …`。
141
145
 
146
+ ### 付费账户的高带宽中继(0.8.8)
147
+
148
+ 付费账户的隧道现在会跟着订阅走:App 知道账户是否付费,插件不知道,所以由 App 调
149
+ `relay.set` 请求电脑把隧道移到账号中继(`dshmizar-1.shirazu-nagisa.com`);免费账户
150
+ 仍走 `dshmizar.shirazu-nagisa.com`,登录/登出或订阅到期时会自动切回。
151
+
152
+ - 隧道令牌不变 ⇒ 房间不变,**已配对关系不受影响**,只是断开重拨。
153
+ - 选择持久化在插件状态目录(`relay-override.json`,600),重启后继续生效。
154
+ - 插件只接受两个公共中继,自建 `relay.url` 时该能力不出现;旧宿主回 `NOT_IMPLEMENTED`,
155
+ 旧客户端不会调用,行为完全不变。
156
+ - 切换前插件广播 `relay.changed`,其它已配对设备会更新保存的中继地址;离线的手机下次
157
+ 连接时会用同一房间尝试另一个公共中继自愈。
158
+ - 连接面板里的「高带宽服务器 / 免费版服务器」现在按**实际所在中继**显示,不再只看账户等级。
159
+
160
+ 协议细节(`relay.set`、`host.info.relay`、`relay.changed`)见 `docs/protocol-v1.md`。
161
+
142
162
  ### 对话只传总结
143
163
 
144
164
  一页历史里只保留这些:你的提问、每一轮的 `turn/start` 和 `turn/end`、每个 `step/start`
@@ -14,6 +14,27 @@ export interface MethodContext {
14
14
  }
15
15
  /** A wire method implementation. */
16
16
  export type MethodHandler = (payload: unknown, context: MethodContext) => Promise<unknown>;
17
+ /**
18
+ * Where this computer's tunnel currently points, as a phone reads it.
19
+ *
20
+ * `baseURL` is the address a phone should dial for this room (the relay origin
21
+ * plus `/r/<room>`), and is `null` until the relay has assigned a room.
22
+ */
23
+ export interface HostRelayState {
24
+ /** The canonical relay origin the tunnel dials. */
25
+ readonly url: string;
26
+ /** The room the relay assigned, once connected. */
27
+ readonly room: string | null;
28
+ /** The base URL a phone should dial, or `null` before the first welcome. */
29
+ readonly baseURL: string | null;
30
+ }
31
+ /** Move this computer's tunnel between the public relays, or report where it is. */
32
+ export interface HostRelayControl {
33
+ /** Read the current relay facts. */
34
+ current(): HostRelayState;
35
+ /** Remember and apply a new relay; the caller has already checked the allowlist. */
36
+ set(url: string): Promise<HostRelayState>;
37
+ }
17
38
  /** What the bridge needs from its host to answer `host.info`. */
18
39
  export interface HostFacts {
19
40
  readonly hostId: string;
@@ -35,6 +56,14 @@ export interface HostFacts {
35
56
  * the second justifies a phone giving up on the direct route.
36
57
  */
37
58
  readonly authorities?: () => readonly string[];
59
+ /**
60
+ * Relay control, when this host may be moved between the two public relays.
61
+ *
62
+ * Absent for a self-hosted relay: there is no account tier to move between,
63
+ * so `relay.set` is not in the method table and an old-style host answers
64
+ * `NOT_IMPLEMENTED` exactly as it would for any other method it does not know.
65
+ */
66
+ readonly relay?: HostRelayControl;
38
67
  }
39
68
  /**
40
69
  * Build the method table for one plugin mount.
@@ -21,7 +21,8 @@ import { modelCatalog, selectModel } from './models.js';
21
21
  import { permissionCatalog, setPermissionPreset } from './permissions.js';
22
22
  import { archiveSession, listWorkspaces } from './workspaces.js';
23
23
  import { cancelSession, createSession, listSessions, promptSession, renameSession, sessionActivity, sessionMessages, sessionStats, } from './sessions.js';
24
- import { parseUploadBegin, parseUploadChunk, UploadStore } from './uploads.js';
24
+ import { parseUploadBegin, parseUploadChunk, PromptReplayGuard, UploadStore } from './uploads.js';
25
+ import { canonicalRelayURL, isSelectableRelayURL } from '../relay/selection.js';
25
26
  /**
26
27
  * Translate any thrown value into a wire-coded failure.
27
28
  * @param handler - the raw implementation.
@@ -288,6 +289,13 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
288
289
  root: join(facts.home, 'uploads'),
289
290
  attachmentsRoot: join(facts.home, 'attachments'),
290
291
  });
292
+ // A prompt that carries attachments is retried with the same requestId when
293
+ // its answer is lost. That is safe only while the request is identical: DSH
294
+ // deduplicates by (session, requestId), so the same key with different
295
+ // content must be refused before it can borrow the first answer. Ported
296
+ // from XEPX2's PR #2.
297
+ const replayGuard = new PromptReplayGuard();
298
+ const relay = facts.relay;
291
299
  const table = {
292
300
  /**
293
301
  * Host identity and protocol facts.
@@ -306,7 +314,32 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
306
314
  home: facts.home,
307
315
  protocolVersion: facts.protocolVersion,
308
316
  ...(facts.authorities === undefined ? {} : { addresses: facts.authorities() }),
317
+ ...(relay === undefined ? {} : { relay: relay.current() }),
309
318
  }),
319
+ /**
320
+ * Move this computer's tunnel to the free or the paid relay.
321
+ *
322
+ * The phone asks because only the phone knows the account tier; the host
323
+ * owns the allowlist and the persistence so a paired device cannot point
324
+ * this computer's tunnel at an arbitrary server. The tunnel credential is
325
+ * unchanged, so the room is unchanged and every existing pairing survives;
326
+ * what changes is the address a phone dials, and the answer carries it.
327
+ *
328
+ * Additive, so no protocol version bump: an old host answers
329
+ * `NOT_IMPLEMENTED`, which is how the app knows to keep its current relay.
330
+ */
331
+ ...(relay === undefined
332
+ ? {}
333
+ : {
334
+ 'relay.set': async (payload) => {
335
+ const requested = requireString(payload, 'url', 'relay.set');
336
+ const canonical = canonicalRelayURL(requested);
337
+ if (canonical === null || !isSelectableRelayURL(canonical)) {
338
+ throw new MethodError('MALFORMED_FRAME', 'relay.set: payload."url" must be the free or the paid relay');
339
+ }
340
+ return relay.set(canonical);
341
+ },
342
+ }),
310
343
  /** Every session DSH would list, newest activity first. */
311
344
  'session.list': async () => listSessions(ctx),
312
345
  /**
@@ -347,6 +380,11 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
347
380
  const before = fields?.['beforeSeq'];
348
381
  const after = fields?.['afterSeq'];
349
382
  const sessionId = requireString(payload, 'sessionId', 'session.messages');
383
+ // A delegated child is not addressable as a session: the host refuses the
384
+ // read with `session/agent-busy` unless the child is named through its
385
+ // parent. The phone knows the lineage from `session.list` and sends it
386
+ // back here, so the address can be rebuilt on the way in.
387
+ const parentSessionId = optionalString(payload, 'parentSessionId');
350
388
  const explicit = typeof after === 'number' && Number.isFinite(after) ? after : undefined;
351
389
  // `null` is the phone saying it holds nothing -- a reinstall, a cleared
352
390
  // cache. Silence is not that claim: it is a request with no cursor, which
@@ -355,7 +393,7 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
355
393
  const remembered = holdsNothing || explicit !== undefined
356
394
  ? undefined
357
395
  : delivery?.deliveredThrough(context.deviceId, sessionId);
358
- return sessionMessages(ctx, sessionId, typeof limit === 'number' ? limit : undefined, typeof before === 'number' ? before : undefined, explicit ?? remembered);
396
+ return sessionMessages(ctx, sessionId, typeof limit === 'number' ? limit : undefined, typeof before === 'number' ? before : undefined, explicit ?? remembered, parentSessionId);
359
397
  },
360
398
  /**
361
399
  * The running turn's compact process detail.
@@ -414,6 +452,15 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
414
452
  if (text === undefined && attachmentIds.length === 0) {
415
453
  throw new MethodError('MALFORMED_FRAME', 'session.prompt: payload."text" must be a non-empty string when no attachments are given');
416
454
  }
455
+ const requestId = optionalString(payload, 'requestId');
456
+ if (attachmentIds.length > 0 && requestId !== undefined) {
457
+ replayGuard.check(context.deviceId, sessionId, requestId, {
458
+ text,
459
+ mode,
460
+ clientTimeZone: optionalString(payload, 'clientTimeZone'),
461
+ attachmentIds,
462
+ });
463
+ }
417
464
  const attachments = attachmentIds.length === 0
418
465
  ? []
419
466
  : await uploadStore.resolveForPrompt(context.deviceId, sessionId, attachmentIds);
@@ -422,7 +469,7 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
422
469
  ...(text === undefined ? {} : { text }),
423
470
  ...(attachments.length === 0 ? {} : { attachments }),
424
471
  mode,
425
- requestId: optionalString(payload, 'requestId'),
472
+ requestId,
426
473
  clientTimeZone: optionalString(payload, 'clientTimeZone'),
427
474
  });
428
475
  },
@@ -445,6 +492,14 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
445
492
  'upload.finish': async (payload, context) => uploadStore.finish(context.deviceId, requireString(payload, 'uploadId', 'upload.finish')),
446
493
  /** Discard a half-sent upload and its bytes. */
447
494
  'upload.abort': async (payload, context) => uploadStore.abort(context.deviceId, requireString(payload, 'uploadId', 'upload.abort')),
495
+ /**
496
+ * The attachment limits as this host enforces them.
497
+ *
498
+ * Additive and optional: a client may hardcode the documented numbers and
499
+ * never call this, but one that asks first survives a host that tunes
500
+ * them. Ported from XEPX2's PR #2.
501
+ */
502
+ 'upload.capabilities': async () => uploadStore.capabilities(),
448
503
  /** Interrupt the running turn. */
449
504
  'session.cancel': async (payload) => cancelSession(ctx, requireString(payload, 'sessionId', 'session.cancel')),
450
505
  /** Rename a session. */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Create (if needed) and secure one staging directory for the current user.
3
+ *
4
+ * The directory is created first when absent, because the ACL reset below is
5
+ * what makes it private — creating it already-secured avoids a window in which
6
+ * an inherited-ACL directory exists.
7
+ *
8
+ * @param path - the directory to create and secure.
9
+ * @throws when the directory cannot be created or its ACL cannot be secured
10
+ * and verified; the caller must not write bytes into it afterwards.
11
+ */
12
+ export declare function makePrivateDirectory(path: string): Promise<void>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Secure a staging directory so only the current user can reach it.
3
+ *
4
+ * Ported from XEPX2's PR #2 (`src/attachments/private-directory.ts`), which
5
+ * guarded its attachment staging area; this module uses it for the same job on
6
+ * the `upload.*` staging roots. `mode: 0o700` is enough on POSIX, but Windows
7
+ * ignores it: a fresh directory under the user profile inherits grants
8
+ * (SYSTEM, Administrators, the user) that `chmod` cannot remove. The OS answer
9
+ * is to reset the ACL to exactly one rule for the current SID — and to verify
10
+ * the reset afterwards rather than trust it.
11
+ *
12
+ * The verification is the point. Writing bytes into a directory whose ACL did
13
+ * not actually tighten would be silent exposure, so the script re-reads the
14
+ * ACL and fails unless it sees the protection flag, exactly one rule, and that
15
+ * rule naming the current user. Every failure path throws before the caller
16
+ * writes a byte.
17
+ *
18
+ * The parent directory (the plugin's state home, never TEMP itself) is left
19
+ * untouched, and no elevation is requested: if the current user cannot secure
20
+ * its own directory, staging is refused, not de-scoped.
21
+ *
22
+ * @module dsh-mizar/bridge/private-directory
23
+ */
24
+ import { spawn } from 'node:child_process';
25
+ import { chmod, mkdir } from 'node:fs/promises';
26
+ import { join } from 'node:path';
27
+ // Current-user SID, not localized account-name parsing. The path is passed as
28
+ // data in the child environment, not interpolated into PowerShell source.
29
+ const WINDOWS_ACL = `
30
+ $ErrorActionPreference = 'Stop'
31
+ $ProgressPreference = 'SilentlyContinue'
32
+ $path = $env:MIZAR_PRIVATE_UPLOAD_DIRECTORY
33
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
34
+ $acl = New-Object System.Security.AccessControl.DirectorySecurity
35
+ $acl.SetOwner($sid)
36
+ $acl.SetAccessRuleProtection($true, $false)
37
+ $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
38
+ $propagation = [System.Security.AccessControl.PropagationFlags]::None
39
+ $allow = [System.Security.AccessControl.AccessControlType]::Allow
40
+ $rights = [System.Security.AccessControl.FileSystemRights]::FullControl
41
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, $rights, $inheritance, $propagation, $allow)
42
+ $acl.AddAccessRule($rule)
43
+ $directory = [System.IO.DirectoryInfo]::new($path)
44
+ $directory.SetAccessControl($acl)
45
+ $check = $directory.GetAccessControl()
46
+ if (-not $check.AreAccessRulesProtected) { throw 'Private upload ACL was not protected' }
47
+ $rules = $check.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
48
+ if ($rules.Count -ne 1) { throw 'Private upload ACL has an unexpected rule count' }
49
+ foreach ($entry in $rules) {
50
+ if ($entry.IdentityReference.Value -ne $sid.Value) {
51
+ throw 'Private upload ACL contains an unexpected principal'
52
+ }
53
+ }
54
+ `;
55
+ /**
56
+ * Create (if needed) and secure one staging directory for the current user.
57
+ *
58
+ * The directory is created first when absent, because the ACL reset below is
59
+ * what makes it private — creating it already-secured avoids a window in which
60
+ * an inherited-ACL directory exists.
61
+ *
62
+ * @param path - the directory to create and secure.
63
+ * @throws when the directory cannot be created or its ACL cannot be secured
64
+ * and verified; the caller must not write bytes into it afterwards.
65
+ */
66
+ export async function makePrivateDirectory(path) {
67
+ await mkdir(path, { recursive: true });
68
+ if (process.platform !== 'win32') {
69
+ await chmod(path, 0o700);
70
+ return;
71
+ }
72
+ // Windows chmod cannot remove inherited ACL grants. Use the OS-supplied
73
+ // Windows PowerShell, with no profile, no shell interpolation and no pipes.
74
+ const executable = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
75
+ await new Promise((resolve, reject) => {
76
+ const child = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(WINDOWS_ACL, 'utf16le').toString('base64')], {
77
+ windowsHide: true,
78
+ stdio: 'ignore',
79
+ env: { ...process.env, MIZAR_PRIVATE_UPLOAD_DIRECTORY: path },
80
+ });
81
+ const timer = setTimeout(() => {
82
+ child.kill();
83
+ reject(new Error('Private upload ACL setup timed out'));
84
+ }, 15_000);
85
+ child.once('error', (error) => {
86
+ clearTimeout(timer);
87
+ reject(error);
88
+ });
89
+ child.once('exit', (code) => {
90
+ clearTimeout(timer);
91
+ if (code === 0)
92
+ resolve();
93
+ else
94
+ reject(new Error('Cannot secure private upload directory ACL'));
95
+ });
96
+ });
97
+ }
@@ -72,9 +72,13 @@ export declare function sessionStats(ctx: Context, sessionId: string): Promise<S
72
72
  * @param afterSeq - the log position the caller already holds; only records
73
73
  * after it travel. Ignored together with `beforeSeq`, because a page paged
74
74
  * backwards is not a page of new records.
75
+ * @param parentSessionId - the parent of a delegated child. Supplying it is what
76
+ * makes a child's history readable at all: without it the host refuses the
77
+ * read outright (`session/agent-busy`), because a child is not addressable as
78
+ * a session. See {@link logAddress}.
75
79
  * @returns the page, or no records when the session is unknown.
76
80
  */
77
- export declare function sessionMessages(ctx: Context, sessionId: string, maxMessages?: number, beforeSeq?: number, afterSeq?: number): Promise<SessionMessagesResult>;
81
+ export declare function sessionMessages(ctx: Context, sessionId: string, maxMessages?: number, beforeSeq?: number, afterSeq?: number, parentSessionId?: string): Promise<SessionMessagesResult>;
78
82
  /** One page of a session's log. */
79
83
  export interface SessionMessagesResult {
80
84
  readonly sessionId: string;
@@ -457,6 +457,19 @@ function turnOf(event) {
457
457
  * answer this way, not for one that is merely slow.
458
458
  */
459
459
  const FOLLOW_TIMEOUT_MS = 30_000;
460
+ /**
461
+ * Address one session's log, as a child when its parent is known.
462
+ *
463
+ * @param sessionId - the session whose log is being read.
464
+ * @param parentSessionId - the parent of a delegated child, or absent for an
465
+ * ordinary conversation (and for a fork, which is a session of its own).
466
+ * @returns the address to send.
467
+ */
468
+ function logAddress(sessionId, parentSessionId) {
469
+ return parentSessionId === undefined
470
+ ? { kind: 'session', sessionId }
471
+ : { kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'unknown' };
472
+ }
460
473
  /**
461
474
  * The log cut, from the opening frame of `session/follow`.
462
475
  *
@@ -485,17 +498,17 @@ const FOLLOW_TIMEOUT_MS = 30_000;
485
498
  * leak that only shows up after an hour of use.
486
499
  *
487
500
  * @param ctx - a context observing `ctx.typertGateway`.
488
- * @param sessionId - the session to open.
501
+ * @param address - the durable address to open; see {@link logAddress}.
489
502
  * @returns the cursor, or `null` when the host would not answer this way.
490
503
  */
491
- async function followCursor(ctx, sessionId) {
504
+ async function followCursor(ctx, address) {
492
505
  const controller = new AbortController();
493
506
  const timer = setTimeout(() => controller.abort(), FOLLOW_TIMEOUT_MS);
494
507
  try {
495
508
  const iterable = await streamRemote(ctx, {
496
509
  namespace: 'session',
497
510
  method: 'follow',
498
- args: { request: { address: { kind: 'session', sessionId } } },
511
+ args: { request: { address } },
499
512
  signal: controller.signal,
500
513
  });
501
514
  const iterator = iterable[Symbol.asyncIterator]();
@@ -584,10 +597,13 @@ async function projectionsCut(ctx, sessionId) {
584
597
  * @param sessionId - the session to read.
585
598
  * @param maxMessages - how many messages to ask for; absent uses the host default.
586
599
  * @param beforeSeq - the exclusive upper bound, for paging backwards.
600
+ * @param parentSessionId - the parent of a delegated child; absent reads the
601
+ * session as a session. See {@link logAddress}.
587
602
  * @returns the page, or `null` when the session has no log to read.
588
603
  */
589
- async function readLogPage(ctx, sessionId, maxMessages, beforeSeq) {
590
- const cut = (await followCursor(ctx, sessionId)) ?? (await projectionsCut(ctx, sessionId));
604
+ async function readLogPage(ctx, sessionId, maxMessages, beforeSeq, parentSessionId) {
605
+ const address = logAddress(sessionId, parentSessionId);
606
+ const cut = (await followCursor(ctx, address)) ?? (await projectionsCut(ctx, sessionId));
591
607
  if (cut === null) {
592
608
  // No cut, no page: the session has no log to read.
593
609
  return null;
@@ -597,7 +613,7 @@ async function readLogPage(ctx, sessionId, maxMessages, beforeSeq) {
597
613
  method: 'page',
598
614
  args: {
599
615
  request: {
600
- address: { kind: 'session', sessionId },
616
+ address,
601
617
  throughSeq: cut,
602
618
  // The lower bound. Without it a page starts at the beginning of the log,
603
619
  // which for a session with any history means a conversation view made
@@ -631,10 +647,14 @@ async function readLogPage(ctx, sessionId, maxMessages, beforeSeq) {
631
647
  * @param afterSeq - the log position the caller already holds; only records
632
648
  * after it travel. Ignored together with `beforeSeq`, because a page paged
633
649
  * backwards is not a page of new records.
650
+ * @param parentSessionId - the parent of a delegated child. Supplying it is what
651
+ * makes a child's history readable at all: without it the host refuses the
652
+ * read outright (`session/agent-busy`), because a child is not addressable as
653
+ * a session. See {@link logAddress}.
634
654
  * @returns the page, or no records when the session is unknown.
635
655
  */
636
- export async function sessionMessages(ctx, sessionId, maxMessages, beforeSeq, afterSeq) {
637
- const page = await readLogPage(ctx, sessionId, maxMessages, beforeSeq);
656
+ export async function sessionMessages(ctx, sessionId, maxMessages, beforeSeq, afterSeq, parentSessionId) {
657
+ const page = await readLogPage(ctx, sessionId, maxMessages, beforeSeq, parentSessionId);
638
658
  if (page === null) {
639
659
  return { sessionId, asOfSeq: null, records: [], hasMore: false };
640
660
  }
@@ -15,6 +15,22 @@ export declare const UPLOAD_LIMITS: {
15
15
  /** Most images one prompt may carry. */
16
16
  readonly images: 4;
17
17
  };
18
+ /**
19
+ * Resource quotas the staging area enforces on top of the documented limits.
20
+ *
21
+ * The per-prompt limits bound one message; these bound the desktop. Ported
22
+ * from XEPX2's PR #2. `reservedBytes` is the sum of what live uploads have
23
+ * declared; the reservation ends when the upload completes (its bytes then
24
+ * become an attachment, bounded at prompt time) or expires.
25
+ */
26
+ export declare const UPLOAD_QUOTAS: {
27
+ /** Largest total of declared-but-unreceived bytes across all live uploads. */
28
+ readonly reservedBytes: number;
29
+ /** Most uploads in flight, across every device. */
30
+ readonly active: 100;
31
+ /** Most uploads in flight for one device. */
32
+ readonly perDevice: 10;
33
+ };
18
34
  /**
19
35
  * Media types DSH accepts as image content.
20
36
  *
@@ -33,6 +49,8 @@ export interface UploadBeginRequest {
33
49
  readonly name: string;
34
50
  readonly mediaType: string;
35
51
  readonly byteSize: number;
52
+ /** Lowercase hex SHA-256 of the full payload, when the client declares it. */
53
+ readonly sha256?: string;
36
54
  }
37
55
  /** `upload.chunk` arguments after validation. */
38
56
  export interface UploadChunkRequest {
@@ -54,6 +72,19 @@ export interface UploadFinishResult {
54
72
  readonly mediaType: string;
55
73
  readonly byteSize: number;
56
74
  }
75
+ /** What `upload.capabilities` answers. */
76
+ export interface UploadCapabilities {
77
+ readonly chunkBytes: number;
78
+ readonly imageBytes: number;
79
+ readonly fileBytes: number;
80
+ readonly promptBytes: number;
81
+ readonly attachments: number;
82
+ readonly images: number;
83
+ readonly expiresSeconds: number;
84
+ readonly imageMediaTypes: readonly string[];
85
+ /** Whether the host verifies a client-declared SHA-256. */
86
+ readonly sha256: boolean;
87
+ }
57
88
  /**
58
89
  * One finished upload, as the session bridge consumes it.
59
90
  *
@@ -90,6 +121,18 @@ export interface UploadStoreOptions {
90
121
  readonly chunkBytes?: number;
91
122
  /** Clock override, used by tests to age an upload without sleeping. */
92
123
  readonly now?: () => number;
124
+ /** Largest total of declared bytes across live uploads (tests shrink this). */
125
+ readonly maxReservedBytes?: number;
126
+ /** Most uploads in flight across every device (tests shrink this). */
127
+ readonly maxActiveUploads?: number;
128
+ /** Most uploads in flight for one device (tests shrink this). */
129
+ readonly maxDeviceUploads?: number;
130
+ /**
131
+ * Whether `begin` secures the staging roots before writing (tests pass
132
+ * `false` so a hundred cases do not each spawn the ACL tooling; the real
133
+ * behavior has its own test).
134
+ */
135
+ readonly secureRoots?: boolean;
93
136
  }
94
137
  /**
95
138
  * Read `upload.begin`'s payload.
@@ -160,6 +203,11 @@ export declare class UploadStore {
160
203
  * @param options - where uploads live and how long they last.
161
204
  */
162
205
  constructor(options: UploadStoreOptions);
206
+ /**
207
+ * The limits a client should obey, as this store enforces them.
208
+ * @returns the capability record `upload.capabilities` answers with.
209
+ */
210
+ capabilities(): UploadCapabilities;
163
211
  /**
164
212
  * Begin one upload.
165
213
  *
@@ -234,3 +282,43 @@ export declare class UploadStore {
234
282
  */
235
283
  resolveForPrompt(deviceId: string, sessionId: string, attachmentIds: readonly string[]): Promise<PromptAttachment[]>;
236
284
  }
285
+ /**
286
+ * Refuses a `requestId` reused for a different attachment prompt.
287
+ *
288
+ * DSH deduplicates prompts by session and request id: the same key always
289
+ * returns the first answer. For a text prompt that is the client's own
290
+ * idempotency choice; for a prompt that carries attachments, the key plus the
291
+ * content is the contract — the same retry must carry the same text, the same
292
+ * attachments in the same order, the same mode and timezone, from the same
293
+ * device. Anything else is either a client bug or an attempt to borrow another
294
+ * request's acceptance, and it is refused rather than second-guessed.
295
+ *
296
+ * The ledger is memory-only and bounded: at most 1000 keys globally, 100 per
297
+ * device, each forgotten 30 minutes after it was last seen — the same bounds
298
+ * XEPX2's PR #2 chose, for the same reason: a restart is allowed to forget,
299
+ * because DSH's own persistence is the final guard against duplicates.
300
+ */
301
+ export declare class PromptReplayGuard {
302
+ #private;
303
+ /**
304
+ * @param now - the clock, overridable for tests.
305
+ */
306
+ constructor(now?: () => number);
307
+ /**
308
+ * Record one attachment prompt, refusing a key reused with different content.
309
+ *
310
+ * @param deviceId - the authenticated device.
311
+ * @param sessionId - the target session.
312
+ * @param requestId - the client's idempotency key.
313
+ * @param content - everything that makes this prompt this prompt.
314
+ * @throws MethodError `ATTACHMENT_INVALID` when the key was seen with a
315
+ * different device or content.
316
+ * @throws MethodError `UPLOAD_LIMIT_REACHED` when the ledger is full.
317
+ */
318
+ check(deviceId: string, sessionId: string, requestId: string, content: {
319
+ readonly text?: string;
320
+ readonly mode: 'queue' | 'steer';
321
+ readonly clientTimeZone?: string;
322
+ readonly attachmentIds: readonly string[];
323
+ }): void;
324
+ }