@tienne/gestalt 0.112.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 +1 -1
- package/dist/package.json +1 -1
- package/dist/src/cli/commands/peer.d.ts +1 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -1
- package/dist/src/cli/commands/peer.js +11 -2
- 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 +1 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/mcp/schemas.d.ts +13 -7
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +11 -2
- package/dist/src/mcp/schemas.js.map +1 -1
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/peer.d.ts +6 -3
- package/dist/src/mcp/tools/peer.d.ts.map +1 -1
- package/dist/src/mcp/tools/peer.js +32 -0
- package/dist/src/mcp/tools/peer.js.map +1 -1
- package/dist/src/peer/codex.d.ts +8 -0
- package/dist/src/peer/codex.d.ts.map +1 -1
- package/dist/src/peer/codex.js +72 -9
- package/dist/src/peer/codex.js.map +1 -1
- package/dist/src/peer/daemon.d.ts +27 -3
- package/dist/src/peer/daemon.d.ts.map +1 -1
- package/dist/src/peer/daemon.js +166 -14
- 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 +11 -0
- 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 +13 -2
- package/dist/src/peer/inbox.d.ts.map +1 -1
- package/dist/src/peer/inbox.js +19 -3
- package/dist/src/peer/inbox.js.map +1 -1
- package/dist/src/peer/locate.d.ts +2 -0
- package/dist/src/peer/locate.d.ts.map +1 -1
- package/dist/src/peer/locate.js +15 -0
- package/dist/src/peer/locate.js.map +1 -1
- package/dist/src/peer/message.d.ts +10 -1
- package/dist/src/peer/message.d.ts.map +1 -1
- package/dist/src/peer/message.js +17 -5
- package/dist/src/peer/message.js.map +1 -1
- package/dist/src/peer/network.d.ts +44 -63
- package/dist/src/peer/network.d.ts.map +1 -1
- package/dist/src/peer/network.js +110 -363
- package/dist/src/peer/network.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 +34 -2
- 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/share.js +6 -2
- package/dist/src/peer/share.js.map +1 -1
- package/dist/src/peer/state.d.ts +1 -1
- package/dist/src/peer/state.d.ts.map +1 -1
- package/dist/src/peer/state.js +1 -1
- package/dist/src/peer/state.js.map +1 -1
- package/dist/src/peer/transform.d.ts +5 -0
- package/dist/src/peer/transform.d.ts.map +1 -1
- package/dist/src/peer/transform.js +16 -1
- package/dist/src/peer/transform.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 +178 -4
- package/dist/src/peer/viewer-html.js.map +1 -1
- package/dist/src/peer/viewer.d.ts +20 -1
- package/dist/src/peer/viewer.d.ts.map +1 -1
- package/dist/src/peer/viewer.js +35 -7
- package/dist/src/peer/viewer.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
package/dist/src/peer/network.js
CHANGED
|
@@ -2,13 +2,15 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { isRawKey } from './keys.js';
|
|
3
3
|
import { MirrorFeed } from './mirror.js';
|
|
4
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,
|
|
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';
|
|
9
|
-
import {
|
|
8
|
+
import { errMessage, PeerNetworkError } from './errors.js';
|
|
9
|
+
import { isMessageId, MESSAGE_RECEIVE_LIMIT, newMessageId, plainLabel, RateLimiter, sanitizeMessageBody, } from './message.js';
|
|
10
10
|
import { RelaySendError } from './transport.js';
|
|
11
11
|
import { DEFAULT_FIRST_WATCH_APPROVAL } from './protocol.js';
|
|
12
|
+
import { PeekDesk } from './peeks.js';
|
|
13
|
+
import { QuestionDesk } from './questions.js';
|
|
12
14
|
/** 초대 유효 시간. 설계 원문의 24시간 */
|
|
13
15
|
export const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
16
|
/** 원격 구독 하나가 릴레이 소켓에 밀어 둘 수 있는 양 */
|
|
@@ -26,18 +28,6 @@ const MAX_REFUSED_SENDERS = 1024;
|
|
|
26
28
|
export const APPROVAL_TTL_MS = 10 * 60 * 1000;
|
|
27
29
|
/** 승인 대기를 알리는 시간. 이 동안은 긴 안내 문구를 붙이고 지나면 훅이 짧은 한 줄만 붙인다 */
|
|
28
30
|
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
31
|
/** 끝난 보기를 `peer watches`에 남겨 두는 개수. 거절이나 kick으로 끝난 이유를 CLI에서 보게 한다 */
|
|
42
32
|
const MAX_CLOSED_WATCHES = 16;
|
|
43
33
|
/** 보는 쪽이 다시 청하지 않고 보기를 끝내는 거절 이유 */
|
|
@@ -49,8 +39,7 @@ const FINAL_REFUSALS = new Set([
|
|
|
49
39
|
'approval_timeout',
|
|
50
40
|
'share_closed',
|
|
51
41
|
]);
|
|
52
|
-
export
|
|
53
|
-
}
|
|
42
|
+
export { PeerNetworkError };
|
|
54
43
|
/** 주인 데몬이 시한 안에 watch에 답하지 않았다 */
|
|
55
44
|
export class WatchTimeout extends PeerNetworkError {
|
|
56
45
|
}
|
|
@@ -125,21 +114,10 @@ export class PeerNetwork {
|
|
|
125
114
|
rejectLimiter = new RateLimiter(MESSAGE_RECEIVE_LIMIT);
|
|
126
115
|
blocked = new Map();
|
|
127
116
|
closedWatches = [];
|
|
128
|
-
/** 주인
|
|
129
|
-
questions
|
|
130
|
-
/**
|
|
131
|
-
|
|
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);
|
|
117
|
+
/** 질문과 답. 주인 쪽 열린 질문도 여기 있다 */
|
|
118
|
+
questions;
|
|
119
|
+
/** 요약 요청과 답. 주인 쪽 알림과 보는 쪽 대기 요청도 여기 있다 */
|
|
120
|
+
peeks;
|
|
143
121
|
constructor(options) {
|
|
144
122
|
this.options = options;
|
|
145
123
|
this.team = options.state.getTeam();
|
|
@@ -147,6 +125,11 @@ export class PeerNetwork {
|
|
|
147
125
|
this.maxBuffered = options.maxBufferedBytes ?? MAX_REMOTE_BUFFERED_BYTES;
|
|
148
126
|
this.approvalTtl = options.approvalTtlMs ?? APPROVAL_TTL_MS;
|
|
149
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
|
+
});
|
|
150
133
|
this.firstWatchMode = options.state.getSettings().firstWatch ?? DEFAULT_FIRST_WATCH_APPROVAL;
|
|
151
134
|
}
|
|
152
135
|
get key() {
|
|
@@ -163,9 +146,8 @@ export class PeerNetwork {
|
|
|
163
146
|
clearTimeout(p.shorten);
|
|
164
147
|
}
|
|
165
148
|
this.pending.clear();
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.questions.clear();
|
|
149
|
+
this.questions.stop();
|
|
150
|
+
this.peeks.stop();
|
|
169
151
|
for (const w of this.watches.values())
|
|
170
152
|
if (w.retry)
|
|
171
153
|
clearTimeout(w.retry);
|
|
@@ -359,6 +341,10 @@ export class PeerNetwork {
|
|
|
359
341
|
this.transport?.publishRoster(signed);
|
|
360
342
|
return { removed, epoch: signed.roster.epoch };
|
|
361
343
|
}
|
|
344
|
+
/** 팀에서 쓰는 내 이름. 팀이 없으면 undefined */
|
|
345
|
+
get person() {
|
|
346
|
+
return this.team?.person;
|
|
347
|
+
}
|
|
362
348
|
/** 내 공유의 링크. 팀이 없으면 undefined */
|
|
363
349
|
linkFor(shareId, shareKey) {
|
|
364
350
|
if (!this.roster)
|
|
@@ -377,7 +363,9 @@ export class PeerNetwork {
|
|
|
377
363
|
return {
|
|
378
364
|
stream: w.feed,
|
|
379
365
|
token: w.token,
|
|
380
|
-
|
|
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) })),
|
|
381
369
|
// 승인을 기다리는 동안 연 뷰어도 받아 준 뒤에 입력창이 있어야 한다. 페이지는 열 때 한 번
|
|
382
370
|
// 그려지므로 기다리는 중에는 입력창을 두고 보내기는 sendMessage가 막는다
|
|
383
371
|
...(w.messages || w.feed.currentStatus === 'waiting'
|
|
@@ -643,15 +631,13 @@ export class PeerNetwork {
|
|
|
643
631
|
case 'msg_status':
|
|
644
632
|
return this.onMessageStatus(from, msg);
|
|
645
633
|
case 'ask':
|
|
646
|
-
return this.onAsk(from, msg);
|
|
647
634
|
case 'ask_close':
|
|
648
|
-
return this.onAskClose(from, msg);
|
|
649
635
|
case 'answer':
|
|
650
|
-
return void this.onAnswer(from, msg).catch((err) => {
|
|
651
|
-
process.stderr.write(`[peer] answer failed: ${errMessage(err)}\n`);
|
|
652
|
-
});
|
|
653
636
|
case 'answer_status':
|
|
654
|
-
return this.
|
|
637
|
+
return this.questions.receive(from, msg);
|
|
638
|
+
case 'peek':
|
|
639
|
+
case 'peek_reply':
|
|
640
|
+
return this.peeks.receive(from, msg);
|
|
655
641
|
default:
|
|
656
642
|
return;
|
|
657
643
|
}
|
|
@@ -732,7 +718,7 @@ export class PeerNetwork {
|
|
|
732
718
|
...(reason ? { reason } : {}),
|
|
733
719
|
});
|
|
734
720
|
};
|
|
735
|
-
const person =
|
|
721
|
+
const person = this.personOf(from);
|
|
736
722
|
const reject = (reason) => {
|
|
737
723
|
// 거절은 증명 없이도 탈 수 있는 길이 있어서 보낸 기기마다 상한을 둔다. 넘으면 답도 기록도 없이 버린다
|
|
738
724
|
if (!this.rejectLimiter.take(from))
|
|
@@ -762,7 +748,8 @@ export class PeerNetwork {
|
|
|
762
748
|
this.rememberRefused(sender, msg.id);
|
|
763
749
|
return reject('rate_limited');
|
|
764
750
|
}
|
|
765
|
-
|
|
751
|
+
const deliver = owned.deliver;
|
|
752
|
+
if (!deliver)
|
|
766
753
|
return reject('messages_off');
|
|
767
754
|
// deliver의 첫 await 전에 기억해야 동시에 온 같은 봉인이 둘 다 들어가지 않는다
|
|
768
755
|
this.seenMessages.add(dedupe);
|
|
@@ -783,29 +770,49 @@ export class PeerNetwork {
|
|
|
783
770
|
origin,
|
|
784
771
|
body: clean.text,
|
|
785
772
|
};
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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) {
|
|
792
794
|
let inserted = false;
|
|
793
795
|
let seenEarly = false;
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
+
}
|
|
802
809
|
if (!result.ok && !result.maybeWritten)
|
|
803
|
-
return
|
|
804
|
-
|
|
805
|
-
this.options.state.audit('message_insert', msg.share, JSON.stringify({ id: msg.id, person, ...(result.ok ? {} : { maybeWritten: true }) }));
|
|
810
|
+
return result;
|
|
811
|
+
on.inserted(!result.ok);
|
|
806
812
|
inserted = true;
|
|
807
813
|
if (seenEarly)
|
|
808
|
-
delivered();
|
|
814
|
+
on.delivered();
|
|
815
|
+
return result;
|
|
809
816
|
}
|
|
810
817
|
rememberRefused(sender, id) {
|
|
811
818
|
let ids = this.refusedMessages.get(sender);
|
|
@@ -837,312 +844,58 @@ export class PeerNetwork {
|
|
|
837
844
|
w.feed.setMessage(msg.id, { status: msg.status, ...(reason ? { reason } : {}) });
|
|
838
845
|
}
|
|
839
846
|
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* 주인 쪽: 주인 세션 모델의 질문을 그 세션 공유를 지금 보고 있는 팀원에게 보낸다. to를 주면 그
|
|
843
|
-
* 사람 기기만이다. 구독이 열린 기기만 받으므로 승인 전, 거절, kick된 기기에는 안 간다. 질문
|
|
844
|
-
* 글자는 부르는 쪽이 가린 뒤 넘긴다. 답은 나중에 onAnswer가 받아 주인 세션에 넣는다.
|
|
845
|
-
*/
|
|
847
|
+
/** 주인 쪽: 주인 세션 모델의 질문을 보낸다. 자세한 건 QuestionDesk.ask */
|
|
846
848
|
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
|
-
}
|
|
849
|
+
return this.questions.ask(shares, raw, to, ttlMs);
|
|
933
850
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
this.
|
|
947
|
-
|
|
948
|
-
|
|
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 }));
|
|
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),
|
|
1010
866
|
};
|
|
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
867
|
}
|
|
1049
|
-
/** 보는 쪽:
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
}
|
|
868
|
+
/** 보는 쪽: 보고 있는 공유의 요약을 주인 데몬에 청한다. 자세한 건 PeekDesk.peek */
|
|
869
|
+
peek(target, since) {
|
|
870
|
+
return this.peeks.peek(target, since);
|
|
1079
871
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
}
|
|
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
|
+
};
|
|
1094
885
|
}
|
|
1095
886
|
/**
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
* 서명하는 길이 없다"가 본다. 주인 데몬이 오프라인이면 릴레이가 메시지처럼 들고 있는다.
|
|
887
|
+
* 그 기기에 보낼 봉인을 지금 만든다. 명단에 없거나 릴레이에 안 붙어 있으면 바로 던진다. 돌려준 함수가
|
|
888
|
+
* 보낸다. queue면 받는 기기가 오프라인일 때 릴레이가 들고 있는다
|
|
1099
889
|
*/
|
|
1100
|
-
|
|
1101
|
-
const
|
|
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;
|
|
890
|
+
prepareSealed(to, msg, queue) {
|
|
891
|
+
const member = this.roster ? memberOf(this.roster.roster, to) : undefined;
|
|
1115
892
|
const t = this.transport;
|
|
1116
893
|
if (!member)
|
|
1117
894
|
throw new PeerNetworkError('공유 주인이 명단에 없어요');
|
|
1118
895
|
if (!t || t.state !== 'online')
|
|
1119
896
|
throw new PeerNetworkError('릴레이에 안 붙어 있어요');
|
|
1120
|
-
const
|
|
1121
|
-
|
|
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 };
|
|
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 } : {});
|
|
1146
899
|
}
|
|
1147
900
|
onJoinRequest(from, data) {
|
|
1148
901
|
try {
|
|
@@ -1455,7 +1208,7 @@ export class PeerNetwork {
|
|
|
1455
1208
|
}
|
|
1456
1209
|
}
|
|
1457
1210
|
const blocked = [...(this.blocked.get(share) ?? new Map())].map(([machine, reason]) => ({ person: this.personOf(machine), reason }));
|
|
1458
|
-
return { watchers: [...watchers], pending, blocked };
|
|
1211
|
+
return { watchers: [...watchers], pending, blocked, peeks: this.peeks.notices(share) };
|
|
1459
1212
|
}
|
|
1460
1213
|
/** 주인 쪽: 승인을 기다리는 그 사람의 요청을 받아 준다. 받아 준 기기는 공유가 열린 동안 기억한다 */
|
|
1461
1214
|
approve(person, shares) {
|
|
@@ -1533,6 +1286,7 @@ export class PeerNetwork {
|
|
|
1533
1286
|
}
|
|
1534
1287
|
this.approved.delete(share);
|
|
1535
1288
|
this.blocked.delete(share);
|
|
1289
|
+
this.peeks.forget(share);
|
|
1536
1290
|
this.changed();
|
|
1537
1291
|
}
|
|
1538
1292
|
/**
|
|
@@ -1858,13 +1612,6 @@ function watchProofText(share, watcher, nonce) {
|
|
|
1858
1612
|
function messageProofText(share, from, id, body) {
|
|
1859
1613
|
return canonical({ p: 'gestalt-peer-msg-v1', share, from, id, body });
|
|
1860
1614
|
}
|
|
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
1615
|
function unique(items) {
|
|
1869
1616
|
return [...new Set(items)];
|
|
1870
1617
|
}
|