@tienne/gestalt 0.111.0 → 0.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/package.json +3 -1
  5. package/dist/plugin/role-agents/lens-context/AGENT.md +8 -7
  6. package/dist/plugin/role-agents/lens-reader/AGENT.md +48 -2
  7. package/dist/plugin/skills/lens/SKILL.md +22 -15
  8. package/dist/src/architecture/doc-fresh.d.ts.map +1 -1
  9. package/dist/src/architecture/doc-fresh.js +3 -1
  10. package/dist/src/architecture/doc-fresh.js.map +1 -1
  11. package/dist/src/cli/commands/lens.d.ts +8 -1
  12. package/dist/src/cli/commands/lens.d.ts.map +1 -1
  13. package/dist/src/cli/commands/lens.js +29 -2
  14. package/dist/src/cli/commands/lens.js.map +1 -1
  15. package/dist/src/cli/commands/peer.d.ts +20 -0
  16. package/dist/src/cli/commands/peer.d.ts.map +1 -1
  17. package/dist/src/cli/commands/peer.js +198 -12
  18. package/dist/src/cli/commands/peer.js.map +1 -1
  19. package/dist/src/cli/index.d.ts.map +1 -1
  20. package/dist/src/cli/index.js +44 -4
  21. package/dist/src/cli/index.js.map +1 -1
  22. package/dist/src/digest/lens-schema.d.ts +150 -3
  23. package/dist/src/digest/lens-schema.d.ts.map +1 -1
  24. package/dist/src/digest/lens-schema.js +14 -1
  25. package/dist/src/digest/lens-schema.js.map +1 -1
  26. package/dist/src/digest/lens-widget-schema.d.ts +2 -2
  27. package/dist/src/digest/lint-lens.d.ts +2 -1
  28. package/dist/src/digest/lint-lens.d.ts.map +1 -1
  29. package/dist/src/digest/lint-lens.js +68 -7
  30. package/dist/src/digest/lint-lens.js.map +1 -1
  31. package/dist/src/digest/ocr.d.ts +1 -1
  32. package/dist/src/digest/ocr.d.ts.map +1 -1
  33. package/dist/src/digest/ocr.js +10 -4
  34. package/dist/src/digest/ocr.js.map +1 -1
  35. package/dist/src/digest/schema.d.ts +62 -62
  36. package/dist/src/lens/attach.d.ts +741 -0
  37. package/dist/src/lens/attach.d.ts.map +1 -1
  38. package/dist/src/lens/attach.js +41 -11
  39. package/dist/src/lens/attach.js.map +1 -1
  40. package/dist/src/lens/context-input.d.ts +17 -0
  41. package/dist/src/lens/context-input.d.ts.map +1 -0
  42. package/dist/src/lens/context-input.js +31 -0
  43. package/dist/src/lens/context-input.js.map +1 -0
  44. package/dist/src/lens/ingest.d.ts +19 -1
  45. package/dist/src/lens/ingest.d.ts.map +1 -1
  46. package/dist/src/lens/ingest.js +92 -14
  47. package/dist/src/lens/ingest.js.map +1 -1
  48. package/dist/src/lens/pdf-extract-py.d.ts +2 -0
  49. package/dist/src/lens/pdf-extract-py.d.ts.map +1 -1
  50. package/dist/src/lens/pdf-extract-py.js +37 -2
  51. package/dist/src/lens/pdf-extract-py.js.map +1 -1
  52. package/dist/src/mcp/schemas.d.ts +29 -0
  53. package/dist/src/mcp/schemas.d.ts.map +1 -1
  54. package/dist/src/mcp/schemas.js +29 -0
  55. package/dist/src/mcp/schemas.js.map +1 -1
  56. package/dist/src/mcp/server.d.ts.map +1 -1
  57. package/dist/src/mcp/server.js +6 -1
  58. package/dist/src/mcp/server.js.map +1 -1
  59. package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -1
  60. package/dist/src/mcp/tools/architecture-passthrough.js +1 -1
  61. package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -1
  62. package/dist/src/mcp/tools/peer.d.ts +24 -0
  63. package/dist/src/mcp/tools/peer.d.ts.map +1 -0
  64. package/dist/src/mcp/tools/peer.js +103 -0
  65. package/dist/src/mcp/tools/peer.js.map +1 -0
  66. package/dist/src/peer/codex.d.ts +57 -0
  67. package/dist/src/peer/codex.d.ts.map +1 -0
  68. package/dist/src/peer/codex.js +303 -0
  69. package/dist/src/peer/codex.js.map +1 -0
  70. package/dist/src/peer/daemon.d.ts +76 -1
  71. package/dist/src/peer/daemon.d.ts.map +1 -1
  72. package/dist/src/peer/daemon.js +511 -19
  73. package/dist/src/peer/daemon.js.map +1 -1
  74. package/dist/src/peer/errors.d.ts +3 -0
  75. package/dist/src/peer/errors.d.ts.map +1 -1
  76. package/dist/src/peer/errors.js +3 -0
  77. package/dist/src/peer/errors.js.map +1 -1
  78. package/dist/src/peer/excerpt.d.ts +47 -0
  79. package/dist/src/peer/excerpt.d.ts.map +1 -0
  80. package/dist/src/peer/excerpt.js +160 -0
  81. package/dist/src/peer/excerpt.js.map +1 -0
  82. package/dist/src/peer/feed.d.ts +35 -2
  83. package/dist/src/peer/feed.d.ts.map +1 -1
  84. package/dist/src/peer/feed.js +22 -0
  85. package/dist/src/peer/feed.js.map +1 -1
  86. package/dist/src/peer/inbox.d.ts +42 -0
  87. package/dist/src/peer/inbox.d.ts.map +1 -0
  88. package/dist/src/peer/inbox.js +96 -0
  89. package/dist/src/peer/inbox.js.map +1 -0
  90. package/dist/src/peer/locate.d.ts +5 -0
  91. package/dist/src/peer/locate.d.ts.map +1 -1
  92. package/dist/src/peer/locate.js +19 -2
  93. package/dist/src/peer/locate.js.map +1 -1
  94. package/dist/src/peer/message.d.ts +113 -0
  95. package/dist/src/peer/message.d.ts.map +1 -0
  96. package/dist/src/peer/message.js +183 -0
  97. package/dist/src/peer/message.js.map +1 -0
  98. package/dist/src/peer/mirror.d.ts +50 -1
  99. package/dist/src/peer/mirror.d.ts.map +1 -1
  100. package/dist/src/peer/mirror.js +138 -0
  101. package/dist/src/peer/mirror.js.map +1 -1
  102. package/dist/src/peer/network.d.ts +229 -1
  103. package/dist/src/peer/network.d.ts.map +1 -1
  104. package/dist/src/peer/network.js +867 -44
  105. package/dist/src/peer/network.js.map +1 -1
  106. package/dist/src/peer/paths.d.ts +6 -0
  107. package/dist/src/peer/paths.d.ts.map +1 -1
  108. package/dist/src/peer/paths.js +8 -0
  109. package/dist/src/peer/paths.js.map +1 -1
  110. package/dist/src/peer/peek.d.ts +79 -0
  111. package/dist/src/peer/peek.d.ts.map +1 -0
  112. package/dist/src/peer/peek.js +465 -0
  113. package/dist/src/peer/peek.js.map +1 -0
  114. package/dist/src/peer/peeks.d.ts +115 -0
  115. package/dist/src/peer/peeks.d.ts.map +1 -0
  116. package/dist/src/peer/peeks.js +262 -0
  117. package/dist/src/peer/peeks.js.map +1 -0
  118. package/dist/src/peer/protocol.d.ts +128 -4
  119. package/dist/src/peer/protocol.d.ts.map +1 -1
  120. package/dist/src/peer/protocol.js.map +1 -1
  121. package/dist/src/peer/questions.d.ts +168 -0
  122. package/dist/src/peer/questions.d.ts.map +1 -0
  123. package/dist/src/peer/questions.js +389 -0
  124. package/dist/src/peer/questions.js.map +1 -0
  125. package/dist/src/peer/relay.d.ts +14 -0
  126. package/dist/src/peer/relay.d.ts.map +1 -1
  127. package/dist/src/peer/relay.js +102 -7
  128. package/dist/src/peer/relay.js.map +1 -1
  129. package/dist/src/peer/roster.d.ts +2 -0
  130. package/dist/src/peer/roster.d.ts.map +1 -1
  131. package/dist/src/peer/roster.js +5 -0
  132. package/dist/src/peer/roster.js.map +1 -1
  133. package/dist/src/peer/share.d.ts +48 -1
  134. package/dist/src/peer/share.d.ts.map +1 -1
  135. package/dist/src/peer/share.js +167 -2
  136. package/dist/src/peer/share.js.map +1 -1
  137. package/dist/src/peer/state.d.ts +12 -1
  138. package/dist/src/peer/state.d.ts.map +1 -1
  139. package/dist/src/peer/state.js +15 -0
  140. package/dist/src/peer/state.js.map +1 -1
  141. package/dist/src/peer/transform.d.ts +32 -0
  142. package/dist/src/peer/transform.d.ts.map +1 -1
  143. package/dist/src/peer/transform.js +52 -33
  144. package/dist/src/peer/transform.js.map +1 -1
  145. package/dist/src/peer/transport.d.ts +7 -3
  146. package/dist/src/peer/transport.d.ts.map +1 -1
  147. package/dist/src/peer/transport.js +12 -4
  148. package/dist/src/peer/transport.js.map +1 -1
  149. package/dist/src/peer/viewer-html.d.ts +13 -1
  150. package/dist/src/peer/viewer-html.d.ts.map +1 -1
  151. package/dist/src/peer/viewer-html.js +328 -4
  152. package/dist/src/peer/viewer-html.js.map +1 -1
  153. package/dist/src/peer/viewer.d.ts +49 -1
  154. package/dist/src/peer/viewer.d.ts.map +1 -1
  155. package/dist/src/peer/viewer.js +133 -7
  156. package/dist/src/peer/viewer.js.map +1 -1
  157. package/dist/src/peer/wire.d.ts +9 -2
  158. package/dist/src/peer/wire.d.ts.map +1 -1
  159. package/dist/src/peer/wire.js.map +1 -1
  160. package/package.json +3 -1
  161. package/plugin/.codex-plugin/plugin.json +1 -1
  162. package/plugin/.mcp.json +1 -1
  163. package/plugin/mcp.json +1 -1
  164. package/plugin/role-agents/lens-context/AGENT.md +8 -7
  165. package/plugin/role-agents/lens-reader/AGENT.md +48 -2
  166. package/plugin/skills/lens/SKILL.md +22 -15
