@tienne/gestalt 0.111.0 → 0.112.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.
- package/CLAUDE.md +3 -3
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/package.json +3 -1
- package/dist/plugin/role-agents/lens-context/AGENT.md +8 -7
- package/dist/plugin/role-agents/lens-reader/AGENT.md +48 -2
- package/dist/plugin/skills/lens/SKILL.md +22 -15
- package/dist/src/architecture/doc-fresh.d.ts.map +1 -1
- package/dist/src/architecture/doc-fresh.js +3 -1
- package/dist/src/architecture/doc-fresh.js.map +1 -1
- package/dist/src/cli/commands/lens.d.ts +8 -1
- package/dist/src/cli/commands/lens.d.ts.map +1 -1
- package/dist/src/cli/commands/lens.js +29 -2
- package/dist/src/cli/commands/lens.js.map +1 -1
- package/dist/src/cli/commands/peer.d.ts +19 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -1
- package/dist/src/cli/commands/peer.js +188 -11
- package/dist/src/cli/commands/peer.js.map +1 -1
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +43 -4
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/digest/lens-schema.d.ts +150 -3
- package/dist/src/digest/lens-schema.d.ts.map +1 -1
- package/dist/src/digest/lens-schema.js +14 -1
- package/dist/src/digest/lens-schema.js.map +1 -1
- package/dist/src/digest/lens-widget-schema.d.ts +2 -2
- package/dist/src/digest/lint-lens.d.ts +2 -1
- package/dist/src/digest/lint-lens.d.ts.map +1 -1
- package/dist/src/digest/lint-lens.js +68 -7
- package/dist/src/digest/lint-lens.js.map +1 -1
- package/dist/src/digest/ocr.d.ts +1 -1
- package/dist/src/digest/ocr.d.ts.map +1 -1
- package/dist/src/digest/ocr.js +10 -4
- package/dist/src/digest/ocr.js.map +1 -1
- package/dist/src/digest/schema.d.ts +62 -62
- package/dist/src/lens/attach.d.ts +741 -0
- package/dist/src/lens/attach.d.ts.map +1 -1
- package/dist/src/lens/attach.js +41 -11
- package/dist/src/lens/attach.js.map +1 -1
- package/dist/src/lens/context-input.d.ts +17 -0
- package/dist/src/lens/context-input.d.ts.map +1 -0
- package/dist/src/lens/context-input.js +31 -0
- package/dist/src/lens/context-input.js.map +1 -0
- package/dist/src/lens/ingest.d.ts +19 -1
- package/dist/src/lens/ingest.d.ts.map +1 -1
- package/dist/src/lens/ingest.js +92 -14
- package/dist/src/lens/ingest.js.map +1 -1
- package/dist/src/lens/pdf-extract-py.d.ts +2 -0
- package/dist/src/lens/pdf-extract-py.d.ts.map +1 -1
- package/dist/src/lens/pdf-extract-py.js +37 -2
- package/dist/src/lens/pdf-extract-py.js.map +1 -1
- package/dist/src/mcp/schemas.d.ts +23 -0
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +20 -0
- package/dist/src/mcp/schemas.js.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +6 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.js +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -1
- package/dist/src/mcp/tools/peer.d.ts +21 -0
- package/dist/src/mcp/tools/peer.d.ts.map +1 -0
- package/dist/src/mcp/tools/peer.js +71 -0
- package/dist/src/mcp/tools/peer.js.map +1 -0
- package/dist/src/peer/codex.d.ts +49 -0
- package/dist/src/peer/codex.d.ts.map +1 -0
- package/dist/src/peer/codex.js +240 -0
- package/dist/src/peer/codex.js.map +1 -0
- package/dist/src/peer/daemon.d.ts +52 -1
- package/dist/src/peer/daemon.d.ts.map +1 -1
- package/dist/src/peer/daemon.js +356 -16
- package/dist/src/peer/daemon.js.map +1 -1
- package/dist/src/peer/feed.d.ts +24 -2
- package/dist/src/peer/feed.d.ts.map +1 -1
- package/dist/src/peer/feed.js.map +1 -1
- package/dist/src/peer/inbox.d.ts +31 -0
- package/dist/src/peer/inbox.d.ts.map +1 -0
- package/dist/src/peer/inbox.js +80 -0
- package/dist/src/peer/inbox.js.map +1 -0
- package/dist/src/peer/locate.d.ts +3 -0
- package/dist/src/peer/locate.d.ts.map +1 -1
- package/dist/src/peer/locate.js +4 -2
- package/dist/src/peer/locate.js.map +1 -1
- package/dist/src/peer/message.d.ts +104 -0
- package/dist/src/peer/message.d.ts.map +1 -0
- package/dist/src/peer/message.js +171 -0
- package/dist/src/peer/message.js.map +1 -0
- package/dist/src/peer/mirror.d.ts +50 -1
- package/dist/src/peer/mirror.d.ts.map +1 -1
- package/dist/src/peer/mirror.js +138 -0
- package/dist/src/peer/mirror.js.map +1 -1
- package/dist/src/peer/network.d.ts +247 -0
- package/dist/src/peer/network.d.ts.map +1 -1
- package/dist/src/peer/network.js +1119 -43
- package/dist/src/peer/network.js.map +1 -1
- package/dist/src/peer/paths.d.ts +6 -0
- package/dist/src/peer/paths.d.ts.map +1 -1
- package/dist/src/peer/paths.js +8 -0
- package/dist/src/peer/paths.js.map +1 -1
- package/dist/src/peer/protocol.d.ts +96 -4
- package/dist/src/peer/protocol.d.ts.map +1 -1
- package/dist/src/peer/protocol.js.map +1 -1
- package/dist/src/peer/relay.d.ts +14 -0
- package/dist/src/peer/relay.d.ts.map +1 -1
- package/dist/src/peer/relay.js +102 -7
- package/dist/src/peer/relay.js.map +1 -1
- package/dist/src/peer/roster.d.ts +2 -0
- package/dist/src/peer/roster.d.ts.map +1 -1
- package/dist/src/peer/roster.js +5 -0
- package/dist/src/peer/roster.js.map +1 -1
- package/dist/src/peer/share.d.ts +48 -1
- package/dist/src/peer/share.d.ts.map +1 -1
- package/dist/src/peer/share.js +163 -2
- package/dist/src/peer/share.js.map +1 -1
- package/dist/src/peer/state.d.ts +12 -1
- package/dist/src/peer/state.d.ts.map +1 -1
- package/dist/src/peer/state.js +15 -0
- package/dist/src/peer/state.js.map +1 -1
- package/dist/src/peer/transform.d.ts +27 -0
- package/dist/src/peer/transform.d.ts.map +1 -1
- package/dist/src/peer/transform.js +37 -33
- package/dist/src/peer/transform.js.map +1 -1
- package/dist/src/peer/transport.d.ts +7 -3
- package/dist/src/peer/transport.d.ts.map +1 -1
- package/dist/src/peer/transport.js +12 -4
- package/dist/src/peer/transport.js.map +1 -1
- package/dist/src/peer/viewer-html.d.ts +1 -1
- package/dist/src/peer/viewer-html.d.ts.map +1 -1
- package/dist/src/peer/viewer-html.js +151 -1
- package/dist/src/peer/viewer-html.js.map +1 -1
- package/dist/src/peer/viewer.d.ts +30 -1
- package/dist/src/peer/viewer.d.ts.map +1 -1
- package/dist/src/peer/viewer.js +103 -5
- package/dist/src/peer/viewer.js.map +1 -1
- package/dist/src/peer/wire.d.ts +9 -2
- package/dist/src/peer/wire.d.ts.map +1 -1
- package/dist/src/peer/wire.js.map +1 -1
- package/package.json +3 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
- package/plugin/role-agents/lens-context/AGENT.md +8 -7
- package/plugin/role-agents/lens-reader/AGENT.md +48 -2
- package/plugin/skills/lens/SKILL.md +22 -15
package/dist/src/peer/network.js
CHANGED
|
@@ -1,20 +1,80 @@
|
|
|
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';
|
|
5
|
-
import { aeadOpen, aeadSeal, canonical, deriveKey, hmac, openBox, safeEqual, sealBox, } from './seal.js';
|
|
4
|
+
import { checkRosterUpdate, decodeInvite, encodeInvite, formatShareLink, isAdmin, isPersonName, isRelayUrl, isShareId, joinProofText, memberOf, nextRoster, parseShareLink, signRoster, } from './roster.js';
|
|
5
|
+
import { aeadOpen, aeadSeal, canonical, deriveKey, hmac, openBox, safeEqual, sealBox, signText, verifyText, } from './seal.js';
|
|
6
6
|
import { RelayTransport } from './transport.js';
|
|
7
7
|
import { MAX_VIEWERS_PER_SHARE, REPLAY_CHUNK_BYTES } from './viewer.js';
|
|
8
8
|
import { errMessage } from './errors.js';
|
|
9
|
+
import { cleanQuestion, isMessageId, isQuestionId, MAX_MESSAGE_BYTES, MAX_QUESTION_BYTES, MESSAGE_RECEIVE_LIMIT, newMessageId, newQuestionId, plainLabel, RateLimiter, sanitizeMessageBody, } from './message.js';
|
|
10
|
+
import { RelaySendError } from './transport.js';
|
|
11
|
+
import { DEFAULT_FIRST_WATCH_APPROVAL } from './protocol.js';
|
|
9
12
|
/** 초대 유효 시간. 설계 원문의 24시간 */
|
|
10
13
|
export const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
14
|
/** 원격 구독 하나가 릴레이 소켓에 밀어 둘 수 있는 양 */
|
|
12
15
|
export const MAX_REMOTE_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
13
16
|
const WATCH_TIMEOUT_MS = 10_000;
|
|
17
|
+
/** 답이 없거나 다시 청하면 되는 거절을 받았을 때 다시 청하는 횟수와 간격 상한 */
|
|
18
|
+
const MAX_REWATCH_RETRIES = 6;
|
|
19
|
+
const MAX_REWATCH_DELAY_MS = 60_000;
|
|
14
20
|
const WATCH_NONCE_TTL_MS = 10 * 60 * 1000;
|
|
15
21
|
const MAX_SEEN_WATCH = 4096;
|
|
22
|
+
const MAX_SEEN_MESSAGES = 8192;
|
|
23
|
+
const MAX_REFUSED_PER_SENDER = 256;
|
|
24
|
+
const MAX_REFUSED_SENDERS = 1024;
|
|
25
|
+
/** 첫 접속 승인을 기다리는 시한. 넘으면 보는 쪽에 approval_timeout으로 알린다 */
|
|
26
|
+
export const APPROVAL_TTL_MS = 10 * 60 * 1000;
|
|
27
|
+
/** 승인 대기를 알리는 시간. 이 동안은 긴 안내 문구를 붙이고 지나면 훅이 짧은 한 줄만 붙인다 */
|
|
28
|
+
export const APPROVAL_NOTICE_MS = 60 * 1000;
|
|
29
|
+
/** 한 데몬이 동시에 열어 두는 질문 수 */
|
|
30
|
+
export const MAX_OPEN_QUESTIONS = 32;
|
|
31
|
+
/** 질문 시한의 기본값과 범위 */
|
|
32
|
+
export const DEFAULT_ASK_TTL_MS = 10 * 60 * 1000;
|
|
33
|
+
export const MIN_ASK_TTL_MS = 60 * 1000;
|
|
34
|
+
export const MAX_ASK_TTL_MS = 60 * 60 * 1000;
|
|
35
|
+
/** 답의 서명 시각이 이만큼 어긋나도 받는다. 기기끼리 시계가 조금씩 다르다 */
|
|
36
|
+
const ANSWER_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
37
|
+
/** Ed25519 서명을 base64url로 쓴 길이 */
|
|
38
|
+
const SIGNATURE_CHARS = 86;
|
|
39
|
+
/** 닫힌 질문 id를 기억하는 수. 늦게 온 답에 answered, expired로 답하려고 둔다 */
|
|
40
|
+
const MAX_CLOSED_QUESTIONS = 256;
|
|
41
|
+
/** 끝난 보기를 `peer watches`에 남겨 두는 개수. 거절이나 kick으로 끝난 이유를 CLI에서 보게 한다 */
|
|
42
|
+
const MAX_CLOSED_WATCHES = 16;
|
|
43
|
+
/** 보는 쪽이 다시 청하지 않고 보기를 끝내는 거절 이유 */
|
|
44
|
+
const FINAL_REFUSALS = new Set([
|
|
45
|
+
'not_found',
|
|
46
|
+
'bad_proof',
|
|
47
|
+
'kicked',
|
|
48
|
+
'denied',
|
|
49
|
+
'approval_timeout',
|
|
50
|
+
'share_closed',
|
|
51
|
+
]);
|
|
16
52
|
export class PeerNetworkError extends Error {
|
|
17
53
|
}
|
|
54
|
+
/** 주인 데몬이 시한 안에 watch에 답하지 않았다 */
|
|
55
|
+
export class WatchTimeout extends PeerNetworkError {
|
|
56
|
+
}
|
|
57
|
+
/** 주인 데몬이 watch를 거절했다. reason은 주인 데몬이 보낸 글자를 정리한 것이다 */
|
|
58
|
+
export class WatchRefused extends PeerNetworkError {
|
|
59
|
+
reason;
|
|
60
|
+
constructor(reason) {
|
|
61
|
+
super(refusalText(reason));
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** 거절 이유를 사람이 읽는 문장으로. 이유 글자는 끝에 괄호로 남겨 스크립트가 찾게 한다 */
|
|
66
|
+
export function refusalText(reason) {
|
|
67
|
+
switch (reason) {
|
|
68
|
+
case 'denied':
|
|
69
|
+
return '주인이 거절했어요 (denied)';
|
|
70
|
+
case 'kicked':
|
|
71
|
+
return '주인이 이 공유에서 끊었어요 (kicked)';
|
|
72
|
+
case 'approval_timeout':
|
|
73
|
+
return '주인이 승인하지 않아 기다리다 끝났어요 (approval_timeout)';
|
|
74
|
+
default:
|
|
75
|
+
return `주인 데몬이 거절했어요: ${reason}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
18
78
|
/**
|
|
19
79
|
* 팀, 명단, 릴레이 통로, 원격 공유를 묶는다. 데몬이 하나 들고 있다.
|
|
20
80
|
*
|
|
@@ -36,12 +96,58 @@ export class PeerNetwork {
|
|
|
36
96
|
announced = [];
|
|
37
97
|
/** 최근 받은 watch 요청의 `기기|nonce`와 받은 시각. 릴레이가 옛 요청을 다시 보내면 거절한다 */
|
|
38
98
|
seenWatch = new Map();
|
|
99
|
+
/** 받은 메시지의 `기기|id`. 릴레이가 같은 봉인을 다시 보내면 버린다 */
|
|
100
|
+
seenMessages = new Set();
|
|
101
|
+
/**
|
|
102
|
+
* 수신 한도로 거절한 메시지 id를 `공유|기기`마다 따로 기억한다. 창이 지난 뒤 릴레이가 같은
|
|
103
|
+
* 봉인을 다시 보내도 넣지 않는다. 공용 seenMessages에 넣으면 한 사람이 맞는 증명으로
|
|
104
|
+
* 쏟아부어 다른 팀원의 기억을 밀어낼 수 있어서 나눈다
|
|
105
|
+
*/
|
|
106
|
+
refusedMessages = new Map();
|
|
107
|
+
sendLimiter = new RateLimiter();
|
|
108
|
+
/** 보는 쪽이 12건으로 먼저 막는다. 주인 쪽은 릴레이에 쌓였다 한꺼번에 온 것도 받게 넉넉하다 */
|
|
109
|
+
receiveLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
|
|
39
110
|
maxBuffered;
|
|
111
|
+
approvalTtl;
|
|
112
|
+
approvalNotice;
|
|
113
|
+
/**
|
|
114
|
+
* 주인 쪽 통제 상태. 전부 공유가 열려 있는 동안만 메모리에 둔다. 공유는 데몬이 다시 뜨면
|
|
115
|
+
* 닫히므로 DB에 남길 필요가 없고 forgetShare가 공유를 닫을 때 같이 버린다.
|
|
116
|
+
* - pending: 첫 접속 승인을 기다리는 요청. 키는 `공유|기기`
|
|
117
|
+
* - approved: 공유마다 받아 준 기기
|
|
118
|
+
* - blocked: 공유마다 끊거나 거절한 기기. 공유가 열려 있는 동안 다시 못 붙고 메시지도 못 넣는다
|
|
119
|
+
*/
|
|
120
|
+
pending = new Map();
|
|
121
|
+
approved = new Map();
|
|
122
|
+
/** 첫 접속 설정. 메시지마다 읽으므로 DB가 아니라 메모리에 두고 setFirstWatch로만 바꾼다 */
|
|
123
|
+
firstWatchMode;
|
|
124
|
+
/** 거절 답과 감사 행을 쓰는 양의 상한. 끊긴 사람이 새 id로 계속 보내도 디스크와 릴레이를 못 채운다 */
|
|
125
|
+
rejectLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
|
|
126
|
+
blocked = new Map();
|
|
127
|
+
closedWatches = [];
|
|
128
|
+
/** 주인 쪽. 열린 질문. 공유처럼 데몬이 다시 뜨면 사라진다 */
|
|
129
|
+
questions = new Map();
|
|
130
|
+
/** 주인 쪽. 닫힌 질문과 닫힌 이유. 늦게 온 답의 거절 이유로 쓴다 */
|
|
131
|
+
closedQuestions = new Map();
|
|
132
|
+
/**
|
|
133
|
+
* 보는 쪽. 주인 기기마다 받는 새 질문 수. 질문마다 감사 행과 뷰어 알림이 따른다. 주인 쪽이 열린
|
|
134
|
+
* 질문 32개와 최소 시한 60초를 허용하므로 메시지 수신 한도(10분에 36건)를 쓴다
|
|
135
|
+
*/
|
|
136
|
+
askLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
|
|
137
|
+
/**
|
|
138
|
+
* 주인 쪽. 같은 기기가 같은 공유(`공유|기기`)에서 인박스에 답을 넣으려는 수를 10분에 36번으로 센다.
|
|
139
|
+
* 인박스가 없는 동안 다시 열린 질문에 계속 답하는 경우를 위해 뒀다. network.test.ts의 "같은 기기가
|
|
140
|
+
* 인박스에 계속 못 넣는 답을 보내면 36번 뒤에 rate_limited로 막는다"가 본다
|
|
141
|
+
*/
|
|
142
|
+
answerLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
|
|
40
143
|
constructor(options) {
|
|
41
144
|
this.options = options;
|
|
42
145
|
this.team = options.state.getTeam();
|
|
43
146
|
this.roster = options.state.getRoster();
|
|
44
147
|
this.maxBuffered = options.maxBufferedBytes ?? MAX_REMOTE_BUFFERED_BYTES;
|
|
148
|
+
this.approvalTtl = options.approvalTtlMs ?? APPROVAL_TTL_MS;
|
|
149
|
+
this.approvalNotice = options.approvalNoticeMs ?? APPROVAL_NOTICE_MS;
|
|
150
|
+
this.firstWatchMode = options.state.getSettings().firstWatch ?? DEFAULT_FIRST_WATCH_APPROVAL;
|
|
45
151
|
}
|
|
46
152
|
get key() {
|
|
47
153
|
return this.options.key;
|
|
@@ -52,6 +158,17 @@ export class PeerNetwork {
|
|
|
52
158
|
this.connect();
|
|
53
159
|
}
|
|
54
160
|
async stop() {
|
|
161
|
+
for (const p of this.pending.values()) {
|
|
162
|
+
clearTimeout(p.timer);
|
|
163
|
+
clearTimeout(p.shorten);
|
|
164
|
+
}
|
|
165
|
+
this.pending.clear();
|
|
166
|
+
for (const q of this.questions.values())
|
|
167
|
+
clearTimeout(q.timer);
|
|
168
|
+
this.questions.clear();
|
|
169
|
+
for (const w of this.watches.values())
|
|
170
|
+
if (w.retry)
|
|
171
|
+
clearTimeout(w.retry);
|
|
55
172
|
for (const s of [...this.subs.values()])
|
|
56
173
|
this.endSub(s, 'daemon_stop', false);
|
|
57
174
|
for (const w of [...this.watches.values()])
|
|
@@ -145,8 +262,15 @@ export class PeerNetwork {
|
|
|
145
262
|
*/
|
|
146
263
|
async join(code, timeoutMs = 60_000) {
|
|
147
264
|
const invite = decodeInvite(code);
|
|
148
|
-
|
|
149
|
-
|
|
265
|
+
// 명단에서 빠지면 릴레이가 바로 끊어서 새 명단을 못 받는다. 그래서 들고 있는 명단에는 아직 내가
|
|
266
|
+
// 있다. 릴레이가 removed로 끊었으면 같은 팀, 같은 관리자의 재초대만 받는다. removed는 릴레이가
|
|
267
|
+
// 정하는 값이라 이걸로 다른 팀에 옮겨 가는 길까지 열지 않는다
|
|
268
|
+
const removed = this.transport?.state === 'removed';
|
|
269
|
+
const rejoin = removed && this.team?.team === invite.team && this.team.anchor === invite.admin;
|
|
270
|
+
if (this.team && this.roster && memberOf(this.roster.roster, this.key.id) && !rejoin) {
|
|
271
|
+
throw new PeerNetworkError(removed
|
|
272
|
+
? `명단에서 빠진 팀(${this.team.team})의 관리자가 새로 만든 초대로만 다시 들어올 수 있어요`
|
|
273
|
+
: `이미 팀에 들어 있어요 (${this.team.team})`);
|
|
150
274
|
}
|
|
151
275
|
if (Date.now() >= invite.expiresAt)
|
|
152
276
|
throw new PeerNetworkError('초대 코드가 만료됐어요');
|
|
@@ -248,16 +372,39 @@ export class PeerNetwork {
|
|
|
248
372
|
}
|
|
249
373
|
lookupWatch(id) {
|
|
250
374
|
const w = this.watches.get(id);
|
|
251
|
-
|
|
375
|
+
if (!w)
|
|
376
|
+
return undefined;
|
|
377
|
+
return {
|
|
378
|
+
stream: w.feed,
|
|
379
|
+
token: w.token,
|
|
380
|
+
answer: (question, text) => this.answer(id, question, text).then((r) => ({ ok: true, question: r.question }), (err) => ({ ok: false, error: errMessage(err) })),
|
|
381
|
+
// 승인을 기다리는 동안 연 뷰어도 받아 준 뒤에 입력창이 있어야 한다. 페이지는 열 때 한 번
|
|
382
|
+
// 그려지므로 기다리는 중에는 입력창을 두고 보내기는 sendMessage가 막는다
|
|
383
|
+
...(w.messages || w.feed.currentStatus === 'waiting'
|
|
384
|
+
? {
|
|
385
|
+
send: (text) => this.sendMessage(id, text, 'human').then((r) => ({ ok: true, id: r.id }), (err) => ({ ok: false, error: errMessage(err) })),
|
|
386
|
+
}
|
|
387
|
+
: {}),
|
|
388
|
+
};
|
|
252
389
|
}
|
|
390
|
+
/** 보고 있는 공유와 최근에 끝난 보기. 끝난 것은 closed에 이유가 있다 */
|
|
253
391
|
listWatches() {
|
|
254
|
-
|
|
392
|
+
const live = [...this.watches.values()].map((w) => ({
|
|
255
393
|
id: w.id,
|
|
256
394
|
share: w.share,
|
|
257
395
|
owner: w.feed.ownerPerson,
|
|
258
396
|
status: w.feed.currentStatus,
|
|
259
397
|
events: w.feed.eventCount,
|
|
260
398
|
}));
|
|
399
|
+
const ended = this.closedWatches.map((c) => ({
|
|
400
|
+
id: c.id,
|
|
401
|
+
share: c.share,
|
|
402
|
+
owner: c.owner,
|
|
403
|
+
status: 'closed',
|
|
404
|
+
events: c.events,
|
|
405
|
+
closed: c.reason,
|
|
406
|
+
}));
|
|
407
|
+
return [...live, ...ended];
|
|
261
408
|
}
|
|
262
409
|
/** 링크로 원격 공유를 보기 시작한다. 주인 데몬이 받아 줄 때까지 기다린다 */
|
|
263
410
|
async watch(link) {
|
|
@@ -288,23 +435,27 @@ export class PeerNetwork {
|
|
|
288
435
|
shareKey: Buffer.from(parsed.shareKey, 'base64url'),
|
|
289
436
|
owner,
|
|
290
437
|
feed: new MirrorFeed(id, owner, ownerMember.person, parsed.shareId),
|
|
438
|
+
messages: false,
|
|
439
|
+
retries: 0,
|
|
291
440
|
};
|
|
292
441
|
this.watches.set(id, w);
|
|
442
|
+
let waiting;
|
|
293
443
|
try {
|
|
294
|
-
await this.requestWatch(w);
|
|
444
|
+
waiting = (await this.requestWatch(w)) === 'waiting';
|
|
295
445
|
}
|
|
296
446
|
catch (err) {
|
|
297
447
|
this.watches.delete(id);
|
|
298
448
|
w.feed.close('watch_failed');
|
|
299
449
|
throw err;
|
|
300
450
|
}
|
|
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 };
|
|
451
|
+
this.options.state.audit('watch_open', null, JSON.stringify({ watch: id, share: w.share, owner: ownerMember.person, waiting }));
|
|
452
|
+
return { id, share: w.share, owner: ownerMember.person, feed: w.feed, token: w.token, waiting };
|
|
303
453
|
}
|
|
304
454
|
unwatch(id) {
|
|
305
455
|
const w = this.watches.get(id);
|
|
306
456
|
if (!w)
|
|
307
457
|
return false;
|
|
458
|
+
// 승인을 기다리는 중이면 sub가 없다. 그때 주인에게 알리는 건 closeWatch가 watch_cancel로 한다
|
|
308
459
|
if (w.sub)
|
|
309
460
|
this.sendBox(w.owner, { t: 'unwatch', share: w.share, sub: w.sub });
|
|
310
461
|
this.closeWatch(w, 'unwatch');
|
|
@@ -411,6 +562,17 @@ export class PeerNetwork {
|
|
|
411
562
|
if (!memberOf(r, sub.watcher))
|
|
412
563
|
this.endSub(sub, 'removed', false);
|
|
413
564
|
}
|
|
565
|
+
for (const [key, p] of [...this.pending]) {
|
|
566
|
+
if (!memberOf(r, p.watcher))
|
|
567
|
+
this.dropApproval(key);
|
|
568
|
+
}
|
|
569
|
+
// 빠진 기기가 같은 키로 다시 초대돼 와도 묻지 않고 붙지 않게 승인은 지운다. 막은 목록은
|
|
570
|
+
// 공유가 닫힐 때까지 둔다. 지우면 kick한 기기가 다시 들어와 풀린다
|
|
571
|
+
for (const map of this.approved.values()) {
|
|
572
|
+
for (const machine of [...map.keys()])
|
|
573
|
+
if (!memberOf(r, machine))
|
|
574
|
+
map.delete(machine);
|
|
575
|
+
}
|
|
414
576
|
for (const w of [...this.watches.values()]) {
|
|
415
577
|
if (!memberOf(r, w.owner))
|
|
416
578
|
this.closeWatch(w, 'owner_removed');
|
|
@@ -462,19 +624,526 @@ export class PeerNetwork {
|
|
|
462
624
|
return this.onWatchRequest(from, msg);
|
|
463
625
|
case 'watch_ok':
|
|
464
626
|
case 'watch_err':
|
|
627
|
+
case 'watch_wait':
|
|
465
628
|
return this.onWatchReply(from, msg);
|
|
466
629
|
case 'watch_end':
|
|
467
630
|
return this.onWatchEnd(from, msg);
|
|
631
|
+
case 'watch_cancel':
|
|
632
|
+
return this.onWatchCancel(from, msg);
|
|
468
633
|
case 'unwatch': {
|
|
469
634
|
const sub = this.subs.get(msg.sub);
|
|
470
635
|
if (sub && sub.watcher === from)
|
|
471
636
|
this.endSub(sub, 'unwatch', false);
|
|
472
637
|
return;
|
|
473
638
|
}
|
|
639
|
+
case 'msg':
|
|
640
|
+
return void this.onMessageRequest(from, msg).catch((err) => {
|
|
641
|
+
process.stderr.write(`[peer] message failed: ${errMessage(err)}\n`);
|
|
642
|
+
});
|
|
643
|
+
case 'msg_status':
|
|
644
|
+
return this.onMessageStatus(from, msg);
|
|
645
|
+
case 'ask':
|
|
646
|
+
return this.onAsk(from, msg);
|
|
647
|
+
case 'ask_close':
|
|
648
|
+
return this.onAskClose(from, msg);
|
|
649
|
+
case 'answer':
|
|
650
|
+
return void this.onAnswer(from, msg).catch((err) => {
|
|
651
|
+
process.stderr.write(`[peer] answer failed: ${errMessage(err)}\n`);
|
|
652
|
+
});
|
|
653
|
+
case 'answer_status':
|
|
654
|
+
return this.onAnswerStatus(from, msg);
|
|
474
655
|
default:
|
|
475
656
|
return;
|
|
476
657
|
}
|
|
477
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* 보는 쪽: 원격 공유 주인 세션에 메시지를 보낸다. 주인 데몬이 오프라인이면 릴레이가 7일까지
|
|
661
|
+
* 들고 있는다. 상태는 보기의 MirrorFeed에 쌓여 뷰어로 간다.
|
|
662
|
+
*/
|
|
663
|
+
async sendMessage(watchId, raw, origin = 'human') {
|
|
664
|
+
const w = this.watches.get(watchId);
|
|
665
|
+
if (!w)
|
|
666
|
+
throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${watchId}`);
|
|
667
|
+
if (w.feed.currentStatus === 'waiting')
|
|
668
|
+
throw new PeerNetworkError('주인 승인을 기다리는 중이라 아직 메시지를 못 보내요');
|
|
669
|
+
if (!w.messages)
|
|
670
|
+
throw new PeerNetworkError('이 공유는 주인이 메시지를 받지 않게 열었어요');
|
|
671
|
+
const clean = sanitizeMessageBody(raw);
|
|
672
|
+
if (!clean.ok)
|
|
673
|
+
throw new PeerNetworkError(clean.reason);
|
|
674
|
+
const member = this.roster ? memberOf(this.roster.roster, w.owner) : undefined;
|
|
675
|
+
const t = this.transport;
|
|
676
|
+
if (!member)
|
|
677
|
+
throw new PeerNetworkError('공유 주인이 명단에 없어요');
|
|
678
|
+
if (!t || t.state !== 'online')
|
|
679
|
+
throw new PeerNetworkError('릴레이에 안 붙어 있어요');
|
|
680
|
+
// 보내기 직전에 센다. 명단이나 연결 때문에 못 보낸 시도는 한도를 안 깎는다.
|
|
681
|
+
// 주인 쪽 수신 한도는 MESSAGE_RECEIVE_LIMIT로 따로 정한다
|
|
682
|
+
if (!this.sendLimiter.take(`${w.share}|${w.owner}`)) {
|
|
683
|
+
throw new PeerNetworkError('메시지를 너무 자주 보냈어요. 10분에 12건까지예요');
|
|
684
|
+
}
|
|
685
|
+
const id = newMessageId();
|
|
686
|
+
const body = {
|
|
687
|
+
t: 'msg',
|
|
688
|
+
share: w.share,
|
|
689
|
+
id,
|
|
690
|
+
body: clean.text,
|
|
691
|
+
origin,
|
|
692
|
+
proof: hmac(w.shareKey, messageProofText(w.share, this.key.id, id, clean.text)),
|
|
693
|
+
};
|
|
694
|
+
// 릴레이 답보다 주인 데몬의 상태가 먼저 올 수 있어서 레코드를 미리 둔다. 알리는 건 답을 받은 뒤다
|
|
695
|
+
w.feed.reserveMessage(id, clean.text, Date.now());
|
|
696
|
+
const box = sealBox(this.key, w.owner, member.boxKey, JSON.stringify(body));
|
|
697
|
+
let result;
|
|
698
|
+
try {
|
|
699
|
+
result = await t.send(w.owner, Buffer.from(JSON.stringify(box)), { queue: true });
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
const reason = err instanceof RelaySendError ? err.reason : errMessage(err);
|
|
703
|
+
w.feed.setMessage(id, { status: 'rejected', reason });
|
|
704
|
+
throw new PeerNetworkError(`메시지를 못 보냈어요: ${errMessage(err)}`);
|
|
705
|
+
}
|
|
706
|
+
w.feed.setMessage(id, { status: result === 'queued' ? 'queued' : 'sent' });
|
|
707
|
+
return { id, status: w.feed.getMessage(id)?.status ?? 'sent' };
|
|
708
|
+
}
|
|
709
|
+
listMessages(watchId) {
|
|
710
|
+
const w = this.watches.get(watchId);
|
|
711
|
+
if (!w)
|
|
712
|
+
throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${watchId}`);
|
|
713
|
+
return w.feed.messages;
|
|
714
|
+
}
|
|
715
|
+
/** 주인 쪽: 받은 메시지를 검사하고 주인 세션 인박스에 쓴다 */
|
|
716
|
+
async onMessageRequest(from, msg) {
|
|
717
|
+
// 꼴이 틀린 공유 id는 감사 로그와 CLI 출력으로 흘러가지 않게 답 없이 버린다
|
|
718
|
+
if (!isShareId(msg.share) || !isMessageId(msg.id))
|
|
719
|
+
return;
|
|
720
|
+
// 릴레이가 같은 봉인을 다시 보내면 한 번만 넣는다. 기억은 검사를 다 통과한 뒤에 한다.
|
|
721
|
+
// 앞에서 기억하면 증명 없는 가짜 id로 칸을 밀어내 옛 봉인을 다시 넣게 할 수 있다
|
|
722
|
+
const dedupe = `${from}|${msg.id}`;
|
|
723
|
+
const sender = `${msg.share}|${from}`;
|
|
724
|
+
if (this.seenMessages.has(dedupe) || this.refusedMessages.get(sender)?.has(msg.id))
|
|
725
|
+
return;
|
|
726
|
+
const status = (s, reason) => {
|
|
727
|
+
this.sendBox(from, {
|
|
728
|
+
t: 'msg_status',
|
|
729
|
+
share: msg.share,
|
|
730
|
+
id: msg.id,
|
|
731
|
+
status: s,
|
|
732
|
+
...(reason ? { reason } : {}),
|
|
733
|
+
});
|
|
734
|
+
};
|
|
735
|
+
const person = (this.roster ? memberOf(this.roster.roster, from)?.person : undefined) ?? '?';
|
|
736
|
+
const reject = (reason) => {
|
|
737
|
+
// 거절은 증명 없이도 탈 수 있는 길이 있어서 보낸 기기마다 상한을 둔다. 넘으면 답도 기록도 없이 버린다
|
|
738
|
+
if (!this.rejectLimiter.take(from))
|
|
739
|
+
return;
|
|
740
|
+
status('rejected', reason);
|
|
741
|
+
this.options.state.audit('message_reject', msg.share, JSON.stringify({ id: msg.id, person, reason }));
|
|
742
|
+
};
|
|
743
|
+
const owned = this.options.lookupShare(msg.share);
|
|
744
|
+
if (!owned || owned.stream.isClosed)
|
|
745
|
+
return reject('share_closed');
|
|
746
|
+
if (typeof msg.body !== 'string' || typeof msg.proof !== 'string')
|
|
747
|
+
return reject('bad_frame');
|
|
748
|
+
const expected = hmac(Buffer.from(owned.shareKey, 'base64url'), messageProofText(msg.share, from, msg.id, msg.body));
|
|
749
|
+
if (!safeEqual(msg.proof, expected))
|
|
750
|
+
return reject('bad_proof');
|
|
751
|
+
// 메시지는 구독이 아니라 링크 증명으로 받아서 구독을 끊어도 링크는 남는다. 끊거나 거절한 사람과
|
|
752
|
+
// 아직 안 받아 준 사람은 여기서 막는다. 릴레이가 들고 있다 나중에 넘긴 봉인도 같은 규칙을 탄다
|
|
753
|
+
const refusal = this.messageRefusal(msg.share, from);
|
|
754
|
+
if (refusal) {
|
|
755
|
+
this.rememberRefused(sender, msg.id);
|
|
756
|
+
return reject(refusal);
|
|
757
|
+
}
|
|
758
|
+
const clean = sanitizeMessageBody(msg.body);
|
|
759
|
+
if (!clean.ok)
|
|
760
|
+
return reject(clean.reason);
|
|
761
|
+
if (!this.receiveLimiter.take(sender)) {
|
|
762
|
+
this.rememberRefused(sender, msg.id);
|
|
763
|
+
return reject('rate_limited');
|
|
764
|
+
}
|
|
765
|
+
if (!owned.deliver)
|
|
766
|
+
return reject('messages_off');
|
|
767
|
+
// deliver의 첫 await 전에 기억해야 동시에 온 같은 봉인이 둘 다 들어가지 않는다
|
|
768
|
+
this.seenMessages.add(dedupe);
|
|
769
|
+
if (this.seenMessages.size > MAX_SEEN_MESSAGES) {
|
|
770
|
+
const oldest = this.seenMessages.values().next().value;
|
|
771
|
+
if (oldest !== undefined)
|
|
772
|
+
this.seenMessages.delete(oldest);
|
|
773
|
+
}
|
|
774
|
+
const origin = msg.origin === 'agent' ? 'agent' : 'human';
|
|
775
|
+
this.options.state.audit('message_receive', msg.share, JSON.stringify({ id: msg.id, person, origin, bytes: Buffer.byteLength(clean.text) }));
|
|
776
|
+
status('arrived');
|
|
777
|
+
const inbound = {
|
|
778
|
+
kind: 'message',
|
|
779
|
+
id: msg.id,
|
|
780
|
+
share: msg.share,
|
|
781
|
+
from,
|
|
782
|
+
person,
|
|
783
|
+
origin,
|
|
784
|
+
body: clean.text,
|
|
785
|
+
};
|
|
786
|
+
const delivered = () => {
|
|
787
|
+
status('delivered');
|
|
788
|
+
this.options.state.audit('message_deliver', msg.share, JSON.stringify({ id: msg.id, person }));
|
|
789
|
+
};
|
|
790
|
+
// 들어갔는지 지켜보는 일은 쓰기 전에 걸어 둔다. 그래서 쓰기가 끝나기 전에 보일 수 있고 그때는
|
|
791
|
+
// 넣음을 먼저 알린 뒤 들어감을 알린다
|
|
792
|
+
let inserted = false;
|
|
793
|
+
let seenEarly = false;
|
|
794
|
+
const result = await owned.deliver(inbound, () => {
|
|
795
|
+
if (inserted)
|
|
796
|
+
delivered();
|
|
797
|
+
else
|
|
798
|
+
seenEarly = true;
|
|
799
|
+
});
|
|
800
|
+
// 소켓에 붙은 뒤 실패했으면 줄이 들어갔을 수 있다. 거절로 알리면 보낸 사람이 다시 보내 겹칠 수 있어서
|
|
801
|
+
// 넣음으로 알리고 대화 기록에 보이면 들어감으로 올린다
|
|
802
|
+
if (!result.ok && !result.maybeWritten)
|
|
803
|
+
return reject(result.reason);
|
|
804
|
+
status('inserted');
|
|
805
|
+
this.options.state.audit('message_insert', msg.share, JSON.stringify({ id: msg.id, person, ...(result.ok ? {} : { maybeWritten: true }) }));
|
|
806
|
+
inserted = true;
|
|
807
|
+
if (seenEarly)
|
|
808
|
+
delivered();
|
|
809
|
+
}
|
|
810
|
+
rememberRefused(sender, id) {
|
|
811
|
+
let ids = this.refusedMessages.get(sender);
|
|
812
|
+
if (!ids) {
|
|
813
|
+
ids = new Set();
|
|
814
|
+
this.refusedMessages.set(sender, ids);
|
|
815
|
+
if (this.refusedMessages.size > MAX_REFUSED_SENDERS) {
|
|
816
|
+
const oldest = this.refusedMessages.keys().next().value;
|
|
817
|
+
if (oldest !== undefined)
|
|
818
|
+
this.refusedMessages.delete(oldest);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
ids.add(id);
|
|
822
|
+
if (ids.size > MAX_REFUSED_PER_SENDER) {
|
|
823
|
+
const oldest = ids.values().next().value;
|
|
824
|
+
if (oldest !== undefined)
|
|
825
|
+
ids.delete(oldest);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/** 보는 쪽: 주인 데몬이 알려 온 상태를 반영한다 */
|
|
829
|
+
onMessageStatus(from, msg) {
|
|
830
|
+
if (!isMessageId(msg.id) || !isMessageStatus(msg.status))
|
|
831
|
+
return;
|
|
832
|
+
for (const w of this.watches.values()) {
|
|
833
|
+
if (w.owner !== from || w.share !== msg.share || !w.feed.getMessage(msg.id))
|
|
834
|
+
continue;
|
|
835
|
+
// 주인 데몬이 보낸 글자라 CLI 터미널에 찍히기 전에 제어 문자를 지운다
|
|
836
|
+
const reason = plainLabel(msg.reason);
|
|
837
|
+
w.feed.setMessage(msg.id, { status: msg.status, ...(reason ? { reason } : {}) });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// ── ask_user ──────────────────────────────────────────────
|
|
841
|
+
/**
|
|
842
|
+
* 주인 쪽: 주인 세션 모델의 질문을 그 세션 공유를 지금 보고 있는 팀원에게 보낸다. to를 주면 그
|
|
843
|
+
* 사람 기기만이다. 구독이 열린 기기만 받으므로 승인 전, 거절, kick된 기기에는 안 간다. 질문
|
|
844
|
+
* 글자는 부르는 쪽이 가린 뒤 넘긴다. 답은 나중에 onAnswer가 받아 주인 세션에 넣는다.
|
|
845
|
+
*/
|
|
846
|
+
ask(shares, raw, to, ttlMs) {
|
|
847
|
+
// 말없이 자르면 모델은 질문이 다 갔다고 여긴다. 넘으면 거절해서 줄여 다시 내게 한다
|
|
848
|
+
if (Buffer.byteLength(raw) > MAX_QUESTION_BYTES) {
|
|
849
|
+
throw new PeerNetworkError(`질문이 ${MAX_QUESTION_BYTES / 1024}KB를 넘어요. 줄여서 다시 보내 주세요`);
|
|
850
|
+
}
|
|
851
|
+
const text = cleanQuestion(raw);
|
|
852
|
+
if (!text)
|
|
853
|
+
throw new PeerNetworkError('질문이 비었어요');
|
|
854
|
+
if (this.questions.size >= MAX_OPEN_QUESTIONS) {
|
|
855
|
+
throw new PeerNetworkError(`열린 질문이 ${MAX_OPEN_QUESTIONS}개라 더 못 내요`);
|
|
856
|
+
}
|
|
857
|
+
const only = to === undefined ? undefined : new Set(this.machinesOf(to));
|
|
858
|
+
const targets = new Map();
|
|
859
|
+
for (const sub of this.subs.values()) {
|
|
860
|
+
if (sub.ended || !shares.includes(sub.share))
|
|
861
|
+
continue;
|
|
862
|
+
if (only && !only.has(sub.watcher))
|
|
863
|
+
continue;
|
|
864
|
+
targets.set(`${sub.share}|${sub.watcher}`, sub);
|
|
865
|
+
}
|
|
866
|
+
if (targets.size === 0) {
|
|
867
|
+
throw new PeerNetworkError(to === undefined
|
|
868
|
+
? '지금 이 세션을 보는 팀원이 없어요'
|
|
869
|
+
: `${plainLabel(to, 32) ?? '?'} 님은 지금 이 세션을 보고 있지 않아요`);
|
|
870
|
+
}
|
|
871
|
+
const id = newQuestionId();
|
|
872
|
+
const askedAt = Date.now();
|
|
873
|
+
const expiresAt = askedAt + ttlMs;
|
|
874
|
+
const asked = new Set();
|
|
875
|
+
for (const [key, sub] of targets) {
|
|
876
|
+
const sent = this.sendBox(sub.watcher, {
|
|
877
|
+
t: 'ask',
|
|
878
|
+
share: sub.share,
|
|
879
|
+
q: id,
|
|
880
|
+
text,
|
|
881
|
+
expiresAt,
|
|
882
|
+
});
|
|
883
|
+
if (sent)
|
|
884
|
+
asked.add(key);
|
|
885
|
+
}
|
|
886
|
+
if (asked.size === 0)
|
|
887
|
+
throw new PeerNetworkError('릴레이에 안 붙어 있어요');
|
|
888
|
+
const timer = setTimeout(() => this.expireQuestion(id), ttlMs);
|
|
889
|
+
timer.unref();
|
|
890
|
+
this.questions.set(id, { id, asked, askedAt, expiresAt, timer, answering: false });
|
|
891
|
+
const sent = [...asked].map((key) => targets.get(key));
|
|
892
|
+
const persons = unique(sent.map((s) => this.personOf(s.watcher)));
|
|
893
|
+
const sentShares = unique(sent.map((s) => s.share));
|
|
894
|
+
for (const share of sentShares) {
|
|
895
|
+
this.options.state.audit('ask_open', share, JSON.stringify({
|
|
896
|
+
question: id,
|
|
897
|
+
persons: unique(sent.filter((s) => s.share === share).map((s) => this.personOf(s.watcher))),
|
|
898
|
+
expiresAt,
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
901
|
+
return { question: id, to: persons, shares: sentShares, expiresAt };
|
|
902
|
+
}
|
|
903
|
+
expireQuestion(id) {
|
|
904
|
+
const q = this.questions.get(id);
|
|
905
|
+
if (!q)
|
|
906
|
+
return;
|
|
907
|
+
this.closeQuestion(q, 'expired');
|
|
908
|
+
try {
|
|
909
|
+
this.options.state.audit('ask_expire', null, JSON.stringify({ question: id }));
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
// 데몬이 멈추는 중이면 DB가 이미 닫혔을 수 있다
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** 질문을 끝내고 받은 사람 뷰어에 알린다. except 자리(먼저 답한 기기)에는 안 알린다 */
|
|
916
|
+
closeQuestion(q, reason, except) {
|
|
917
|
+
if (this.questions.get(q.id) !== q)
|
|
918
|
+
return;
|
|
919
|
+
clearTimeout(q.timer);
|
|
920
|
+
this.questions.delete(q.id);
|
|
921
|
+
this.closedQuestions.set(q.id, reason);
|
|
922
|
+
if (this.closedQuestions.size > MAX_CLOSED_QUESTIONS) {
|
|
923
|
+
const oldest = this.closedQuestions.keys().next().value;
|
|
924
|
+
if (oldest !== undefined)
|
|
925
|
+
this.closedQuestions.delete(oldest);
|
|
926
|
+
}
|
|
927
|
+
for (const key of q.asked) {
|
|
928
|
+
if (key === except)
|
|
929
|
+
continue;
|
|
930
|
+
const [share, watcher] = key.split('|');
|
|
931
|
+
this.sendBox(watcher, { t: 'ask_close', share, q: q.id, reason });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* 주인 쪽: 답을 검사하고 주인 세션 인박스에 `<peer-answer … verified>`로 넣는다. 검사 순서는
|
|
936
|
+
* 제안서 "ask_user 서명 답" 절의 표와 같다. 하나라도 걸리면 넣지 않는다. network.test.ts의 "ask_user 서명
|
|
937
|
+
* 답" 블록이 kicked, not_approved, not_asked, answered, bad_signature, bad_frame, bad_time, expired, 길이
|
|
938
|
+
* 검사, rate_limited, 인박스 실패 뒤 처리를 넣어 본다.
|
|
939
|
+
*/
|
|
940
|
+
async onAnswer(from, msg) {
|
|
941
|
+
// 꼴이 틀린 id는 감사 로그와 답으로 흘러가지 않게 답 없이 버린다
|
|
942
|
+
if (!isShareId(msg.share) || !isQuestionId(msg.q))
|
|
943
|
+
return;
|
|
944
|
+
const person = this.personOf(from);
|
|
945
|
+
const status = (s, reason) => {
|
|
946
|
+
this.sendBox(from, {
|
|
947
|
+
t: 'answer_status',
|
|
948
|
+
share: msg.share,
|
|
949
|
+
q: msg.q,
|
|
950
|
+
status: s,
|
|
951
|
+
...(reason ? { reason } : {}),
|
|
952
|
+
});
|
|
953
|
+
};
|
|
954
|
+
const reject = (reason) => {
|
|
955
|
+
if (!this.rejectLimiter.take(from))
|
|
956
|
+
return;
|
|
957
|
+
status('rejected', reason);
|
|
958
|
+
this.options.state.audit('answer_reject', msg.share, JSON.stringify({ question: msg.q, person, reason }));
|
|
959
|
+
};
|
|
960
|
+
const owned = this.options.lookupShare(msg.share);
|
|
961
|
+
if (!owned || owned.stream.isClosed)
|
|
962
|
+
return reject('share_closed');
|
|
963
|
+
if (!owned.deliver)
|
|
964
|
+
return reject('messages_off');
|
|
965
|
+
// 승인과 kick은 구독이 아니라 공유 단위 기록으로 본다. 질문을 받은 뒤 끊긴 기기도 여기서 막힌다
|
|
966
|
+
const refusal = this.messageRefusal(msg.share, from);
|
|
967
|
+
if (refusal)
|
|
968
|
+
return reject(refusal);
|
|
969
|
+
const q = this.questions.get(msg.q);
|
|
970
|
+
if (!q)
|
|
971
|
+
return reject(this.closedQuestions.get(msg.q) ?? 'unknown_question');
|
|
972
|
+
const seat = `${msg.share}|${from}`;
|
|
973
|
+
if (!q.asked.has(seat))
|
|
974
|
+
return reject('not_asked');
|
|
975
|
+
if (q.answering)
|
|
976
|
+
return reject('answered');
|
|
977
|
+
if (typeof msg.body !== 'string' ||
|
|
978
|
+
typeof msg.sig !== 'string' ||
|
|
979
|
+
!Number.isSafeInteger(msg.at))
|
|
980
|
+
return reject('bad_frame');
|
|
981
|
+
if (msg.sig.length !== SIGNATURE_CHARS)
|
|
982
|
+
return reject('bad_signature');
|
|
983
|
+
// 서명 검증 비용이 본문 크기에 비례해서 상한을 먼저 본다. UTF-16 길이는 바이트 수보다 크지 않다
|
|
984
|
+
if (msg.body.length > MAX_MESSAGE_BYTES) {
|
|
985
|
+
return reject(`본문이 ${MAX_MESSAGE_BYTES / 1024}KB를 넘어요`);
|
|
986
|
+
}
|
|
987
|
+
if (!verifyText(from, answerSignedText(msg.share, msg.q, msg.body, from, msg.at), msg.sig)) {
|
|
988
|
+
return reject('bad_signature');
|
|
989
|
+
}
|
|
990
|
+
const now = Date.now();
|
|
991
|
+
if (msg.at < q.askedAt - ANSWER_CLOCK_SKEW_MS || msg.at > now + ANSWER_CLOCK_SKEW_MS) {
|
|
992
|
+
return reject('bad_time');
|
|
993
|
+
}
|
|
994
|
+
if (now >= q.expiresAt)
|
|
995
|
+
return reject('expired');
|
|
996
|
+
const clean = sanitizeMessageBody(msg.body);
|
|
997
|
+
if (!clean.ok)
|
|
998
|
+
return reject(clean.reason);
|
|
999
|
+
if (!this.answerLimiter.take(seat))
|
|
1000
|
+
return reject('rate_limited');
|
|
1001
|
+
// 인박스 쓰기를 기다리는 사이에 온 다른 답은 answered로 막는다. 소켓에 붙기 전에 실패했거나 deliver가
|
|
1002
|
+
// 던졌으면 질문을 열어 두고 답한 기기에 reopened로 알려 다시 답하게 한다. 이 알림은 거절 상한을 타지
|
|
1003
|
+
// 않는다. 붙은 뒤 실패했으면 줄이 들어갔을 수 있어서 메시지처럼 넣음으로 알리고 질문을 끝낸다.
|
|
1004
|
+
// 대화 기록에 보이면 들어감으로 올린다
|
|
1005
|
+
let inserted = false;
|
|
1006
|
+
let seenEarly = false;
|
|
1007
|
+
const delivered = () => {
|
|
1008
|
+
status('delivered');
|
|
1009
|
+
this.options.state.audit('answer_deliver', msg.share, JSON.stringify({ question: q.id, person }));
|
|
1010
|
+
};
|
|
1011
|
+
let result;
|
|
1012
|
+
q.answering = true;
|
|
1013
|
+
try {
|
|
1014
|
+
this.options.state.audit('answer_receive', msg.share, JSON.stringify({ question: q.id, person, bytes: Buffer.byteLength(clean.text) }));
|
|
1015
|
+
result = await owned.deliver({ kind: 'answer', question: q.id, share: msg.share, from, person, body: clean.text }, () => {
|
|
1016
|
+
if (inserted)
|
|
1017
|
+
delivered();
|
|
1018
|
+
else
|
|
1019
|
+
seenEarly = true;
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
result = { ok: false, reason: 'deliver_failed' };
|
|
1024
|
+
process.stderr.write(`[peer] answer deliver failed: ${errMessage(err)}\n`);
|
|
1025
|
+
}
|
|
1026
|
+
finally {
|
|
1027
|
+
q.answering = false;
|
|
1028
|
+
}
|
|
1029
|
+
if (!result.ok && !result.maybeWritten) {
|
|
1030
|
+
const reopened = this.questions.get(q.id) === q;
|
|
1031
|
+
this.sendBox(from, {
|
|
1032
|
+
t: 'answer_status',
|
|
1033
|
+
share: msg.share,
|
|
1034
|
+
q: msg.q,
|
|
1035
|
+
status: 'rejected',
|
|
1036
|
+
reason: result.reason,
|
|
1037
|
+
...(reopened ? { reopened: true } : {}),
|
|
1038
|
+
});
|
|
1039
|
+
this.options.state.audit('answer_reject', msg.share, JSON.stringify({ question: msg.q, person, reason: result.reason }));
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
this.closeQuestion(q, 'answered', seat);
|
|
1043
|
+
status('inserted');
|
|
1044
|
+
this.options.state.audit('answer_insert', msg.share, JSON.stringify({ question: q.id, person, ...(result.ok ? {} : { maybeWritten: true }) }));
|
|
1045
|
+
inserted = true;
|
|
1046
|
+
if (seenEarly)
|
|
1047
|
+
delivered();
|
|
1048
|
+
}
|
|
1049
|
+
/** 보는 쪽: 내가 보는 공유의 주인 데몬이 보낸 질문을 뷰어에 띄운다 */
|
|
1050
|
+
onAsk(from, msg) {
|
|
1051
|
+
if (!isQuestionId(msg.q) || !Number.isSafeInteger(msg.expiresAt))
|
|
1052
|
+
return;
|
|
1053
|
+
// 주인 데몬이 보낸 글자라 보여주기 전에 제어 문자를 지운다
|
|
1054
|
+
const text = cleanQuestion(msg.text);
|
|
1055
|
+
const now = Date.now();
|
|
1056
|
+
if (!text || msg.expiresAt <= now)
|
|
1057
|
+
return;
|
|
1058
|
+
const targets = [...this.watches.values()].filter((w) => w.owner === from && w.share === msg.share && w.sub && !w.feed.getQuestion(msg.q));
|
|
1059
|
+
if (targets.length === 0)
|
|
1060
|
+
return;
|
|
1061
|
+
// 새 질문 수를 주인 기기마다 10분에 36개로 센다. 질문마다 감사 행과 뷰어 알림이 따른다
|
|
1062
|
+
if (!this.askLimiter.take(from)) {
|
|
1063
|
+
process.stderr.write('[peer] ask dropped: rate limited\n');
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
for (const w of targets) {
|
|
1067
|
+
w.feed.addQuestion(msg.q, text, Math.min(msg.expiresAt, now + MAX_ASK_TTL_MS));
|
|
1068
|
+
this.options.state.audit('ask_receive', null, JSON.stringify({ watch: w.id, share: w.share, question: msg.q }));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
onAskClose(from, msg) {
|
|
1072
|
+
if (!isQuestionId(msg.q))
|
|
1073
|
+
return;
|
|
1074
|
+
const reason = plainLabel(msg.reason, 32);
|
|
1075
|
+
for (const w of this.watches.values()) {
|
|
1076
|
+
if (w.owner === from && w.share === msg.share)
|
|
1077
|
+
w.feed.setQuestion(msg.q, 'closed', reason);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
onAnswerStatus(from, msg) {
|
|
1081
|
+
if (!isQuestionId(msg.q))
|
|
1082
|
+
return;
|
|
1083
|
+
if (msg.status !== 'inserted' && msg.status !== 'delivered' && msg.status !== 'rejected')
|
|
1084
|
+
return;
|
|
1085
|
+
const reason = plainLabel(msg.reason);
|
|
1086
|
+
for (const w of this.watches.values()) {
|
|
1087
|
+
if (w.owner !== from || w.share !== msg.share)
|
|
1088
|
+
continue;
|
|
1089
|
+
if (msg.status === 'rejected' && msg.reopened === true)
|
|
1090
|
+
w.feed.reopenQuestion(msg.q, reason);
|
|
1091
|
+
else
|
|
1092
|
+
w.feed.setQuestion(msg.q, msg.status, reason);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* 보는 쪽: 뷰어에서 사람이 쓴 답을 기기 키로 서명해 주인 데몬에 보낸다. 부르는 곳은 lookupWatch가
|
|
1097
|
+
* 뷰어에 넘기는 answer 하나다. IPC에 답 요청이 없는 건 network.test.ts의 "IPC와 MCP 도구에는 답을
|
|
1098
|
+
* 서명하는 길이 없다"가 본다. 주인 데몬이 오프라인이면 릴레이가 메시지처럼 들고 있는다.
|
|
1099
|
+
*/
|
|
1100
|
+
async answer(watchId, question, raw) {
|
|
1101
|
+
const w = this.watches.get(watchId);
|
|
1102
|
+
if (!w)
|
|
1103
|
+
throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${watchId}`);
|
|
1104
|
+
const q = isQuestionId(question) ? w.feed.getQuestion(question) : undefined;
|
|
1105
|
+
if (!q || q.state !== 'open')
|
|
1106
|
+
throw new PeerNetworkError('답할 수 있는 질문이 아니에요');
|
|
1107
|
+
if (Date.now() >= q.expiresAt) {
|
|
1108
|
+
w.feed.setQuestion(q.id, 'closed', 'expired');
|
|
1109
|
+
throw new PeerNetworkError('시한이 지난 질문이에요');
|
|
1110
|
+
}
|
|
1111
|
+
const clean = sanitizeMessageBody(raw);
|
|
1112
|
+
if (!clean.ok)
|
|
1113
|
+
throw new PeerNetworkError(clean.reason);
|
|
1114
|
+
const member = this.roster ? memberOf(this.roster.roster, w.owner) : undefined;
|
|
1115
|
+
const t = this.transport;
|
|
1116
|
+
if (!member)
|
|
1117
|
+
throw new PeerNetworkError('공유 주인이 명단에 없어요');
|
|
1118
|
+
if (!t || t.state !== 'online')
|
|
1119
|
+
throw new PeerNetworkError('릴레이에 안 붙어 있어요');
|
|
1120
|
+
const at = Date.now();
|
|
1121
|
+
const box = {
|
|
1122
|
+
t: 'answer',
|
|
1123
|
+
share: w.share,
|
|
1124
|
+
q: q.id,
|
|
1125
|
+
body: clean.text,
|
|
1126
|
+
at,
|
|
1127
|
+
sig: signText(this.key, answerSignedText(w.share, q.id, clean.text, this.key.id, at)),
|
|
1128
|
+
};
|
|
1129
|
+
// 상태를 옮기는 건 봉인 뒤, 첫 await 앞이다. 여기까지가 동기로 돌아서 같은 질문의 두 번째 호출은
|
|
1130
|
+
// 위 open 검사에서 걸린다
|
|
1131
|
+
const sealed = sealBox(this.key, w.owner, member.boxKey, JSON.stringify(box));
|
|
1132
|
+
w.feed.setQuestion(q.id, 'queued');
|
|
1133
|
+
let result;
|
|
1134
|
+
try {
|
|
1135
|
+
result = await t.send(w.owner, Buffer.from(JSON.stringify(sealed)), { queue: true });
|
|
1136
|
+
}
|
|
1137
|
+
catch (err) {
|
|
1138
|
+
const reason = err instanceof RelaySendError ? err.reason : errMessage(err);
|
|
1139
|
+
w.feed.setQuestion(q.id, 'rejected', plainLabel(reason));
|
|
1140
|
+
throw new PeerNetworkError(`답을 못 보냈어요: ${errMessage(err)}`);
|
|
1141
|
+
}
|
|
1142
|
+
if (result === 'delivered')
|
|
1143
|
+
w.feed.setQuestion(q.id, 'sent');
|
|
1144
|
+
this.options.state.audit('answer_send', null, JSON.stringify({ watch: w.id, share: w.share, question: q.id }));
|
|
1145
|
+
return { question: q.id };
|
|
1146
|
+
}
|
|
478
1147
|
onJoinRequest(from, data) {
|
|
479
1148
|
try {
|
|
480
1149
|
this.handleJoin(from, data);
|
|
@@ -550,20 +1219,40 @@ export class PeerNetwork {
|
|
|
550
1219
|
}
|
|
551
1220
|
if (!this.rememberWatchNonce(from, msg.nonce))
|
|
552
1221
|
return reply('replay');
|
|
1222
|
+
const block = this.blocked.get(msg.share)?.get(from);
|
|
1223
|
+
if (block)
|
|
1224
|
+
return reply(block);
|
|
1225
|
+
const after = Number.isSafeInteger(msg.after) && msg.after > 0 ? msg.after : 0;
|
|
1226
|
+
// 승인은 salt와 세션 키를 정하기 전에 본다. 키를 보낸 뒤에 막으면 보는 쪽이 이미 대화를 열어 읽을 수 있다
|
|
1227
|
+
if (!this.approved.get(msg.share)?.has(from)) {
|
|
1228
|
+
if (this.firstWatchMode === 'ask') {
|
|
1229
|
+
return this.holdForApproval(from, msg.share, msg.nonce, after);
|
|
1230
|
+
}
|
|
1231
|
+
// 기다리는 사이 설정을 team으로 바꿨으면 남은 대기를 치운다. 안 치우면 목록과 훅에 계속 보인다
|
|
1232
|
+
this.dropApproval(`${msg.share}|${from}`);
|
|
1233
|
+
this.grant(msg.share, from, 'team');
|
|
1234
|
+
}
|
|
1235
|
+
this.startSub(from, msg.share, msg.nonce, after, owned);
|
|
1236
|
+
}
|
|
1237
|
+
/** 받아 준 사람의 구독을 연다. 공유 세션 키는 여기서 처음 정한다 */
|
|
1238
|
+
startSub(from, share, nonce, after, owned) {
|
|
553
1239
|
// 같은 사람이 다시 청하면 앞의 구독을 바꾼다. 보는 쪽이 모르는 교체일 수 있어서 알린다
|
|
554
1240
|
for (const s of [...this.subs.values()]) {
|
|
555
|
-
if (s.watcher === from && s.share ===
|
|
1241
|
+
if (s.watcher === from && s.share === share)
|
|
556
1242
|
this.endSub(s, 'replaced', true);
|
|
557
1243
|
}
|
|
558
|
-
if (owned.stream.viewerCount >= MAX_VIEWERS_PER_SHARE)
|
|
559
|
-
|
|
1244
|
+
if (owned.stream.viewerCount >= MAX_VIEWERS_PER_SHARE) {
|
|
1245
|
+
this.sendBox(from, { t: 'watch_err', share, nonce, reason: 'too_many' });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
560
1248
|
const sub = randomBytes(9).toString('base64url');
|
|
561
1249
|
const salt = randomBytes(32);
|
|
1250
|
+
const shareKey = Buffer.from(owned.shareKey, 'base64url');
|
|
562
1251
|
const remote = {
|
|
563
1252
|
sub,
|
|
564
|
-
share
|
|
1253
|
+
share,
|
|
565
1254
|
watcher: from,
|
|
566
|
-
key: streamKey(shareKey, salt,
|
|
1255
|
+
key: streamKey(shareKey, salt, share, from, sub),
|
|
567
1256
|
stream: owned.stream,
|
|
568
1257
|
unsubscribe: () => { },
|
|
569
1258
|
ended: false,
|
|
@@ -571,14 +1260,280 @@ export class PeerNetwork {
|
|
|
571
1260
|
this.subs.set(sub, remote);
|
|
572
1261
|
this.sendBox(from, {
|
|
573
1262
|
t: 'watch_ok',
|
|
574
|
-
share
|
|
575
|
-
nonce
|
|
1263
|
+
share,
|
|
1264
|
+
nonce,
|
|
576
1265
|
sub,
|
|
577
1266
|
salt: salt.toString('base64url'),
|
|
1267
|
+
messages: owned.deliver !== undefined,
|
|
578
1268
|
});
|
|
579
|
-
|
|
580
|
-
this.
|
|
581
|
-
this.pump(remote,
|
|
1269
|
+
this.options.state.audit('remote_watch', share, JSON.stringify({ person: this.personOf(from), sub }));
|
|
1270
|
+
this.changed();
|
|
1271
|
+
this.pump(remote, after);
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* 첫 접속을 주인이 받아 줄 때까지 붙잡아 둔다. 보는 쪽에는 watch_wait로 알린다. 같은 사람이
|
|
1275
|
+
* 다시 청하면(주인이나 보는 쪽이 릴레이에 다시 붙은 경우) 시한은 그대로 두고 nonce만 바꾼다.
|
|
1276
|
+
*/
|
|
1277
|
+
holdForApproval(from, share, nonce, after) {
|
|
1278
|
+
const key = `${share}|${from}`;
|
|
1279
|
+
let p = this.pending.get(key);
|
|
1280
|
+
if (p) {
|
|
1281
|
+
p.nonce = nonce;
|
|
1282
|
+
p.after = after;
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
let waiting = 0;
|
|
1286
|
+
for (const q of this.pending.values())
|
|
1287
|
+
if (q.share === share)
|
|
1288
|
+
waiting++;
|
|
1289
|
+
if (waiting >= MAX_VIEWERS_PER_SHARE) {
|
|
1290
|
+
this.sendBox(from, { t: 'watch_err', share, nonce, reason: 'too_many' });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const at = Date.now();
|
|
1294
|
+
const timer = setTimeout(() => this.expireApproval(key), this.approvalTtl);
|
|
1295
|
+
timer.unref();
|
|
1296
|
+
// 긴 안내 문구는 처음 한동안만 붙이고 그 뒤로는 짧게 줄인다. 시각 비교는 타이머가 몇 ms 일찍
|
|
1297
|
+
// 울리면 어긋나서 울린 사실을 표시로 남기고 훅 파일을 다시 쓴다
|
|
1298
|
+
const shorten = setTimeout(() => {
|
|
1299
|
+
const q = this.pending.get(key);
|
|
1300
|
+
if (!q)
|
|
1301
|
+
return;
|
|
1302
|
+
q.brief = true;
|
|
1303
|
+
this.changed();
|
|
1304
|
+
}, this.approvalNotice);
|
|
1305
|
+
shorten.unref();
|
|
1306
|
+
p = {
|
|
1307
|
+
share,
|
|
1308
|
+
watcher: from,
|
|
1309
|
+
nonce,
|
|
1310
|
+
after,
|
|
1311
|
+
at,
|
|
1312
|
+
expiresAt: at + this.approvalTtl,
|
|
1313
|
+
timer,
|
|
1314
|
+
shorten,
|
|
1315
|
+
brief: false,
|
|
1316
|
+
};
|
|
1317
|
+
this.pending.set(key, p);
|
|
1318
|
+
this.options.state.audit('approval_request', share, JSON.stringify({ person: this.personOf(from) }));
|
|
1319
|
+
this.changed();
|
|
1320
|
+
}
|
|
1321
|
+
this.sendBox(from, { t: 'watch_wait', share, nonce, expiresAt: p.expiresAt });
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* 승인 대기 하나를 치운다. 타이머 둘을 풀고 훅 파일을 다시 쓰게 한다. reason을 주면 보는 쪽에
|
|
1325
|
+
* 그 이유로 거절을 알린다. 치운 대기를 돌려준다.
|
|
1326
|
+
*/
|
|
1327
|
+
dropApproval(key, reason) {
|
|
1328
|
+
const p = this.pending.get(key);
|
|
1329
|
+
if (!p)
|
|
1330
|
+
return undefined;
|
|
1331
|
+
clearTimeout(p.timer);
|
|
1332
|
+
clearTimeout(p.shorten);
|
|
1333
|
+
this.pending.delete(key);
|
|
1334
|
+
if (reason) {
|
|
1335
|
+
this.sendBox(p.watcher, { t: 'watch_err', share: p.share, nonce: p.nonce, reason });
|
|
1336
|
+
}
|
|
1337
|
+
this.changed();
|
|
1338
|
+
return p;
|
|
1339
|
+
}
|
|
1340
|
+
expireApproval(key) {
|
|
1341
|
+
const p = this.dropApproval(key, 'approval_timeout');
|
|
1342
|
+
if (!p)
|
|
1343
|
+
return;
|
|
1344
|
+
try {
|
|
1345
|
+
this.options.state.audit('approval_expire', p.share, JSON.stringify({ person: this.personOf(p.watcher) }));
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
// 데몬이 멈추는 중이면 DB가 이미 닫혔을 수 있다
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/** 보는 쪽이 승인을 기다리다 그만뒀다. 그 요청의 nonce가 맞을 때만 치운다 */
|
|
1352
|
+
onWatchCancel(from, msg) {
|
|
1353
|
+
if (typeof msg.share !== 'string' || typeof msg.nonce !== 'string')
|
|
1354
|
+
return;
|
|
1355
|
+
const key = `${msg.share}|${from}`;
|
|
1356
|
+
// 릴레이가 옛 취소를 다시 보내도 그 뒤에 새로 청한 대기는 안 지워진다
|
|
1357
|
+
if (this.pending.get(key)?.nonce !== msg.nonce)
|
|
1358
|
+
return;
|
|
1359
|
+
const p = this.dropApproval(key);
|
|
1360
|
+
this.options.state.audit('approval_cancel', p.share, JSON.stringify({ person: this.personOf(from) }));
|
|
1361
|
+
}
|
|
1362
|
+
grant(share, machine, by) {
|
|
1363
|
+
let map = this.approved.get(share);
|
|
1364
|
+
if (!map) {
|
|
1365
|
+
map = new Map();
|
|
1366
|
+
this.approved.set(share, map);
|
|
1367
|
+
}
|
|
1368
|
+
map.set(machine, by);
|
|
1369
|
+
this.options.state.audit('approval_grant', share, JSON.stringify({ person: this.personOf(machine), by }));
|
|
1370
|
+
}
|
|
1371
|
+
block(share, machine, reason) {
|
|
1372
|
+
let map = this.blocked.get(share);
|
|
1373
|
+
if (!map) {
|
|
1374
|
+
map = new Map();
|
|
1375
|
+
this.blocked.set(share, map);
|
|
1376
|
+
}
|
|
1377
|
+
map.set(machine, reason);
|
|
1378
|
+
this.approved.get(share)?.delete(machine);
|
|
1379
|
+
}
|
|
1380
|
+
/** 이 공유로 이 기기가 보낸 메시지를 막을 이유. 받을 수 있으면 undefined */
|
|
1381
|
+
messageRefusal(share, from) {
|
|
1382
|
+
const block = this.blocked.get(share)?.get(from);
|
|
1383
|
+
if (block)
|
|
1384
|
+
return block;
|
|
1385
|
+
if (this.firstWatchMode === 'ask' && !this.approved.get(share)?.has(from)) {
|
|
1386
|
+
return 'not_approved';
|
|
1387
|
+
}
|
|
1388
|
+
return undefined;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* 첫 접속 설정을 바꾼다. ask로 조이면 팀 설정으로 들어온 사람의 승인을 거두고 구독을 끝낸다.
|
|
1392
|
+
* 보는 쪽은 approval_revoked를 받고 다시 청해 승인 대기에 오른다. 거둔 구독 수를 돌려준다.
|
|
1393
|
+
*/
|
|
1394
|
+
setFirstWatch(mode) {
|
|
1395
|
+
this.firstWatchMode = mode;
|
|
1396
|
+
if (mode !== 'ask')
|
|
1397
|
+
return 0;
|
|
1398
|
+
let revoked = 0;
|
|
1399
|
+
for (const [share, map] of this.approved) {
|
|
1400
|
+
const machines = [...map].filter(([, by]) => by === 'team').map(([m]) => m);
|
|
1401
|
+
if (machines.length === 0)
|
|
1402
|
+
continue;
|
|
1403
|
+
for (const m of machines)
|
|
1404
|
+
map.delete(m);
|
|
1405
|
+
for (const sub of [...this.subs.values()]) {
|
|
1406
|
+
if (sub.share !== share || !machines.includes(sub.watcher))
|
|
1407
|
+
continue;
|
|
1408
|
+
this.endSub(sub, 'approval_revoked', true);
|
|
1409
|
+
revoked++;
|
|
1410
|
+
}
|
|
1411
|
+
this.options.state.audit('approval_revoke', share, JSON.stringify({ persons: unique(machines.map((m) => this.personOf(m))) }));
|
|
1412
|
+
}
|
|
1413
|
+
this.changed();
|
|
1414
|
+
return revoked;
|
|
1415
|
+
}
|
|
1416
|
+
/** 명단의 이름을 한 줄 라벨로. 감사 로그와 CLI, 훅 출력으로 나간다 */
|
|
1417
|
+
personOf(machine) {
|
|
1418
|
+
const person = this.roster ? memberOf(this.roster.roster, machine)?.person : undefined;
|
|
1419
|
+
return plainLabel(person, 32) ?? '?';
|
|
1420
|
+
}
|
|
1421
|
+
machinesOf(person) {
|
|
1422
|
+
if (!this.roster)
|
|
1423
|
+
throw new PeerNetworkError('아직 팀에 안 들었어요');
|
|
1424
|
+
const keys = this.roster.roster.members
|
|
1425
|
+
.filter((m) => m.person === person)
|
|
1426
|
+
.map((m) => m.machineKey);
|
|
1427
|
+
if (keys.length === 0)
|
|
1428
|
+
throw new PeerNetworkError(`명단에 없는 이름이에요: ${person}`);
|
|
1429
|
+
return keys;
|
|
1430
|
+
}
|
|
1431
|
+
changed() {
|
|
1432
|
+
try {
|
|
1433
|
+
this.options.onControlChange?.();
|
|
1434
|
+
}
|
|
1435
|
+
catch (err) {
|
|
1436
|
+
process.stderr.write(`[peer] control change failed: ${errMessage(err)}\n`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/** 주인 쪽: 공유 하나를 누가 보고 누가 기다리고 누가 막혔는지 */
|
|
1440
|
+
shareControl(share) {
|
|
1441
|
+
const watchers = new Set();
|
|
1442
|
+
for (const s of this.subs.values()) {
|
|
1443
|
+
if (s.share === share)
|
|
1444
|
+
watchers.add(this.personOf(s.watcher));
|
|
1445
|
+
}
|
|
1446
|
+
const pending = [];
|
|
1447
|
+
for (const p of this.pending.values()) {
|
|
1448
|
+
if (p.share === share) {
|
|
1449
|
+
pending.push({
|
|
1450
|
+
person: this.personOf(p.watcher),
|
|
1451
|
+
at: p.at,
|
|
1452
|
+
expiresAt: p.expiresAt,
|
|
1453
|
+
brief: p.brief,
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const blocked = [...(this.blocked.get(share) ?? new Map())].map(([machine, reason]) => ({ person: this.personOf(machine), reason }));
|
|
1458
|
+
return { watchers: [...watchers], pending, blocked };
|
|
1459
|
+
}
|
|
1460
|
+
/** 주인 쪽: 승인을 기다리는 그 사람의 요청을 받아 준다. 받아 준 기기는 공유가 열린 동안 기억한다 */
|
|
1461
|
+
approve(person, shares) {
|
|
1462
|
+
const hits = this.pendingOf(person, shares);
|
|
1463
|
+
for (const p of hits) {
|
|
1464
|
+
this.dropApproval(`${p.share}|${p.watcher}`);
|
|
1465
|
+
this.grant(p.share, p.watcher, 'owner');
|
|
1466
|
+
const owned = this.options.lookupShare(p.share);
|
|
1467
|
+
if (owned && !owned.stream.isClosed) {
|
|
1468
|
+
this.startSub(p.watcher, p.share, p.nonce, p.after, owned);
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
this.sendBox(p.watcher, {
|
|
1472
|
+
t: 'watch_err',
|
|
1473
|
+
share: p.share,
|
|
1474
|
+
nonce: p.nonce,
|
|
1475
|
+
reason: 'not_found',
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return { person, shares: unique(hits.map((p) => p.share)), count: hits.length };
|
|
1480
|
+
}
|
|
1481
|
+
/** 주인 쪽: 승인을 기다리는 그 사람의 요청을 거절한다. 그 공유가 열린 동안 다시 못 붙는다 */
|
|
1482
|
+
deny(person, shares) {
|
|
1483
|
+
const hits = this.pendingOf(person, shares);
|
|
1484
|
+
for (const p of hits) {
|
|
1485
|
+
this.block(p.share, p.watcher, 'denied');
|
|
1486
|
+
this.dropApproval(`${p.share}|${p.watcher}`, 'denied');
|
|
1487
|
+
this.options.state.audit('approval_deny', p.share, JSON.stringify({ person }));
|
|
1488
|
+
}
|
|
1489
|
+
return { person, shares: unique(hits.map((p) => p.share)), count: hits.length };
|
|
1490
|
+
}
|
|
1491
|
+
pendingOf(person, shares) {
|
|
1492
|
+
const machines = new Set(this.machinesOf(person));
|
|
1493
|
+
const hits = [...this.pending.values()].filter((p) => machines.has(p.watcher) && shares.includes(p.share));
|
|
1494
|
+
if (hits.length === 0)
|
|
1495
|
+
throw new PeerNetworkError(`승인을 기다리는 사람이 아니에요: ${person}`);
|
|
1496
|
+
return hits;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* 주인 쪽: 그 사람의 기기 전부를 공유에서 끊는다. 구독을 끝내고 그 구독 몫의 세션 키를 메모리에서
|
|
1500
|
+
* 지운다. 지금 안 보고 있어도 막아 둔다. 공유가 열린 동안 다시 붙지 못하고 메시지도 못 넣는다.
|
|
1501
|
+
*/
|
|
1502
|
+
kick(person, shares) {
|
|
1503
|
+
const machines = this.machinesOf(person);
|
|
1504
|
+
if (machines.includes(this.key.id))
|
|
1505
|
+
throw new PeerNetworkError('자기 자신은 끊을 수 없어요');
|
|
1506
|
+
let count = 0;
|
|
1507
|
+
for (const share of shares) {
|
|
1508
|
+
let ended = 0;
|
|
1509
|
+
for (const machine of machines) {
|
|
1510
|
+
this.block(share, machine, 'kicked');
|
|
1511
|
+
this.dropApproval(`${share}|${machine}`, 'kicked');
|
|
1512
|
+
}
|
|
1513
|
+
for (const sub of [...this.subs.values()]) {
|
|
1514
|
+
if (sub.share !== share || !machines.includes(sub.watcher))
|
|
1515
|
+
continue;
|
|
1516
|
+
this.endSub(sub, 'kicked', true);
|
|
1517
|
+
ended++;
|
|
1518
|
+
}
|
|
1519
|
+
this.options.state.audit('kick', share, JSON.stringify({ person, ended }));
|
|
1520
|
+
count += ended;
|
|
1521
|
+
}
|
|
1522
|
+
this.changed();
|
|
1523
|
+
return { person, shares, count };
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* 주인 쪽: 공유가 닫혔다. 승인을 기다리던 사람에게 알리고 그 공유의 승인과 막은 목록을 버린다.
|
|
1527
|
+
* 붙어 있던 구독은 공유 스트림의 closed 프레임으로 끝난다.
|
|
1528
|
+
*/
|
|
1529
|
+
forgetShare(share) {
|
|
1530
|
+
for (const [key, p] of [...this.pending]) {
|
|
1531
|
+
if (p.share === share)
|
|
1532
|
+
this.dropApproval(key, 'share_closed');
|
|
1533
|
+
}
|
|
1534
|
+
this.approved.delete(share);
|
|
1535
|
+
this.blocked.delete(share);
|
|
1536
|
+
this.changed();
|
|
582
1537
|
}
|
|
583
1538
|
/**
|
|
584
1539
|
* 처음 보는 nonce면 기억하고 true. 공유는 데몬이 다시 뜨면 닫히므로 메모리에만 둔다.
|
|
@@ -653,6 +1608,10 @@ export class PeerNetwork {
|
|
|
653
1608
|
this.endSub(remote, 'share_closed', false);
|
|
654
1609
|
}
|
|
655
1610
|
post(remote, frames) {
|
|
1611
|
+
// 끊긴 구독은 키를 지웠다. 지금 부르는 쪽(pump의 step, onLocalFrame)이 ended를 먼저 보지만
|
|
1612
|
+
// 호출부가 늘어도 지운 키로 봉인한 프레임이 나가지 않게 여기서도 막는다
|
|
1613
|
+
if (remote.ended)
|
|
1614
|
+
return;
|
|
656
1615
|
const env = {
|
|
657
1616
|
k: 's',
|
|
658
1617
|
share: remote.share,
|
|
@@ -667,6 +1626,9 @@ export class PeerNetwork {
|
|
|
667
1626
|
remote.ended = true;
|
|
668
1627
|
remote.unsubscribe();
|
|
669
1628
|
this.subs.delete(remote.sub);
|
|
1629
|
+
// 이 구독 몫의 세션 키를 메모리에서 지운다. 새 프레임은 ended를 보는 앞 경로가 거르고 post가 한 번 더 막는다
|
|
1630
|
+
remote.key.fill(0);
|
|
1631
|
+
this.changed();
|
|
670
1632
|
if (notify)
|
|
671
1633
|
this.sendBox(remote.watcher, {
|
|
672
1634
|
t: 'watch_end',
|
|
@@ -682,19 +1644,24 @@ export class PeerNetwork {
|
|
|
682
1644
|
}
|
|
683
1645
|
}
|
|
684
1646
|
// ── 보는 쪽 ──────────────────────────────────────────────
|
|
1647
|
+
/**
|
|
1648
|
+
* 주인 데몬에 보기를 청한다. watch_ok면 'ok', 주인 승인을 기다리면 'waiting'으로 끝난다.
|
|
1649
|
+
* 기다리는 동안 pending.nonce는 남겨 두고 나중에 오는 watch_ok나 watch_err를 그 nonce로 맞춘다.
|
|
1650
|
+
*/
|
|
685
1651
|
requestWatch(w) {
|
|
686
1652
|
return new Promise((resolve, reject) => {
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
w.
|
|
690
|
-
|
|
1653
|
+
this.dropPending(w, '다시 청해서 앞 요청을 버렸어요');
|
|
1654
|
+
if (w.retry)
|
|
1655
|
+
clearTimeout(w.retry);
|
|
1656
|
+
w.retry = undefined;
|
|
691
1657
|
const nonce = randomBytes(16).toString('base64url');
|
|
692
1658
|
const timer = setTimeout(() => {
|
|
693
|
-
w.pending
|
|
694
|
-
|
|
1659
|
+
if (w.pending?.nonce === nonce)
|
|
1660
|
+
w.pending = undefined;
|
|
1661
|
+
reject(new WatchTimeout('주인 데몬이 답하지 않았어요'));
|
|
695
1662
|
}, WATCH_TIMEOUT_MS);
|
|
696
1663
|
timer.unref();
|
|
697
|
-
w.pending = { nonce, resolve, reject, timer };
|
|
1664
|
+
w.pending = { nonce, settle: { resolve, reject, timer } };
|
|
698
1665
|
w.sub = undefined;
|
|
699
1666
|
w.key = undefined;
|
|
700
1667
|
const sent = this.sendBox(w.owner, {
|
|
@@ -711,41 +1678,114 @@ export class PeerNetwork {
|
|
|
711
1678
|
}
|
|
712
1679
|
});
|
|
713
1680
|
}
|
|
1681
|
+
dropPending(w, why) {
|
|
1682
|
+
const settle = w.pending?.settle;
|
|
1683
|
+
w.pending = undefined;
|
|
1684
|
+
if (!settle)
|
|
1685
|
+
return;
|
|
1686
|
+
clearTimeout(settle.timer);
|
|
1687
|
+
settle.reject(new PeerNetworkError(why));
|
|
1688
|
+
}
|
|
714
1689
|
rewatch(w) {
|
|
715
|
-
this.requestWatch(w)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1690
|
+
this.requestWatch(w)
|
|
1691
|
+
.then((r) => {
|
|
1692
|
+
if (r === 'waiting')
|
|
1693
|
+
w.feed.markWaiting();
|
|
1694
|
+
})
|
|
1695
|
+
.catch((err) => {
|
|
1696
|
+
if (err instanceof WatchRefused)
|
|
1697
|
+
return this.onRefused(w, err.reason);
|
|
1698
|
+
w.feed.markOffline();
|
|
1699
|
+
// 앞 요청을 버려서 실패한 건 새 요청이 이미 나갔으니 다시 걸지 않는다
|
|
1700
|
+
if (err instanceof WatchTimeout)
|
|
1701
|
+
this.scheduleRewatch(w);
|
|
722
1702
|
});
|
|
723
1703
|
}
|
|
1704
|
+
/**
|
|
1705
|
+
* 조금 뒤에 다시 청한다. 주인 데몬이 답하지 않았거나 too_many처럼 다시 청하면 되는 거절일 때다.
|
|
1706
|
+
* 이게 없으면 다음 presence가 올 때까지 보기가 offline에 멈춘다. 간격은 두 배씩 늘리고 횟수는
|
|
1707
|
+
* 구독이 열릴 때만 0으로 돌아간다. 상한에 닿으면 멈추고 주인이 릴레이에 다시 붙을 때 다시 청한다.
|
|
1708
|
+
*/
|
|
1709
|
+
scheduleRewatch(w) {
|
|
1710
|
+
if (w.retry || w.retries >= MAX_REWATCH_RETRIES || !this.watches.has(w.id))
|
|
1711
|
+
return;
|
|
1712
|
+
const delay = Math.min(MAX_REWATCH_DELAY_MS, 1000 * 2 ** w.retries);
|
|
1713
|
+
w.retries++;
|
|
1714
|
+
w.retry = setTimeout(() => {
|
|
1715
|
+
w.retry = undefined;
|
|
1716
|
+
if (this.watches.has(w.id) && !w.pending)
|
|
1717
|
+
this.rewatch(w);
|
|
1718
|
+
}, delay);
|
|
1719
|
+
w.retry.unref();
|
|
1720
|
+
}
|
|
1721
|
+
/** 주인 데몬이 보기를 거절했다. 다시 청해도 안 되는 이유면 보기를 끝낸다 */
|
|
1722
|
+
onRefused(w, reason) {
|
|
1723
|
+
if (!FINAL_REFUSALS.has(reason)) {
|
|
1724
|
+
w.feed.markOffline();
|
|
1725
|
+
this.scheduleRewatch(w);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
const closeAs = reason === 'not_found' || reason === 'bad_proof' ? 'share_closed' : reason;
|
|
1729
|
+
this.closeWatch(w, closeAs);
|
|
1730
|
+
}
|
|
724
1731
|
onWatchReply(from, msg) {
|
|
725
1732
|
const w = [...this.watches.values()].find((x) => x.owner === from && x.share === msg.share && x.pending?.nonce === msg.nonce);
|
|
726
|
-
if (!w || !w.pending)
|
|
1733
|
+
if (!w || !w.pending) {
|
|
1734
|
+
// 기다리다 그만둔 뒤 취소와 승인이 엇갈리면 맞는 보기가 없는 watch_ok가 온다. 주인 쪽에
|
|
1735
|
+
// 구독이 열린 채로 남지 않게 그 구독을 끊어 달라고 돌려보낸다
|
|
1736
|
+
if (msg.t === 'watch_ok' && typeof msg.sub === 'string' && isShareId(msg.share)) {
|
|
1737
|
+
this.sendBox(from, { t: 'unwatch', share: msg.share, sub: msg.sub });
|
|
1738
|
+
}
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const settle = w.pending.settle;
|
|
1742
|
+
if (msg.t === 'watch_wait') {
|
|
1743
|
+
w.feed.markWaiting();
|
|
1744
|
+
if (settle) {
|
|
1745
|
+
clearTimeout(settle.timer);
|
|
1746
|
+
w.pending.settle = undefined;
|
|
1747
|
+
settle.resolve('waiting');
|
|
1748
|
+
}
|
|
727
1749
|
return;
|
|
728
|
-
|
|
1750
|
+
}
|
|
729
1751
|
w.pending = undefined;
|
|
730
|
-
|
|
1752
|
+
if (settle)
|
|
1753
|
+
clearTimeout(settle.timer);
|
|
731
1754
|
if (msg.t === 'watch_err') {
|
|
732
|
-
|
|
1755
|
+
// 주인 데몬이 보낸 글자라 CLI와 뷰어에 찍히기 전에 정리한다
|
|
1756
|
+
const err = new WatchRefused(plainLabel(msg.reason, 64) ?? 'unknown');
|
|
1757
|
+
if (settle)
|
|
1758
|
+
settle.reject(err);
|
|
1759
|
+
else
|
|
1760
|
+
this.onRefused(w, err.reason);
|
|
733
1761
|
return;
|
|
734
1762
|
}
|
|
735
1763
|
if (typeof msg.sub !== 'string' || typeof msg.salt !== 'string') {
|
|
736
|
-
|
|
1764
|
+
const err = new PeerNetworkError('주인 데몬 답이 맞지 않아요');
|
|
1765
|
+
if (settle)
|
|
1766
|
+
settle.reject(err);
|
|
1767
|
+
else
|
|
1768
|
+
w.feed.markOffline();
|
|
737
1769
|
return;
|
|
738
1770
|
}
|
|
739
1771
|
w.sub = msg.sub;
|
|
1772
|
+
// 재청 횟수는 구독이 실제로 열렸을 때만 지운다. 거절 답에서 지우면 too_many가 이어질 때 간격이
|
|
1773
|
+
// 늘지 않고 상한에도 안 닿아 1초마다 끝없이 청한다
|
|
1774
|
+
w.retries = 0;
|
|
1775
|
+
w.messages = msg.messages === true;
|
|
740
1776
|
w.key = streamKey(w.shareKey, Buffer.from(msg.salt, 'base64url'), w.share, this.key.id, msg.sub);
|
|
741
|
-
|
|
1777
|
+
settle?.resolve('ok');
|
|
742
1778
|
}
|
|
743
1779
|
onWatchEnd(from, msg) {
|
|
744
1780
|
const w = [...this.watches.values()].find((x) => x.owner === from && x.sub === msg.sub);
|
|
745
1781
|
if (!w)
|
|
746
1782
|
return;
|
|
747
1783
|
w.sub = undefined;
|
|
1784
|
+
w.key?.fill(0);
|
|
748
1785
|
w.key = undefined;
|
|
1786
|
+
// 주인이 끊었으면 다시 청하지 않는다. 다시 청해도 주인 데몬이 kicked로 거절한다
|
|
1787
|
+
if (msg.reason === 'kicked')
|
|
1788
|
+
return this.closeWatch(w, 'kicked');
|
|
749
1789
|
this.rewatch(w);
|
|
750
1790
|
}
|
|
751
1791
|
onStream(from, env) {
|
|
@@ -774,11 +1814,25 @@ export class PeerNetwork {
|
|
|
774
1814
|
if (!this.watches.has(w.id))
|
|
775
1815
|
return;
|
|
776
1816
|
this.watches.delete(w.id);
|
|
777
|
-
if (w.
|
|
778
|
-
clearTimeout(w.
|
|
779
|
-
|
|
780
|
-
|
|
1817
|
+
if (w.retry)
|
|
1818
|
+
clearTimeout(w.retry);
|
|
1819
|
+
w.retry = undefined;
|
|
1820
|
+
// 승인을 기다리던 중이면 주인 쪽 대기가 시한까지 남지 않게 그 nonce로 취소를 보낸다
|
|
1821
|
+
if (w.pending && !w.sub) {
|
|
1822
|
+
this.sendBox(w.owner, { t: 'watch_cancel', share: w.share, nonce: w.pending.nonce });
|
|
781
1823
|
}
|
|
1824
|
+
this.dropPending(w, `보기가 끝났어요: ${reason}`);
|
|
1825
|
+
w.key?.fill(0);
|
|
1826
|
+
w.key = undefined;
|
|
1827
|
+
this.closedWatches.push({
|
|
1828
|
+
id: w.id,
|
|
1829
|
+
share: w.share,
|
|
1830
|
+
owner: w.feed.ownerPerson,
|
|
1831
|
+
events: w.feed.eventCount,
|
|
1832
|
+
reason,
|
|
1833
|
+
});
|
|
1834
|
+
if (this.closedWatches.length > MAX_CLOSED_WATCHES)
|
|
1835
|
+
this.closedWatches.shift();
|
|
782
1836
|
w.feed.close(reason);
|
|
783
1837
|
try {
|
|
784
1838
|
this.options.state.audit('watch_close', null, JSON.stringify({ watch: w.id, share: w.share, reason }));
|
|
@@ -800,6 +1854,28 @@ export class PeerNetwork {
|
|
|
800
1854
|
function watchProofText(share, watcher, nonce) {
|
|
801
1855
|
return canonical({ p: 'gestalt-peer-watch-v1', share, watcher, nonce });
|
|
802
1856
|
}
|
|
1857
|
+
/** 링크를 가진 쪽만 만드는 메시지 증명. 본문까지 넣어 릴레이가 내용을 바꾸지 못하게 한다 */
|
|
1858
|
+
function messageProofText(share, from, id, body) {
|
|
1859
|
+
return canonical({ p: 'gestalt-peer-msg-v1', share, from, id, body });
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* 답에 거는 기기 서명의 대상. 질문 id와 공유, 본문, 답한 기기, 시각을 한 덩어리로 묶어 어느 질문에
|
|
1863
|
+
* 무엇이라고 답했는지를 그 기기가 보증한다. 봉인 상자의 서명은 통로의 서명이라 따로 둔다.
|
|
1864
|
+
*/
|
|
1865
|
+
export function answerSignedText(share, question, body, device, at) {
|
|
1866
|
+
return canonical({ p: 'gestalt-peer-answer-v1', share, question, body, device, at });
|
|
1867
|
+
}
|
|
1868
|
+
function unique(items) {
|
|
1869
|
+
return [...new Set(items)];
|
|
1870
|
+
}
|
|
1871
|
+
function isMessageStatus(v) {
|
|
1872
|
+
return (v === 'queued' ||
|
|
1873
|
+
v === 'sent' ||
|
|
1874
|
+
v === 'arrived' ||
|
|
1875
|
+
v === 'inserted' ||
|
|
1876
|
+
v === 'delivered' ||
|
|
1877
|
+
v === 'rejected');
|
|
1878
|
+
}
|
|
803
1879
|
function streamKey(shareKey, salt, share, watcher, sub) {
|
|
804
1880
|
return deriveKey(shareKey, salt, `gestalt-peer-stream-v1|${share}|${watcher}|${sub}`);
|
|
805
1881
|
}
|