@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.
- 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 +20 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -1
- package/dist/src/cli/commands/peer.js +198 -12
- 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 +44 -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 +29 -0
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +29 -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 +24 -0
- package/dist/src/mcp/tools/peer.d.ts.map +1 -0
- package/dist/src/mcp/tools/peer.js +103 -0
- package/dist/src/mcp/tools/peer.js.map +1 -0
- package/dist/src/peer/codex.d.ts +57 -0
- package/dist/src/peer/codex.d.ts.map +1 -0
- package/dist/src/peer/codex.js +303 -0
- package/dist/src/peer/codex.js.map +1 -0
- package/dist/src/peer/daemon.d.ts +76 -1
- package/dist/src/peer/daemon.d.ts.map +1 -1
- package/dist/src/peer/daemon.js +511 -19
- package/dist/src/peer/daemon.js.map +1 -1
- package/dist/src/peer/errors.d.ts +3 -0
- package/dist/src/peer/errors.d.ts.map +1 -1
- package/dist/src/peer/errors.js +3 -0
- package/dist/src/peer/errors.js.map +1 -1
- package/dist/src/peer/excerpt.d.ts +47 -0
- package/dist/src/peer/excerpt.d.ts.map +1 -0
- package/dist/src/peer/excerpt.js +160 -0
- package/dist/src/peer/excerpt.js.map +1 -0
- package/dist/src/peer/feed.d.ts +35 -2
- package/dist/src/peer/feed.d.ts.map +1 -1
- package/dist/src/peer/feed.js +22 -0
- package/dist/src/peer/feed.js.map +1 -1
- package/dist/src/peer/inbox.d.ts +42 -0
- package/dist/src/peer/inbox.d.ts.map +1 -0
- package/dist/src/peer/inbox.js +96 -0
- package/dist/src/peer/inbox.js.map +1 -0
- package/dist/src/peer/locate.d.ts +5 -0
- package/dist/src/peer/locate.d.ts.map +1 -1
- package/dist/src/peer/locate.js +19 -2
- package/dist/src/peer/locate.js.map +1 -1
- package/dist/src/peer/message.d.ts +113 -0
- package/dist/src/peer/message.d.ts.map +1 -0
- package/dist/src/peer/message.js +183 -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 +229 -1
- package/dist/src/peer/network.d.ts.map +1 -1
- package/dist/src/peer/network.js +867 -44
- 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/peek.d.ts +79 -0
- package/dist/src/peer/peek.d.ts.map +1 -0
- package/dist/src/peer/peek.js +465 -0
- package/dist/src/peer/peek.js.map +1 -0
- package/dist/src/peer/peeks.d.ts +115 -0
- package/dist/src/peer/peeks.d.ts.map +1 -0
- package/dist/src/peer/peeks.js +262 -0
- package/dist/src/peer/peeks.js.map +1 -0
- package/dist/src/peer/protocol.d.ts +128 -4
- package/dist/src/peer/protocol.d.ts.map +1 -1
- package/dist/src/peer/protocol.js.map +1 -1
- package/dist/src/peer/questions.d.ts +168 -0
- package/dist/src/peer/questions.d.ts.map +1 -0
- package/dist/src/peer/questions.js +389 -0
- package/dist/src/peer/questions.js.map +1 -0
- 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 +167 -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 +32 -0
- package/dist/src/peer/transform.d.ts.map +1 -1
- package/dist/src/peer/transform.js +52 -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 +13 -1
- package/dist/src/peer/viewer-html.d.ts.map +1 -1
- package/dist/src/peer/viewer-html.js +328 -4
- package/dist/src/peer/viewer-html.js.map +1 -1
- package/dist/src/peer/viewer.d.ts +49 -1
- package/dist/src/peer/viewer.d.ts.map +1 -1
- package/dist/src/peer/viewer.js +133 -7
- 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,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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
1006
|
+
share,
|
|
565
1007
|
watcher: from,
|
|
566
|
-
key: streamKey(shareKey, salt,
|
|
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
|
|
575
|
-
nonce
|
|
1016
|
+
share,
|
|
1017
|
+
nonce,
|
|
576
1018
|
sub,
|
|
577
1019
|
salt: salt.toString('base64url'),
|
|
1020
|
+
messages: owned.deliver !== undefined,
|
|
578
1021
|
});
|
|
579
|
-
|
|
580
|
-
this.
|
|
581
|
-
this.pump(remote,
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
w.
|
|
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
|
|
694
|
-
|
|
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)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
1504
|
+
}
|
|
729
1505
|
w.pending = undefined;
|
|
730
|
-
|
|
1506
|
+
if (settle)
|
|
1507
|
+
clearTimeout(settle.timer);
|
|
731
1508
|
if (msg.t === 'watch_err') {
|
|
732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
778
|
-
clearTimeout(w.
|
|
779
|
-
|
|
780
|
-
|
|
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
|
}
|