@@ -1,19 +1,68 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { isRawKey } from './keys.js';
3
3
  import { MirrorFeed } from './mirror.js';
4
- import { checkRosterUpdate, decodeInvite, encodeInvite, formatShareLink, isAdmin, isPersonName, isRelayUrl, joinProofText, memberOf, nextRoster, parseShareLink, signRoster, } from './roster.js';
4
+ import { checkRosterUpdate, decodeInvite, encodeInvite, formatShareLink, isAdmin, isPersonName, isRelayUrl, isShareId, joinProofText, memberOf, nextRoster, parseShareLink, signRoster, } from './roster.js';
5
5
  import { aeadOpen, aeadSeal, canonical, deriveKey, hmac, openBox, safeEqual, sealBox, } from './seal.js';
6
6
  import { RelayTransport } from './transport.js';
7
7
  import { MAX_VIEWERS_PER_SHARE, REPLAY_CHUNK_BYTES } from './viewer.js';
8
- import { errMessage } from './errors.js';
8
+ import { errMessage, PeerNetworkError } from './errors.js';
9
+ import { isMessageId, MESSAGE_RECEIVE_LIMIT, newMessageId, plainLabel, RateLimiter, sanitizeMessageBody, } from './message.js';
10
+ import { RelaySendError } from './transport.js';
11
+ import { DEFAULT_FIRST_WATCH_APPROVAL } from './protocol.js';
12
+ import { PeekDesk } from './peeks.js';
13
+ import { QuestionDesk } from './questions.js';
9
14
  /** 초대 유효 시간. 설계 원문의 24시간 */
10
15
  export const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
11
16
  /** 원격 구독 하나가 릴레이 소켓에 밀어 둘 수 있는 양 */
12
17
  export const MAX_REMOTE_BUFFERED_BYTES = 8 * 1024 * 1024;
13
18
  const WATCH_TIMEOUT_MS = 10_000;
19
+ /** 답이 없거나 다시 청하면 되는 거절을 받았을 때 다시 청하는 횟수와 간격 상한 */
20
+ const MAX_REWATCH_RETRIES = 6;
21
+ const MAX_REWATCH_DELAY_MS = 60_000;
14
22
  const WATCH_NONCE_TTL_MS = 10 * 60 * 1000;
15
23
  const MAX_SEEN_WATCH = 4096;
16
- export class PeerNetworkError extends Error {
24
+ const MAX_SEEN_MESSAGES = 8192;
25
+ const MAX_REFUSED_PER_SENDER = 256;
26
+ const MAX_REFUSED_SENDERS = 1024;
27
+ /** 첫 접속 승인을 기다리는 시한. 넘으면 보는 쪽에 approval_timeout으로 알린다 */
28
+ export const APPROVAL_TTL_MS = 10 * 60 * 1000;
29
+ /** 승인 대기를 알리는 시간. 이 동안은 긴 안내 문구를 붙이고 지나면 훅이 짧은 한 줄만 붙인다 */
30
+ export const APPROVAL_NOTICE_MS = 60 * 1000;
31
+ /** 끝난 보기를 `peer watches`에 남겨 두는 개수. 거절이나 kick으로 끝난 이유를 CLI에서 보게 한다 */
32
+ const MAX_CLOSED_WATCHES = 16;
33
+ /** 보는 쪽이 다시 청하지 않고 보기를 끝내는 거절 이유 */
34
+ const FINAL_REFUSALS = new Set([
35
+ 'not_found',
36
+ 'bad_proof',
37
+ 'kicked',
38
+ 'denied',
39
+ 'approval_timeout',
40
+ 'share_closed',
41
+ ]);
42
+ export { PeerNetworkError };
43
+ /** 주인 데몬이 시한 안에 watch에 답하지 않았다 */
44
+ export class WatchTimeout extends PeerNetworkError {
45
+ }
46
+ /** 주인 데몬이 watch를 거절했다. reason은 주인 데몬이 보낸 글자를 정리한 것이다 */
47
+ export class WatchRefused extends PeerNetworkError {
48
+ reason;
49
+ constructor(reason) {
50
+ super(refusalText(reason));
51
+ this.reason = reason;
52
+ }
53
+ }
54
+ /** 거절 이유를 사람이 읽는 문장으로. 이유 글자는 끝에 괄호로 남겨 스크립트가 찾게 한다 */
55
+ export function refusalText(reason) {
56
+ switch (reason) {
57
+ case 'denied':
58
+ return '주인이 거절했어요 (denied)';
59
+ case 'kicked':
60
+ return '주인이 이 공유에서 끊었어요 (kicked)';
61
+ case 'approval_timeout':
62
+ return '주인이 승인하지 않아 기다리다 끝났어요 (approval_timeout)';
63
+ default:
64
+ return `주인 데몬이 거절했어요: ${reason}`;
65
+ }
17
66
  }
18
67
  /**
19
68
  * 팀, 명단, 릴레이 통로, 원격 공유를 묶는다. 데몬이 하나 들고 있다.
@@ -36,12 +85,52 @@ export class PeerNetwork {
36
85
  announced = [];
37
86
  /** 최근 받은 watch 요청의 `기기|nonce`와 받은 시각. 릴레이가 옛 요청을 다시 보내면 거절한다 */
38
87
  seenWatch = new Map();
88
+ /** 받은 메시지의 `기기|id`. 릴레이가 같은 봉인을 다시 보내면 버린다 */
89
+ seenMessages = new Set();
90
+ /**
91
+ * 수신 한도로 거절한 메시지 id를 `공유|기기`마다 따로 기억한다. 창이 지난 뒤 릴레이가 같은
92
+ * 봉인을 다시 보내도 넣지 않는다. 공용 seenMessages에 넣으면 한 사람이 맞는 증명으로
93
+ * 쏟아부어 다른 팀원의 기억을 밀어낼 수 있어서 나눈다
94
+ */
95
+ refusedMessages = new Map();
96
+ sendLimiter = new RateLimiter();
97
+ /** 보는 쪽이 12건으로 먼저 막는다. 주인 쪽은 릴레이에 쌓였다 한꺼번에 온 것도 받게 넉넉하다 */
98
+ receiveLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
39
99
  maxBuffered;
100
+ approvalTtl;
101
+ approvalNotice;
102
+ /**
103
+ * 주인 쪽 통제 상태. 전부 공유가 열려 있는 동안만 메모리에 둔다. 공유는 데몬이 다시 뜨면
104
+ * 닫히므로 DB에 남길 필요가 없고 forgetShare가 공유를 닫을 때 같이 버린다.
105
+ * - pending: 첫 접속 승인을 기다리는 요청. 키는 `공유|기기`
106
+ * - approved: 공유마다 받아 준 기기
107
+ * - blocked: 공유마다 끊거나 거절한 기기. 공유가 열려 있는 동안 다시 못 붙고 메시지도 못 넣는다
108
+ */
109
+ pending = new Map();
110
+ approved = new Map();
111
+ /** 첫 접속 설정. 메시지마다 읽으므로 DB가 아니라 메모리에 두고 setFirstWatch로만 바꾼다 */
112
+ firstWatchMode;
113
+ /** 거절 답과 감사 행을 쓰는 양의 상한. 끊긴 사람이 새 id로 계속 보내도 디스크와 릴레이를 못 채운다 */
114
+ rejectLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
115
+ blocked = new Map();
116
+ closedWatches = [];
117
+ /** 질문과 답. 주인 쪽 열린 질문도 여기 있다 */
118
+ questions;
119
+ /** 요약 요청과 답. 주인 쪽 알림과 보는 쪽 대기 요청도 여기 있다 */
120
+ peeks;
40
121
  constructor(options) {
41
122
  this.options = options;
42
123
  this.team = options.state.getTeam();
43
124
  this.roster = options.state.getRoster();
44
125
  this.maxBuffered = options.maxBufferedBytes ?? MAX_REMOTE_BUFFERED_BYTES;
126
+ this.approvalTtl = options.approvalTtlMs ?? APPROVAL_TTL_MS;
127
+ this.approvalNotice = options.approvalNoticeMs ?? APPROVAL_NOTICE_MS;
128
+ this.questions = new QuestionDesk(this.questionHost());
129
+ this.peeks = new PeekDesk(this.peekHost(), {
130
+ noticeMs: options.peekNoticeMs,
131
+ timeoutMs: options.peekTimeoutMs,
132
+ });
133
+ this.firstWatchMode = options.state.getSettings().firstWatch ?? DEFAULT_FIRST_WATCH_APPROVAL;
45
134
  }
46
135
  get key() {
47
136
  return this.options.key;
@@ -52,6 +141,16 @@ export class PeerNetwork {
52
141
  this.connect();
53
142
  }
54
143
  async stop() {
144
+ for (const p of this.pending.values()) {
145
+ clearTimeout(p.timer);
146
+ clearTimeout(p.shorten);
147
+ }
148
+ this.pending.clear();
149
+ this.questions.stop();
150
+ this.peeks.stop();
151
+ for (const w of this.watches.values())
152
+ if (w.retry)
153
+ clearTimeout(w.retry);
55
154
  for (const s of [...this.subs.values()])
56
155
  this.endSub(s, 'daemon_stop', false);
57
156
  for (const w of [...this.watches.values()])
@@ -145,8 +244,15 @@ export class PeerNetwork {
145
244
  */
146
245
  async join(code, timeoutMs = 60_000) {
147
246
  const invite = decodeInvite(code);
148
- if (this.team && this.roster && memberOf(this.roster.roster, this.key.id)) {
149
- throw new PeerNetworkError(`이미 팀에 들어 있어요 (${this.team.team})`);
247
+ // 명단에서 빠지면 릴레이가 바로 끊어서 새 명단을 못 받는다. 그래서 들고 있는 명단에는 아직 내가
248
+ // 있다. 릴레이가 removed로 끊었으면 같은 팀, 같은 관리자의 재초대만 받는다. removed는 릴레이가
249
+ // 정하는 값이라 이걸로 다른 팀에 옮겨 가는 길까지 열지 않는다
250
+ const removed = this.transport?.state === 'removed';
251
+ const rejoin = removed && this.team?.team === invite.team && this.team.anchor === invite.admin;
252
+ if (this.team && this.roster && memberOf(this.roster.roster, this.key.id) && !rejoin) {
253
+ throw new PeerNetworkError(removed
254
+ ? `명단에서 빠진 팀(${this.team.team})의 관리자가 새로 만든 초대로만 다시 들어올 수 있어요`
255
+ : `이미 팀에 들어 있어요 (${this.team.team})`);
150
256
  }
151
257
  if (Date.now() >= invite.expiresAt)
152
258
  throw new PeerNetworkError('초대 코드가 만료됐어요');
@@ -235,6 +341,10 @@ export class PeerNetwork {
235
341
  this.transport?.publishRoster(signed);
236
342
  return { removed, epoch: signed.roster.epoch };
237
343
  }
344
+ /** 팀에서 쓰는 내 이름. 팀이 없으면 undefined */
345
+ get person() {
346
+ return this.team?.person;
347
+ }
238
348
  /** 내 공유의 링크. 팀이 없으면 undefined */
239
349
  linkFor(shareId, shareKey) {
240
350
  if (!this.roster)
@@ -248,16 +358,41 @@ export class PeerNetwork {
248
358
  }
249
359
  lookupWatch(id) {
250
360
  const w = this.watches.get(id);
251
- return w ? { stream: w.feed, token: w.token } : undefined;
361
+ if (!w)
362
+ return undefined;
363
+ return {
364
+ stream: w.feed,
365
+ token: w.token,
366
+ owner: w.feed.ownerPerson,
367
+ share: w.share,
368
+ answer: (question, text) => this.questions.answer(id, question, text).then((r) => ({ ok: true, question: r.question }), (err) => ({ ok: false, error: errMessage(err) })),
369
+ // 승인을 기다리는 동안 연 뷰어도 받아 준 뒤에 입력창이 있어야 한다. 페이지는 열 때 한 번
370
+ // 그려지므로 기다리는 중에는 입력창을 두고 보내기는 sendMessage가 막는다
371
+ ...(w.messages || w.feed.currentStatus === 'waiting'
372
+ ? {
373
+ send: (text) => this.sendMessage(id, text, 'human').then((r) => ({ ok: true, id: r.id }), (err) => ({ ok: false, error: errMessage(err) })),
374
+ }
375
+ : {}),
376
+ };
252
377
  }
378
+ /** 보고 있는 공유와 최근에 끝난 보기. 끝난 것은 closed에 이유가 있다 */
253
379
  listWatches() {
254
- return [...this.watches.values()].map((w) => ({
380
+ const live = [...this.watches.values()].map((w) => ({
255
381
  id: w.id,
256
382
  share: w.share,
257
383
  owner: w.feed.ownerPerson,
258
384
  status: w.feed.currentStatus,
259
385
  events: w.feed.eventCount,
260
386
  }));
387
+ const ended = this.closedWatches.map((c) => ({
388
+ id: c.id,
389
+ share: c.share,
390
+ owner: c.owner,
391
+ status: 'closed',
392
+ events: c.events,
393
+ closed: c.reason,
394
+ }));
395
+ return [...live, ...ended];
261
396
  }
262
397
  /** 링크로 원격 공유를 보기 시작한다. 주인 데몬이 받아 줄 때까지 기다린다 */
263
398
  async watch(link) {
@@ -288,23 +423,27 @@ export class PeerNetwork {
288
423
  shareKey: Buffer.from(parsed.shareKey, 'base64url'),
289
424
  owner,
290
425
  feed: new MirrorFeed(id, owner, ownerMember.person, parsed.shareId),
426
+ messages: false,
427
+ retries: 0,
291
428
  };
292
429
  this.watches.set(id, w);
430
+ let waiting;
293
431
  try {
294
- await this.requestWatch(w);
432
+ waiting = (await this.requestWatch(w)) === 'waiting';
295
433
  }
296
434
  catch (err) {
297
435
  this.watches.delete(id);
298
436
  w.feed.close('watch_failed');
299
437
  throw err;
300
438
  }
301
- this.options.state.audit('watch_open', null, JSON.stringify({ watch: id, share: w.share, owner: ownerMember.person }));
302
- return { id, share: w.share, owner: ownerMember.person, feed: w.feed, token: w.token };
439
+ this.options.state.audit('watch_open', null, JSON.stringify({ watch: id, share: w.share, owner: ownerMember.person, waiting }));
440
+ return { id, share: w.share, owner: ownerMember.person, feed: w.feed, token: w.token, waiting };
303
441
  }
304
442
  unwatch(id) {
305
443
  const w = this.watches.get(id);
306
444
  if (!w)
307
445
  return false;
446
+ // 승인을 기다리는 중이면 sub가 없다. 그때 주인에게 알리는 건 closeWatch가 watch_cancel로 한다
308
447
  if (w.sub)
309
448
  this.sendBox(w.owner, { t: 'unwatch', share: w.share, sub: w.sub });
310
449
  this.closeWatch(w, 'unwatch');
@@ -411,6 +550,17 @@ export class PeerNetwork {
411
550
  if (!memberOf(r, sub.watcher))
412
551
  this.endSub(sub, 'removed', false);
413
552
  }
553
+ for (const [key, p] of [...this.pending]) {
554
+ if (!memberOf(r, p.watcher))
555
+ this.dropApproval(key);
556
+ }
557
+ // 빠진 기기가 같은 키로 다시 초대돼 와도 묻지 않고 붙지 않게 승인은 지운다. 막은 목록은
558
+ // 공유가 닫힐 때까지 둔다. 지우면 kick한 기기가 다시 들어와 풀린다
559
+ for (const map of this.approved.values()) {
560
+ for (const machine of [...map.keys()])
561
+ if (!memberOf(r, machine))
562
+ map.delete(machine);
563
+ }
414
564
  for (const w of [...this.watches.values()]) {
415
565
  if (!memberOf(r, w.owner))
416
566
  this.closeWatch(w, 'owner_removed');
@@ -462,19 +612,291 @@ export class PeerNetwork {
462
612
  return this.onWatchRequest(from, msg);
463
613
  case 'watch_ok':
464
614
  case 'watch_err':
615
+ case 'watch_wait':
465
616
  return this.onWatchReply(from, msg);
466
617
  case 'watch_end':
467
618
  return this.onWatchEnd(from, msg);
619
+ case 'watch_cancel':
620
+ return this.onWatchCancel(from, msg);
468
621
  case 'unwatch': {
469
622
  const sub = this.subs.get(msg.sub);
470
623
  if (sub && sub.watcher === from)
471
624
  this.endSub(sub, 'unwatch', false);
472
625
  return;
473
626
  }
627
+ case 'msg':
628
+ return void this.onMessageRequest(from, msg).catch((err) => {
629
+ process.stderr.write(`[peer] message failed: ${errMessage(err)}\n`);
630
+ });
631
+ case 'msg_status':
632
+ return this.onMessageStatus(from, msg);
633
+ case 'ask':
634
+ case 'ask_close':
635
+ case 'answer':
636
+ case 'answer_status':
637
+ return this.questions.receive(from, msg);
638
+ case 'peek':
639
+ case 'peek_reply':
640
+ return this.peeks.receive(from, msg);
474
641
  default:
475
642
  return;
476
643
  }
477
644
  }
645
+ /**
646
+ * 보는 쪽: 원격 공유 주인 세션에 메시지를 보낸다. 주인 데몬이 오프라인이면 릴레이가 7일까지
647
+ * 들고 있는다. 상태는 보기의 MirrorFeed에 쌓여 뷰어로 간다.
648
+ */
649
+ async sendMessage(watchId, raw, origin = 'human') {
650
+ const w = this.watches.get(watchId);
651
+ if (!w)
652
+ throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${watchId}`);
653
+ if (w.feed.currentStatus === 'waiting')
654
+ throw new PeerNetworkError('주인 승인을 기다리는 중이라 아직 메시지를 못 보내요');
655
+ if (!w.messages)
656
+ throw new PeerNetworkError('이 공유는 주인이 메시지를 받지 않게 열었어요');
657
+ const clean = sanitizeMessageBody(raw);
658
+ if (!clean.ok)
659
+ throw new PeerNetworkError(clean.reason);
660
+ const member = this.roster ? memberOf(this.roster.roster, w.owner) : undefined;
661
+ const t = this.transport;
662
+ if (!member)
663
+ throw new PeerNetworkError('공유 주인이 명단에 없어요');
664
+ if (!t || t.state !== 'online')
665
+ throw new PeerNetworkError('릴레이에 안 붙어 있어요');
666
+ // 보내기 직전에 센다. 명단이나 연결 때문에 못 보낸 시도는 한도를 안 깎는다.
667
+ // 주인 쪽 수신 한도는 MESSAGE_RECEIVE_LIMIT로 따로 정한다
668
+ if (!this.sendLimiter.take(`${w.share}|${w.owner}`)) {
669
+ throw new PeerNetworkError('메시지를 너무 자주 보냈어요. 10분에 12건까지예요');
670
+ }
671
+ const id = newMessageId();
672
+ const body = {
673
+ t: 'msg',
674
+ share: w.share,
675
+ id,
676
+ body: clean.text,
677
+ origin,
678
+ proof: hmac(w.shareKey, messageProofText(w.share, this.key.id, id, clean.text)),
679
+ };
680
+ // 릴레이 답보다 주인 데몬의 상태가 먼저 올 수 있어서 레코드를 미리 둔다. 알리는 건 답을 받은 뒤다
681
+ w.feed.reserveMessage(id, clean.text, Date.now());
682
+ const box = sealBox(this.key, w.owner, member.boxKey, JSON.stringify(body));
683
+ let result;
684
+ try {
685
+ result = await t.send(w.owner, Buffer.from(JSON.stringify(box)), { queue: true });
686
+ }
687
+ catch (err) {
688
+ const reason = err instanceof RelaySendError ? err.reason : errMessage(err);
689
+ w.feed.setMessage(id, { status: 'rejected', reason });
690
+ throw new PeerNetworkError(`메시지를 못 보냈어요: ${errMessage(err)}`);
691
+ }
692
+ w.feed.setMessage(id, { status: result === 'queued' ? 'queued' : 'sent' });
693
+ return { id, status: w.feed.getMessage(id)?.status ?? 'sent' };
694
+ }
695
+ listMessages(watchId) {
696
+ const w = this.watches.get(watchId);
697
+ if (!w)
698
+ throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${watchId}`);
699
+ return w.feed.messages;
700
+ }
701
+ /** 주인 쪽: 받은 메시지를 검사하고 주인 세션 인박스에 쓴다 */
702
+ async onMessageRequest(from, msg) {
703
+ // 꼴이 틀린 공유 id는 감사 로그와 CLI 출력으로 흘러가지 않게 답 없이 버린다
704
+ if (!isShareId(msg.share) || !isMessageId(msg.id))
705
+ return;
706
+ // 릴레이가 같은 봉인을 다시 보내면 한 번만 넣는다. 기억은 검사를 다 통과한 뒤에 한다.
707
+ // 앞에서 기억하면 증명 없는 가짜 id로 칸을 밀어내 옛 봉인을 다시 넣게 할 수 있다
708
+ const dedupe = `${from}|${msg.id}`;
709
+ const sender = `${msg.share}|${from}`;
710
+ if (this.seenMessages.has(dedupe) || this.refusedMessages.get(sender)?.has(msg.id))
711
+ return;
712
+ const status = (s, reason) => {
713
+ this.sendBox(from, {
714
+ t: 'msg_status',
715
+ share: msg.share,
716
+ id: msg.id,
717
+ status: s,
718
+ ...(reason ? { reason } : {}),
719
+ });
720
+ };
721
+ const person = this.personOf(from);
722
+ const reject = (reason) => {
723
+ // 거절은 증명 없이도 탈 수 있는 길이 있어서 보낸 기기마다 상한을 둔다. 넘으면 답도 기록도 없이 버린다
724
+ if (!this.rejectLimiter.take(from))
725
+ return;
726
+ status('rejected', reason);
727
+ this.options.state.audit('message_reject', msg.share, JSON.stringify({ id: msg.id, person, reason }));
728
+ };
729
+ const owned = this.options.lookupShare(msg.share);
730
+ if (!owned || owned.stream.isClosed)
731
+ return reject('share_closed');
732
+ if (typeof msg.body !== 'string' || typeof msg.proof !== 'string')
733
+ return reject('bad_frame');
734
+ const expected = hmac(Buffer.from(owned.shareKey, 'base64url'), messageProofText(msg.share, from, msg.id, msg.body));
735
+ if (!safeEqual(msg.proof, expected))
736
+ return reject('bad_proof');
737
+ // 메시지는 구독이 아니라 링크 증명으로 받아서 구독을 끊어도 링크는 남는다. 끊거나 거절한 사람과
738
+ // 아직 안 받아 준 사람은 여기서 막는다. 릴레이가 들고 있다 나중에 넘긴 봉인도 같은 규칙을 탄다
739
+ const refusal = this.messageRefusal(msg.share, from);
740
+ if (refusal) {
741
+ this.rememberRefused(sender, msg.id);
742
+ return reject(refusal);
743
+ }
744
+ const clean = sanitizeMessageBody(msg.body);
745
+ if (!clean.ok)
746
+ return reject(clean.reason);
747
+ if (!this.receiveLimiter.take(sender)) {
748
+ this.rememberRefused(sender, msg.id);
749
+ return reject('rate_limited');
750
+ }
751
+ const deliver = owned.deliver;
752
+ if (!deliver)
753
+ return reject('messages_off');
754
+ // deliver의 첫 await 전에 기억해야 동시에 온 같은 봉인이 둘 다 들어가지 않는다
755
+ this.seenMessages.add(dedupe);
756
+ if (this.seenMessages.size > MAX_SEEN_MESSAGES) {
757
+ const oldest = this.seenMessages.values().next().value;
758
+ if (oldest !== undefined)
759
+ this.seenMessages.delete(oldest);
760
+ }
761
+ const origin = msg.origin === 'agent' ? 'agent' : 'human';
762
+ this.options.state.audit('message_receive', msg.share, JSON.stringify({ id: msg.id, person, origin, bytes: Buffer.byteLength(clean.text) }));
763
+ status('arrived');
764
+ const inbound = {
765
+ kind: 'message',
766
+ id: msg.id,
767
+ share: msg.share,
768
+ from,
769
+ person,
770
+ origin,
771
+ body: clean.text,
772
+ };
773
+ const result = await this.insertInbound(deliver, inbound, {
774
+ inserted: (maybeWritten) => {
775
+ status('inserted');
776
+ this.options.state.audit('message_insert', msg.share, JSON.stringify({ id: msg.id, person, ...(maybeWritten ? { maybeWritten: true } : {}) }));
777
+ },
778
+ delivered: () => {
779
+ status('delivered');
780
+ this.options.state.audit('message_deliver', msg.share, JSON.stringify({ id: msg.id, person }));
781
+ },
782
+ });
783
+ if (!result.ok && !result.maybeWritten)
784
+ reject(result.reason);
785
+ }
786
+ /**
787
+ * 검사를 마친 메시지나 답을 주인 세션 인박스에 넣는다. 메시지와 답이 같이 쓴다. 들어갔는지 지켜보는
788
+ * 일은 deliver가 쓰기 전에 걸어 둔다. 그래서 쓰기가 끝나기 전에 보일 수 있고 그때는 inserted를 먼저
789
+ * 부른 뒤 delivered를 부른다. 소켓에 붙은 뒤 실패했으면 줄이 들어갔을 수 있다. 거절로 알리면 보낸
790
+ * 사람이 다시 보내 겹칠 수 있어서 inserted로 보고 대화 기록에 보이면 delivered로 올린다. deliver가
791
+ * 던지면 deliver_failed로 돌려준다. 넣지 못했을 때 무엇을 할지는 부르는 쪽이 정한다.
792
+ */
793
+ async insertInbound(deliver, inbound, on) {
794
+ let inserted = false;
795
+ let seenEarly = false;
796
+ let result;
797
+ try {
798
+ result = await deliver(inbound, () => {
799
+ if (inserted)
800
+ on.delivered();
801
+ else
802
+ seenEarly = true;
803
+ });
804
+ }
805
+ catch (err) {
806
+ process.stderr.write(`[peer] ${inbound.kind} deliver failed: ${errMessage(err)}\n`);
807
+ return { ok: false, reason: 'deliver_failed' };
808
+ }
809
+ if (!result.ok && !result.maybeWritten)
810
+ return result;
811
+ on.inserted(!result.ok);
812
+ inserted = true;
813
+ if (seenEarly)
814
+ on.delivered();
815
+ return result;
816
+ }
817
+ rememberRefused(sender, id) {
818
+ let ids = this.refusedMessages.get(sender);
819
+ if (!ids) {
820
+ ids = new Set();
821
+ this.refusedMessages.set(sender, ids);
822
+ if (this.refusedMessages.size > MAX_REFUSED_SENDERS) {
823
+ const oldest = this.refusedMessages.keys().next().value;
824
+ if (oldest !== undefined)
825
+ this.refusedMessages.delete(oldest);
826
+ }
827
+ }
828
+ ids.add(id);
829
+ if (ids.size > MAX_REFUSED_PER_SENDER) {
830
+ const oldest = ids.values().next().value;
831
+ if (oldest !== undefined)
832
+ ids.delete(oldest);
833
+ }
834
+ }
835
+ /** 보는 쪽: 주인 데몬이 알려 온 상태를 반영한다 */
836
+ onMessageStatus(from, msg) {
837
+ if (!isMessageId(msg.id) || !isMessageStatus(msg.status))
838
+ return;
839
+ for (const w of this.watches.values()) {
840
+ if (w.owner !== from || w.share !== msg.share || !w.feed.getMessage(msg.id))
841
+ continue;
842
+ // 주인 데몬이 보낸 글자라 CLI 터미널에 찍히기 전에 제어 문자를 지운다
843
+ const reason = plainLabel(msg.reason);
844
+ w.feed.setMessage(msg.id, { status: msg.status, ...(reason ? { reason } : {}) });
845
+ }
846
+ }
847
+ /** 주인 쪽: 주인 세션 모델의 질문을 보낸다. 자세한 건 QuestionDesk.ask */
848
+ ask(shares, raw, to, ttlMs) {
849
+ return this.questions.ask(shares, raw, to, ttlMs);
850
+ }
851
+ questionHost() {
852
+ return {
853
+ state: this.options.state,
854
+ key: this.options.key,
855
+ sendBox: (to, msg) => this.sendBox(to, msg),
856
+ subs: () => this.subs.values(),
857
+ watches: () => this.watches.values(),
858
+ watch: (id) => this.watches.get(id),
859
+ personOf: (machine) => this.personOf(machine),
860
+ machinesOf: (person) => this.machinesOf(person),
861
+ refusal: (share, from) => this.messageRefusal(share, from),
862
+ takeReject: (from) => this.rejectLimiter.take(from),
863
+ lookupShare: (id) => this.options.lookupShare(id),
864
+ insertInbound: (deliver, inbound, on) => this.insertInbound(deliver, inbound, on),
865
+ prepareSend: (owner, msg) => this.prepareSealed(owner, msg, true),
866
+ };
867
+ }
868
+ /** 보는 쪽: 보고 있는 공유의 요약을 주인 데몬에 청한다. 자세한 건 PeekDesk.peek */
869
+ peek(target, since) {
870
+ return this.peeks.peek(target, since);
871
+ }
872
+ peekHost() {
873
+ return {
874
+ state: this.options.state,
875
+ key: this.options.key,
876
+ sendBox: (to, msg) => this.sendBox(to, msg),
877
+ watches: () => this.watches.values(),
878
+ prepareDirect: (owner, msg) => this.prepareSealed(owner, msg, false),
879
+ personOf: (machine) => this.personOf(machine),
880
+ refusal: (share, from) => this.messageRefusal(share, from),
881
+ takeReject: (from) => this.rejectLimiter.take(from),
882
+ lookupShare: (id) => this.options.lookupShare(id),
883
+ changed: () => this.changed(),
884
+ };
885
+ }
886
+ /**
887
+ * 그 기기에 보낼 봉인을 지금 만든다. 명단에 없거나 릴레이에 안 붙어 있으면 바로 던진다. 돌려준 함수가
888
+ * 보낸다. queue면 받는 기기가 오프라인일 때 릴레이가 들고 있는다
889
+ */
890
+ prepareSealed(to, msg, queue) {
891
+ const member = this.roster ? memberOf(this.roster.roster, to) : undefined;
892
+ const t = this.transport;
893
+ if (!member)
894
+ throw new PeerNetworkError('공유 주인이 명단에 없어요');
895
+ if (!t || t.state !== 'online')
896
+ throw new PeerNetworkError('릴레이에 안 붙어 있어요');
897
+ const sealed = sealBox(this.key, to, member.boxKey, JSON.stringify(msg));
898
+ return () => t.send(to, Buffer.from(JSON.stringify(sealed)), queue ? { queue: true } : {});
899
+ }
478
900
  onJoinRequest(from, data) {
479
901
  try {
480
902
  this.handleJoin(from, data);
@@ -550,20 +972,40 @@ export class PeerNetwork {
550
972
  }
551
973
  if (!this.rememberWatchNonce(from, msg.nonce))
552
974
  return reply('replay');
975
+ const block = this.blocked.get(msg.share)?.get(from);
976
+ if (block)
977
+ return reply(block);
978
+ const after = Number.isSafeInteger(msg.after) && msg.after > 0 ? msg.after : 0;
979
+ // 승인은 salt와 세션 키를 정하기 전에 본다. 키를 보낸 뒤에 막으면 보는 쪽이 이미 대화를 열어 읽을 수 있다
980
+ if (!this.approved.get(msg.share)?.has(from)) {
981
+ if (this.firstWatchMode === 'ask') {
982
+ return this.holdForApproval(from, msg.share, msg.nonce, after);
983
+ }
984
+ // 기다리는 사이 설정을 team으로 바꿨으면 남은 대기를 치운다. 안 치우면 목록과 훅에 계속 보인다
985
+ this.dropApproval(`${msg.share}|${from}`);
986
+ this.grant(msg.share, from, 'team');
987
+ }
988
+ this.startSub(from, msg.share, msg.nonce, after, owned);
989
+ }
990
+ /** 받아 준 사람의 구독을 연다. 공유 세션 키는 여기서 처음 정한다 */
991
+ startSub(from, share, nonce, after, owned) {
553
992
  // 같은 사람이 다시 청하면 앞의 구독을 바꾼다. 보는 쪽이 모르는 교체일 수 있어서 알린다
554
993
  for (const s of [...this.subs.values()]) {
555
- if (s.watcher === from && s.share === msg.share)
994
+ if (s.watcher === from && s.share === share)
556
995
  this.endSub(s, 'replaced', true);
557
996
  }
558
- if (owned.stream.viewerCount >= MAX_VIEWERS_PER_SHARE)
559
- return reply('too_many');
997
+ if (owned.stream.viewerCount >= MAX_VIEWERS_PER_SHARE) {
998
+ this.sendBox(from, { t: 'watch_err', share, nonce, reason: 'too_many' });
999
+ return;
1000
+ }
560
1001
  const sub = randomBytes(9).toString('base64url');
561
1002
  const salt = randomBytes(32);
1003
+ const shareKey = Buffer.from(owned.shareKey, 'base64url');
562
1004
  const remote = {
563
1005
  sub,
564
- share: msg.share,
1006
+ share,
565
1007
  watcher: from,
566
- key: streamKey(shareKey, salt, msg.share, from, sub),
1008
+ key: streamKey(shareKey, salt, share, from, sub),
567
1009
  stream: owned.stream,
568
1010
  unsubscribe: () => { },
569
1011
  ended: false,
@@ -571,14 +1013,281 @@ export class PeerNetwork {
571
1013
  this.subs.set(sub, remote);
572
1014
  this.sendBox(from, {
573
1015
  t: 'watch_ok',
574
- share: msg.share,
575
- nonce: msg.nonce,
1016
+ share,
1017
+ nonce,
576
1018
  sub,
577
1019
  salt: salt.toString('base64url'),
1020
+ messages: owned.deliver !== undefined,
578
1021
  });
579
- const person = this.roster ? memberOf(this.roster.roster, from)?.person : undefined;
580
- this.options.state.audit('remote_watch', msg.share, JSON.stringify({ person: person ?? null, sub }));
581
- this.pump(remote, Number.isSafeInteger(msg.after) && msg.after > 0 ? msg.after : 0);
1022
+ this.options.state.audit('remote_watch', share, JSON.stringify({ person: this.personOf(from), sub }));
1023
+ this.changed();
1024
+ this.pump(remote, after);
1025
+ }
1026
+ /**
1027
+ * 첫 접속을 주인이 받아 줄 때까지 붙잡아 둔다. 보는 쪽에는 watch_wait로 알린다. 같은 사람이
1028
+ * 다시 청하면(주인이나 보는 쪽이 릴레이에 다시 붙은 경우) 시한은 그대로 두고 nonce만 바꾼다.
1029
+ */
1030
+ holdForApproval(from, share, nonce, after) {
1031
+ const key = `${share}|${from}`;
1032
+ let p = this.pending.get(key);
1033
+ if (p) {
1034
+ p.nonce = nonce;
1035
+ p.after = after;
1036
+ }
1037
+ else {
1038
+ let waiting = 0;
1039
+ for (const q of this.pending.values())
1040
+ if (q.share === share)
1041
+ waiting++;
1042
+ if (waiting >= MAX_VIEWERS_PER_SHARE) {
1043
+ this.sendBox(from, { t: 'watch_err', share, nonce, reason: 'too_many' });
1044
+ return;
1045
+ }
1046
+ const at = Date.now();
1047
+ const timer = setTimeout(() => this.expireApproval(key), this.approvalTtl);
1048
+ timer.unref();
1049
+ // 긴 안내 문구는 처음 한동안만 붙이고 그 뒤로는 짧게 줄인다. 시각 비교는 타이머가 몇 ms 일찍
1050
+ // 울리면 어긋나서 울린 사실을 표시로 남기고 훅 파일을 다시 쓴다
1051
+ const shorten = setTimeout(() => {
1052
+ const q = this.pending.get(key);
1053
+ if (!q)
1054
+ return;
1055
+ q.brief = true;
1056
+ this.changed();
1057
+ }, this.approvalNotice);
1058
+ shorten.unref();
1059
+ p = {
1060
+ share,
1061
+ watcher: from,
1062
+ nonce,
1063
+ after,
1064
+ at,
1065
+ expiresAt: at + this.approvalTtl,
1066
+ timer,
1067
+ shorten,
1068
+ brief: false,
1069
+ };
1070
+ this.pending.set(key, p);
1071
+ this.options.state.audit('approval_request', share, JSON.stringify({ person: this.personOf(from) }));
1072
+ this.changed();
1073
+ }
1074
+ this.sendBox(from, { t: 'watch_wait', share, nonce, expiresAt: p.expiresAt });
1075
+ }
1076
+ /**
1077
+ * 승인 대기 하나를 치운다. 타이머 둘을 풀고 훅 파일을 다시 쓰게 한다. reason을 주면 보는 쪽에
1078
+ * 그 이유로 거절을 알린다. 치운 대기를 돌려준다.
1079
+ */
1080
+ dropApproval(key, reason) {
1081
+ const p = this.pending.get(key);
1082
+ if (!p)
1083
+ return undefined;
1084
+ clearTimeout(p.timer);
1085
+ clearTimeout(p.shorten);
1086
+ this.pending.delete(key);
1087
+ if (reason) {
1088
+ this.sendBox(p.watcher, { t: 'watch_err', share: p.share, nonce: p.nonce, reason });
1089
+ }
1090
+ this.changed();
1091
+ return p;
1092
+ }
1093
+ expireApproval(key) {
1094
+ const p = this.dropApproval(key, 'approval_timeout');
1095
+ if (!p)
1096
+ return;
1097
+ try {
1098
+ this.options.state.audit('approval_expire', p.share, JSON.stringify({ person: this.personOf(p.watcher) }));
1099
+ }
1100
+ catch {
1101
+ // 데몬이 멈추는 중이면 DB가 이미 닫혔을 수 있다
1102
+ }
1103
+ }
1104
+ /** 보는 쪽이 승인을 기다리다 그만뒀다. 그 요청의 nonce가 맞을 때만 치운다 */
1105
+ onWatchCancel(from, msg) {
1106
+ if (typeof msg.share !== 'string' || typeof msg.nonce !== 'string')
1107
+ return;
1108
+ const key = `${msg.share}|${from}`;
1109
+ // 릴레이가 옛 취소를 다시 보내도 그 뒤에 새로 청한 대기는 안 지워진다
1110
+ if (this.pending.get(key)?.nonce !== msg.nonce)
1111
+ return;
1112
+ const p = this.dropApproval(key);
1113
+ this.options.state.audit('approval_cancel', p.share, JSON.stringify({ person: this.personOf(from) }));
1114
+ }
1115
+ grant(share, machine, by) {
1116
+ let map = this.approved.get(share);
1117
+ if (!map) {
1118
+ map = new Map();
1119
+ this.approved.set(share, map);
1120
+ }
1121
+ map.set(machine, by);
1122
+ this.options.state.audit('approval_grant', share, JSON.stringify({ person: this.personOf(machine), by }));
1123
+ }
1124
+ block(share, machine, reason) {
1125
+ let map = this.blocked.get(share);
1126
+ if (!map) {
1127
+ map = new Map();
1128
+ this.blocked.set(share, map);
1129
+ }
1130
+ map.set(machine, reason);
1131
+ this.approved.get(share)?.delete(machine);
1132
+ }
1133
+ /** 이 공유로 이 기기가 보낸 메시지를 막을 이유. 받을 수 있으면 undefined */
1134
+ messageRefusal(share, from) {
1135
+ const block = this.blocked.get(share)?.get(from);
1136
+ if (block)
1137
+ return block;
1138
+ if (this.firstWatchMode === 'ask' && !this.approved.get(share)?.has(from)) {
1139
+ return 'not_approved';
1140
+ }
1141
+ return undefined;
1142
+ }
1143
+ /**
1144
+ * 첫 접속 설정을 바꾼다. ask로 조이면 팀 설정으로 들어온 사람의 승인을 거두고 구독을 끝낸다.
1145
+ * 보는 쪽은 approval_revoked를 받고 다시 청해 승인 대기에 오른다. 거둔 구독 수를 돌려준다.
1146
+ */
1147
+ setFirstWatch(mode) {
1148
+ this.firstWatchMode = mode;
1149
+ if (mode !== 'ask')
1150
+ return 0;
1151
+ let revoked = 0;
1152
+ for (const [share, map] of this.approved) {
1153
+ const machines = [...map].filter(([, by]) => by === 'team').map(([m]) => m);
1154
+ if (machines.length === 0)
1155
+ continue;
1156
+ for (const m of machines)
1157
+ map.delete(m);
1158
+ for (const sub of [...this.subs.values()]) {
1159
+ if (sub.share !== share || !machines.includes(sub.watcher))
1160
+ continue;
1161
+ this.endSub(sub, 'approval_revoked', true);
1162
+ revoked++;
1163
+ }
1164
+ this.options.state.audit('approval_revoke', share, JSON.stringify({ persons: unique(machines.map((m) => this.personOf(m))) }));
1165
+ }
1166
+ this.changed();
1167
+ return revoked;
1168
+ }
1169
+ /** 명단의 이름을 한 줄 라벨로. 감사 로그와 CLI, 훅 출력으로 나간다 */
1170
+ personOf(machine) {
1171
+ const person = this.roster ? memberOf(this.roster.roster, machine)?.person : undefined;
1172
+ return plainLabel(person, 32) ?? '?';
1173
+ }
1174
+ machinesOf(person) {
1175
+ if (!this.roster)
1176
+ throw new PeerNetworkError('아직 팀에 안 들었어요');
1177
+ const keys = this.roster.roster.members
1178
+ .filter((m) => m.person === person)
1179
+ .map((m) => m.machineKey);
1180
+ if (keys.length === 0)
1181
+ throw new PeerNetworkError(`명단에 없는 이름이에요: ${person}`);
1182
+ return keys;
1183
+ }
1184
+ changed() {
1185
+ try {
1186
+ this.options.onControlChange?.();
1187
+ }
1188
+ catch (err) {
1189
+ process.stderr.write(`[peer] control change failed: ${errMessage(err)}\n`);
1190
+ }
1191
+ }
1192
+ /** 주인 쪽: 공유 하나를 누가 보고 누가 기다리고 누가 막혔는지 */
1193
+ shareControl(share) {
1194
+ const watchers = new Set();
1195
+ for (const s of this.subs.values()) {
1196
+ if (s.share === share)
1197
+ watchers.add(this.personOf(s.watcher));
1198
+ }
1199
+ const pending = [];
1200
+ for (const p of this.pending.values()) {
1201
+ if (p.share === share) {
1202
+ pending.push({
1203
+ person: this.personOf(p.watcher),
1204
+ at: p.at,
1205
+ expiresAt: p.expiresAt,
1206
+ brief: p.brief,
1207
+ });
1208
+ }
1209
+ }
1210
+ const blocked = [...(this.blocked.get(share) ?? new Map())].map(([machine, reason]) => ({ person: this.personOf(machine), reason }));
1211
+ return { watchers: [...watchers], pending, blocked, peeks: this.peeks.notices(share) };
1212
+ }
1213
+ /** 주인 쪽: 승인을 기다리는 그 사람의 요청을 받아 준다. 받아 준 기기는 공유가 열린 동안 기억한다 */
1214
+ approve(person, shares) {
1215
+ const hits = this.pendingOf(person, shares);
1216
+ for (const p of hits) {
1217
+ this.dropApproval(`${p.share}|${p.watcher}`);
1218
+ this.grant(p.share, p.watcher, 'owner');
1219
+ const owned = this.options.lookupShare(p.share);
1220
+ if (owned && !owned.stream.isClosed) {
1221
+ this.startSub(p.watcher, p.share, p.nonce, p.after, owned);
1222
+ }
1223
+ else {
1224
+ this.sendBox(p.watcher, {
1225
+ t: 'watch_err',
1226
+ share: p.share,
1227
+ nonce: p.nonce,
1228
+ reason: 'not_found',
1229
+ });
1230
+ }
1231
+ }
1232
+ return { person, shares: unique(hits.map((p) => p.share)), count: hits.length };
1233
+ }
1234
+ /** 주인 쪽: 승인을 기다리는 그 사람의 요청을 거절한다. 그 공유가 열린 동안 다시 못 붙는다 */
1235
+ deny(person, shares) {
1236
+ const hits = this.pendingOf(person, shares);
1237
+ for (const p of hits) {
1238
+ this.block(p.share, p.watcher, 'denied');
1239
+ this.dropApproval(`${p.share}|${p.watcher}`, 'denied');
1240
+ this.options.state.audit('approval_deny', p.share, JSON.stringify({ person }));
1241
+ }
1242
+ return { person, shares: unique(hits.map((p) => p.share)), count: hits.length };
1243
+ }
1244
+ pendingOf(person, shares) {
1245
+ const machines = new Set(this.machinesOf(person));
1246
+ const hits = [...this.pending.values()].filter((p) => machines.has(p.watcher) && shares.includes(p.share));
1247
+ if (hits.length === 0)
1248
+ throw new PeerNetworkError(`승인을 기다리는 사람이 아니에요: ${person}`);
1249
+ return hits;
1250
+ }
1251
+ /**
1252
+ * 주인 쪽: 그 사람의 기기 전부를 공유에서 끊는다. 구독을 끝내고 그 구독 몫의 세션 키를 메모리에서
1253
+ * 지운다. 지금 안 보고 있어도 막아 둔다. 공유가 열린 동안 다시 붙지 못하고 메시지도 못 넣는다.
1254
+ */
1255
+ kick(person, shares) {
1256
+ const machines = this.machinesOf(person);
1257
+ if (machines.includes(this.key.id))
1258
+ throw new PeerNetworkError('자기 자신은 끊을 수 없어요');
1259
+ let count = 0;
1260
+ for (const share of shares) {
1261
+ let ended = 0;
1262
+ for (const machine of machines) {
1263
+ this.block(share, machine, 'kicked');
1264
+ this.dropApproval(`${share}|${machine}`, 'kicked');
1265
+ }
1266
+ for (const sub of [...this.subs.values()]) {
1267
+ if (sub.share !== share || !machines.includes(sub.watcher))
1268
+ continue;
1269
+ this.endSub(sub, 'kicked', true);
1270
+ ended++;
1271
+ }
1272
+ this.options.state.audit('kick', share, JSON.stringify({ person, ended }));
1273
+ count += ended;
1274
+ }
1275
+ this.changed();
1276
+ return { person, shares, count };
1277
+ }
1278
+ /**
1279
+ * 주인 쪽: 공유가 닫혔다. 승인을 기다리던 사람에게 알리고 그 공유의 승인과 막은 목록을 버린다.
1280
+ * 붙어 있던 구독은 공유 스트림의 closed 프레임으로 끝난다.
1281
+ */
1282
+ forgetShare(share) {
1283
+ for (const [key, p] of [...this.pending]) {
1284
+ if (p.share === share)
1285
+ this.dropApproval(key, 'share_closed');
1286
+ }
1287
+ this.approved.delete(share);
1288
+ this.blocked.delete(share);
1289
+ this.peeks.forget(share);
1290
+ this.changed();
582
1291
  }
583
1292
  /**
584
1293
  * 처음 보는 nonce면 기억하고 true. 공유는 데몬이 다시 뜨면 닫히므로 메모리에만 둔다.
@@ -653,6 +1362,10 @@ export class PeerNetwork {
653
1362
  this.endSub(remote, 'share_closed', false);
654
1363
  }
655
1364
  post(remote, frames) {
1365
+ // 끊긴 구독은 키를 지웠다. 지금 부르는 쪽(pump의 step, onLocalFrame)이 ended를 먼저 보지만
1366
+ // 호출부가 늘어도 지운 키로 봉인한 프레임이 나가지 않게 여기서도 막는다
1367
+ if (remote.ended)
1368
+ return;
656
1369
  const env = {
657
1370
  k: 's',
658
1371
  share: remote.share,
@@ -667,6 +1380,9 @@ export class PeerNetwork {
667
1380
  remote.ended = true;
668
1381
  remote.unsubscribe();
669
1382
  this.subs.delete(remote.sub);
1383
+ // 이 구독 몫의 세션 키를 메모리에서 지운다. 새 프레임은 ended를 보는 앞 경로가 거르고 post가 한 번 더 막는다
1384
+ remote.key.fill(0);
1385
+ this.changed();
670
1386
  if (notify)
671
1387
  this.sendBox(remote.watcher, {
672
1388
  t: 'watch_end',
@@ -682,19 +1398,24 @@ export class PeerNetwork {
682
1398
  }
683
1399
  }
684
1400
  // ── 보는 쪽 ──────────────────────────────────────────────
1401
+ /**
1402
+ * 주인 데몬에 보기를 청한다. watch_ok면 'ok', 주인 승인을 기다리면 'waiting'으로 끝난다.
1403
+ * 기다리는 동안 pending.nonce는 남겨 두고 나중에 오는 watch_ok나 watch_err를 그 nonce로 맞춘다.
1404
+ */
685
1405
  requestWatch(w) {
686
1406
  return new Promise((resolve, reject) => {
687
- if (w.pending) {
688
- clearTimeout(w.pending.timer);
689
- w.pending.reject(new PeerNetworkError('다시 청해서 앞 요청을 버렸어요'));
690
- }
1407
+ this.dropPending(w, '다시 청해서 앞 요청을 버렸어요');
1408
+ if (w.retry)
1409
+ clearTimeout(w.retry);
1410
+ w.retry = undefined;
691
1411
  const nonce = randomBytes(16).toString('base64url');
692
1412
  const timer = setTimeout(() => {
693
- w.pending = undefined;
694
- reject(new PeerNetworkError('주인 데몬이 답하지 않았어요'));
1413
+ if (w.pending?.nonce === nonce)
1414
+ w.pending = undefined;
1415
+ reject(new WatchTimeout('주인 데몬이 답하지 않았어요'));
695
1416
  }, WATCH_TIMEOUT_MS);
696
1417
  timer.unref();
697
- w.pending = { nonce, resolve, reject, timer };
1418
+ w.pending = { nonce, settle: { resolve, reject, timer } };
698
1419
  w.sub = undefined;
699
1420
  w.key = undefined;
700
1421
  const sent = this.sendBox(w.owner, {
@@ -711,41 +1432,114 @@ export class PeerNetwork {
711
1432
  }
712
1433
  });
713
1434
  }
1435
+ dropPending(w, why) {
1436
+ const settle = w.pending?.settle;
1437
+ w.pending = undefined;
1438
+ if (!settle)
1439
+ return;
1440
+ clearTimeout(settle.timer);
1441
+ settle.reject(new PeerNetworkError(why));
1442
+ }
714
1443
  rewatch(w) {
715
- this.requestWatch(w).catch((err) => {
716
- if (err instanceof PeerNetworkError && /not_found|bad_proof/.test(err.message)) {
717
- this.closeWatch(w, 'share_closed');
718
- }
719
- else {
720
- w.feed.markOffline();
721
- }
1444
+ this.requestWatch(w)
1445
+ .then((r) => {
1446
+ if (r === 'waiting')
1447
+ w.feed.markWaiting();
1448
+ })
1449
+ .catch((err) => {
1450
+ if (err instanceof WatchRefused)
1451
+ return this.onRefused(w, err.reason);
1452
+ w.feed.markOffline();
1453
+ // 앞 요청을 버려서 실패한 건 새 요청이 이미 나갔으니 다시 걸지 않는다
1454
+ if (err instanceof WatchTimeout)
1455
+ this.scheduleRewatch(w);
722
1456
  });
723
1457
  }
1458
+ /**
1459
+ * 조금 뒤에 다시 청한다. 주인 데몬이 답하지 않았거나 too_many처럼 다시 청하면 되는 거절일 때다.
1460
+ * 이게 없으면 다음 presence가 올 때까지 보기가 offline에 멈춘다. 간격은 두 배씩 늘리고 횟수는
1461
+ * 구독이 열릴 때만 0으로 돌아간다. 상한에 닿으면 멈추고 주인이 릴레이에 다시 붙을 때 다시 청한다.
1462
+ */
1463
+ scheduleRewatch(w) {
1464
+ if (w.retry || w.retries >= MAX_REWATCH_RETRIES || !this.watches.has(w.id))
1465
+ return;
1466
+ const delay = Math.min(MAX_REWATCH_DELAY_MS, 1000 * 2 ** w.retries);
1467
+ w.retries++;
1468
+ w.retry = setTimeout(() => {
1469
+ w.retry = undefined;
1470
+ if (this.watches.has(w.id) && !w.pending)
1471
+ this.rewatch(w);
1472
+ }, delay);
1473
+ w.retry.unref();
1474
+ }
1475
+ /** 주인 데몬이 보기를 거절했다. 다시 청해도 안 되는 이유면 보기를 끝낸다 */
1476
+ onRefused(w, reason) {
1477
+ if (!FINAL_REFUSALS.has(reason)) {
1478
+ w.feed.markOffline();
1479
+ this.scheduleRewatch(w);
1480
+ return;
1481
+ }
1482
+ const closeAs = reason === 'not_found' || reason === 'bad_proof' ? 'share_closed' : reason;
1483
+ this.closeWatch(w, closeAs);
1484
+ }
724
1485
  onWatchReply(from, msg) {
725
1486
  const w = [...this.watches.values()].find((x) => x.owner === from && x.share === msg.share && x.pending?.nonce === msg.nonce);
726
- if (!w || !w.pending)
1487
+ if (!w || !w.pending) {
1488
+ // 기다리다 그만둔 뒤 취소와 승인이 엇갈리면 맞는 보기가 없는 watch_ok가 온다. 주인 쪽에
1489
+ // 구독이 열린 채로 남지 않게 그 구독을 끊어 달라고 돌려보낸다
1490
+ if (msg.t === 'watch_ok' && typeof msg.sub === 'string' && isShareId(msg.share)) {
1491
+ this.sendBox(from, { t: 'unwatch', share: msg.share, sub: msg.sub });
1492
+ }
1493
+ return;
1494
+ }
1495
+ const settle = w.pending.settle;
1496
+ if (msg.t === 'watch_wait') {
1497
+ w.feed.markWaiting();
1498
+ if (settle) {
1499
+ clearTimeout(settle.timer);
1500
+ w.pending.settle = undefined;
1501
+ settle.resolve('waiting');
1502
+ }
727
1503
  return;
728
- const pending = w.pending;
1504
+ }
729
1505
  w.pending = undefined;
730
- clearTimeout(pending.timer);
1506
+ if (settle)
1507
+ clearTimeout(settle.timer);
731
1508
  if (msg.t === 'watch_err') {
732
- pending.reject(new PeerNetworkError(`주인 데몬이 거절했어요: ${msg.reason}`));
1509
+ // 주인 데몬이 보낸 글자라 CLI와 뷰어에 찍히기 전에 정리한다
1510
+ const err = new WatchRefused(plainLabel(msg.reason, 64) ?? 'unknown');
1511
+ if (settle)
1512
+ settle.reject(err);
1513
+ else
1514
+ this.onRefused(w, err.reason);
733
1515
  return;
734
1516
  }
735
1517
  if (typeof msg.sub !== 'string' || typeof msg.salt !== 'string') {
736
- pending.reject(new PeerNetworkError('주인 데몬 답이 맞지 않아요'));
1518
+ const err = new PeerNetworkError('주인 데몬 답이 맞지 않아요');
1519
+ if (settle)
1520
+ settle.reject(err);
1521
+ else
1522
+ w.feed.markOffline();
737
1523
  return;
738
1524
  }
739
1525
  w.sub = msg.sub;
1526
+ // 재청 횟수는 구독이 실제로 열렸을 때만 지운다. 거절 답에서 지우면 too_many가 이어질 때 간격이
1527
+ // 늘지 않고 상한에도 안 닿아 1초마다 끝없이 청한다
1528
+ w.retries = 0;
1529
+ w.messages = msg.messages === true;
740
1530
  w.key = streamKey(w.shareKey, Buffer.from(msg.salt, 'base64url'), w.share, this.key.id, msg.sub);
741
- pending.resolve();
1531
+ settle?.resolve('ok');
742
1532
  }
743
1533
  onWatchEnd(from, msg) {
744
1534
  const w = [...this.watches.values()].find((x) => x.owner === from && x.sub === msg.sub);
745
1535
  if (!w)
746
1536
  return;
747
1537
  w.sub = undefined;
1538
+ w.key?.fill(0);
748
1539
  w.key = undefined;
1540
+ // 주인이 끊었으면 다시 청하지 않는다. 다시 청해도 주인 데몬이 kicked로 거절한다
1541
+ if (msg.reason === 'kicked')
1542
+ return this.closeWatch(w, 'kicked');
749
1543
  this.rewatch(w);
750
1544
  }
751
1545
  onStream(from, env) {
@@ -774,11 +1568,25 @@ export class PeerNetwork {
774
1568
  if (!this.watches.has(w.id))
775
1569
  return;
776
1570
  this.watches.delete(w.id);
777
- if (w.pending) {
778
- clearTimeout(w.pending.timer);
779
- w.pending.reject(new PeerNetworkError(`보기가 끝났어요: ${reason}`));
780
- w.pending = undefined;
1571
+ if (w.retry)
1572
+ clearTimeout(w.retry);
1573
+ w.retry = undefined;
1574
+ // 승인을 기다리던 중이면 주인 쪽 대기가 시한까지 남지 않게 그 nonce로 취소를 보낸다
1575
+ if (w.pending && !w.sub) {
1576
+ this.sendBox(w.owner, { t: 'watch_cancel', share: w.share, nonce: w.pending.nonce });
781
1577
  }
1578
+ this.dropPending(w, `보기가 끝났어요: ${reason}`);
1579
+ w.key?.fill(0);
1580
+ w.key = undefined;
1581
+ this.closedWatches.push({
1582
+ id: w.id,
1583
+ share: w.share,
1584
+ owner: w.feed.ownerPerson,
1585
+ events: w.feed.eventCount,
1586
+ reason,
1587
+ });
1588
+ if (this.closedWatches.length > MAX_CLOSED_WATCHES)
1589
+ this.closedWatches.shift();
782
1590
  w.feed.close(reason);
783
1591
  try {
784
1592
  this.options.state.audit('watch_close', null, JSON.stringify({ watch: w.id, share: w.share, reason }));
@@ -800,6 +1608,21 @@ export class PeerNetwork {
800
1608
  function watchProofText(share, watcher, nonce) {
801
1609
  return canonical({ p: 'gestalt-peer-watch-v1', share, watcher, nonce });
802
1610
  }
1611
+ /** 링크를 가진 쪽만 만드는 메시지 증명. 본문까지 넣어 릴레이가 내용을 바꾸지 못하게 한다 */
1612
+ function messageProofText(share, from, id, body) {
1613
+ return canonical({ p: 'gestalt-peer-msg-v1', share, from, id, body });
1614
+ }
1615
+ function unique(items) {
1616
+ return [...new Set(items)];
1617
+ }
1618
+ function isMessageStatus(v) {
1619
+ return (v === 'queued' ||
1620
+ v === 'sent' ||
1621
+ v === 'arrived' ||
1622
+ v === 'inserted' ||
1623
+ v === 'delivered' ||
1624
+ v === 'rejected');
1625
+ }
803
1626
  function streamKey(shareKey, salt, share, watcher, sub) {
804
1627
  return deriveKey(shareKey, salt, `gestalt-peer-stream-v1|${share}|${watcher}|${sub}`);
805
1628
  }