@tienne/gestalt 0.109.0 → 0.111.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/dist/package.json +1 -1
- package/dist/src/cli/commands/peer.d.ts +30 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -1
- package/dist/src/cli/commands/peer.js +159 -4
- 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 +48 -2
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/digest/render-html.d.ts +5 -0
- package/dist/src/digest/render-html.d.ts.map +1 -1
- package/dist/src/digest/render-html.js +31 -10
- package/dist/src/digest/render-html.js.map +1 -1
- package/dist/src/digest/render-lens.d.ts +1 -1
- package/dist/src/digest/render-lens.d.ts.map +1 -1
- package/dist/src/digest/render-lens.js +17 -2
- package/dist/src/digest/render-lens.js.map +1 -1
- package/dist/src/digest/server.d.ts.map +1 -1
- package/dist/src/digest/server.js +7 -4
- package/dist/src/digest/server.js.map +1 -1
- package/dist/src/digest/theme-diary.d.ts +10 -0
- package/dist/src/digest/theme-diary.d.ts.map +1 -0
- package/dist/src/digest/theme-diary.js +111 -0
- package/dist/src/digest/theme-diary.js.map +1 -0
- package/dist/src/digest/viewer-js.d.ts +6 -0
- package/dist/src/digest/viewer-js.d.ts.map +1 -1
- package/dist/src/digest/viewer-js.js +78 -4
- package/dist/src/digest/viewer-js.js.map +1 -1
- package/dist/src/peer/daemon.d.ts +17 -5
- package/dist/src/peer/daemon.d.ts.map +1 -1
- package/dist/src/peer/daemon.js +124 -15
- package/dist/src/peer/daemon.js.map +1 -1
- package/dist/src/peer/feed.d.ts +103 -0
- package/dist/src/peer/feed.d.ts.map +1 -0
- package/dist/src/peer/feed.js +167 -0
- package/dist/src/peer/feed.js.map +1 -0
- package/dist/src/peer/index.d.ts +10 -0
- package/dist/src/peer/index.d.ts.map +1 -1
- package/dist/src/peer/index.js +5 -0
- package/dist/src/peer/index.js.map +1 -1
- package/dist/src/peer/keys.d.ts +26 -0
- package/dist/src/peer/keys.d.ts.map +1 -0
- package/dist/src/peer/keys.js +95 -0
- package/dist/src/peer/keys.js.map +1 -0
- package/dist/src/peer/lock.d.ts +32 -0
- package/dist/src/peer/lock.d.ts.map +1 -0
- package/dist/src/peer/lock.js +147 -0
- package/dist/src/peer/lock.js.map +1 -0
- package/dist/src/peer/mirror.d.ts +21 -0
- package/dist/src/peer/mirror.d.ts.map +1 -0
- package/dist/src/peer/mirror.js +68 -0
- package/dist/src/peer/mirror.js.map +1 -0
- package/dist/src/peer/network.d.ts +148 -0
- package/dist/src/peer/network.d.ts.map +1 -0
- package/dist/src/peer/network.js +809 -0
- package/dist/src/peer/network.js.map +1 -0
- package/dist/src/peer/protocol.d.ts +52 -1
- package/dist/src/peer/protocol.d.ts.map +1 -1
- package/dist/src/peer/protocol.js +1 -0
- package/dist/src/peer/protocol.js.map +1 -1
- package/dist/src/peer/relay.d.ts +70 -0
- package/dist/src/peer/relay.d.ts.map +1 -0
- package/dist/src/peer/relay.js +410 -0
- package/dist/src/peer/relay.js.map +1 -0
- package/dist/src/peer/roster.d.ts +89 -0
- package/dist/src/peer/roster.d.ts.map +1 -0
- package/dist/src/peer/roster.js +160 -0
- package/dist/src/peer/roster.js.map +1 -0
- package/dist/src/peer/seal.d.ts +37 -0
- package/dist/src/peer/seal.d.ts.map +1 -0
- package/dist/src/peer/seal.js +115 -0
- package/dist/src/peer/seal.js.map +1 -0
- package/dist/src/peer/share.d.ts +4 -86
- package/dist/src/peer/share.d.ts.map +1 -1
- package/dist/src/peer/share.js +6 -140
- package/dist/src/peer/share.js.map +1 -1
- package/dist/src/peer/state.d.ts +35 -4
- package/dist/src/peer/state.d.ts.map +1 -1
- package/dist/src/peer/state.js +65 -3
- package/dist/src/peer/state.js.map +1 -1
- package/dist/src/peer/transport.d.ts +89 -0
- package/dist/src/peer/transport.d.ts.map +1 -0
- package/dist/src/peer/transport.js +345 -0
- package/dist/src/peer/transport.js.map +1 -0
- package/dist/src/peer/viewer-html.d.ts.map +1 -1
- package/dist/src/peer/viewer-html.js +10 -2
- package/dist/src/peer/viewer-html.js.map +1 -1
- package/dist/src/peer/viewer.d.ts +2 -2
- package/dist/src/peer/viewer.d.ts.map +1 -1
- package/dist/src/peer/viewer.js.map +1 -1
- package/dist/src/peer/wire.d.ts +90 -0
- package/dist/src/peer/wire.d.ts.map +1 -0
- package/dist/src/peer/wire.js +10 -0
- package/dist/src/peer/wire.js.map +1 -0
- package/dist/src/peer/ws.d.ts +47 -0
- package/dist/src/peer/ws.d.ts.map +1 -0
- package/dist/src/peer/ws.js +308 -0
- package/dist/src/peer/ws.js.map +1 -0
- 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
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { isRawKey } from './keys.js';
|
|
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';
|
|
6
|
+
import { RelayTransport } from './transport.js';
|
|
7
|
+
import { MAX_VIEWERS_PER_SHARE, REPLAY_CHUNK_BYTES } from './viewer.js';
|
|
8
|
+
import { errMessage } from './errors.js';
|
|
9
|
+
/** 초대 유효 시간. 설계 원문의 24시간 */
|
|
10
|
+
export const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
/** 원격 구독 하나가 릴레이 소켓에 밀어 둘 수 있는 양 */
|
|
12
|
+
export const MAX_REMOTE_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const WATCH_TIMEOUT_MS = 10_000;
|
|
14
|
+
const WATCH_NONCE_TTL_MS = 10 * 60 * 1000;
|
|
15
|
+
const MAX_SEEN_WATCH = 4096;
|
|
16
|
+
export class PeerNetworkError extends Error {
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 팀, 명단, 릴레이 통로, 원격 공유를 묶는다. 데몬이 하나 들고 있다.
|
|
20
|
+
*
|
|
21
|
+
* 주인 쪽: 보는 사람이 링크의 공유 키로 증명을 보내면 명단을 확인하고 구독마다 새 공유
|
|
22
|
+
* 세션 키를 정해 그 키로 봉인한 프레임을 흘린다. 세션 키는 링크의 공유 키와 주인이 고른
|
|
23
|
+
* salt로 만든다. salt는 보는 사람 X25519 키에 봉인해 보내므로 링크만 가진 사람도,
|
|
24
|
+
* 릴레이도 못 연다.
|
|
25
|
+
*
|
|
26
|
+
* 보는 쪽: 받은 프레임을 MirrorFeed에 옮겨 로컬 뷰어로 보여준다.
|
|
27
|
+
*/
|
|
28
|
+
export class PeerNetwork {
|
|
29
|
+
options;
|
|
30
|
+
transport = null;
|
|
31
|
+
team;
|
|
32
|
+
roster;
|
|
33
|
+
subs = new Map();
|
|
34
|
+
watches = new Map();
|
|
35
|
+
online = new Set();
|
|
36
|
+
announced = [];
|
|
37
|
+
/** 최근 받은 watch 요청의 `기기|nonce`와 받은 시각. 릴레이가 옛 요청을 다시 보내면 거절한다 */
|
|
38
|
+
seenWatch = new Map();
|
|
39
|
+
maxBuffered;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.team = options.state.getTeam();
|
|
43
|
+
this.roster = options.state.getRoster();
|
|
44
|
+
this.maxBuffered = options.maxBufferedBytes ?? MAX_REMOTE_BUFFERED_BYTES;
|
|
45
|
+
}
|
|
46
|
+
get key() {
|
|
47
|
+
return this.options.key;
|
|
48
|
+
}
|
|
49
|
+
/** 팀이 있으면 릴레이에 붙기 시작한다. 첫 접속을 기다리지 않는다 */
|
|
50
|
+
start() {
|
|
51
|
+
if (this.team && this.roster && memberOf(this.roster.roster, this.key.id))
|
|
52
|
+
this.connect();
|
|
53
|
+
}
|
|
54
|
+
async stop() {
|
|
55
|
+
for (const s of [...this.subs.values()])
|
|
56
|
+
this.endSub(s, 'daemon_stop', false);
|
|
57
|
+
for (const w of [...this.watches.values()])
|
|
58
|
+
this.closeWatch(w, 'daemon_stop');
|
|
59
|
+
const t = this.transport;
|
|
60
|
+
this.transport = null;
|
|
61
|
+
await t?.close();
|
|
62
|
+
}
|
|
63
|
+
status() {
|
|
64
|
+
if (!this.team || !this.roster)
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
team: this.team.team,
|
|
68
|
+
person: this.team.person,
|
|
69
|
+
relay: this.roster.roster.relay,
|
|
70
|
+
epoch: this.roster.roster.epoch,
|
|
71
|
+
role: memberOf(this.roster.roster, this.key.id)?.role ?? null,
|
|
72
|
+
connection: this.transport?.state ?? 'idle',
|
|
73
|
+
remoteViewers: this.subs.size,
|
|
74
|
+
watches: this.watches.size,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
members() {
|
|
78
|
+
if (!this.roster)
|
|
79
|
+
throw new PeerNetworkError('아직 팀에 안 들었어요');
|
|
80
|
+
return this.roster.roster.members.map((m) => ({
|
|
81
|
+
person: m.person,
|
|
82
|
+
machineKey: m.machineKey,
|
|
83
|
+
role: m.role,
|
|
84
|
+
addedAt: m.addedAt,
|
|
85
|
+
online: m.machineKey === this.key.id
|
|
86
|
+
? this.transport?.state === 'online'
|
|
87
|
+
: this.online.has(m.machineKey),
|
|
88
|
+
me: m.machineKey === this.key.id,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
/** 팀을 만들고 첫 관리자가 된다 */
|
|
92
|
+
init(person, relay) {
|
|
93
|
+
if (this.team)
|
|
94
|
+
throw new PeerNetworkError(`이미 팀에 들어 있어요 (${this.team.team})`);
|
|
95
|
+
if (!isPersonName(person))
|
|
96
|
+
throw new PeerNetworkError('이름은 글자, 숫자, `.`, `_`, `-`로 32자까지예요');
|
|
97
|
+
if (!isRelayUrl(relay))
|
|
98
|
+
throw new PeerNetworkError('--relay는 ws:// 또는 wss:// 주소예요');
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const roster = {
|
|
101
|
+
v: 1,
|
|
102
|
+
team: this.key.id,
|
|
103
|
+
epoch: 1,
|
|
104
|
+
relay,
|
|
105
|
+
members: [
|
|
106
|
+
{ person, machineKey: this.key.id, boxKey: this.key.boxKey, role: 'admin', addedAt: now },
|
|
107
|
+
],
|
|
108
|
+
issuedAt: now,
|
|
109
|
+
issuer: this.key.id,
|
|
110
|
+
};
|
|
111
|
+
const team = { team: this.key.id, relay, person, anchor: this.key.id };
|
|
112
|
+
this.team = team;
|
|
113
|
+
this.options.state.setTeam(team);
|
|
114
|
+
this.applyRoster(signRoster(roster, this.key), 'local');
|
|
115
|
+
this.options.state.audit('team_init', null, JSON.stringify({ team: team.team, relay }));
|
|
116
|
+
this.connect();
|
|
117
|
+
return this.status();
|
|
118
|
+
}
|
|
119
|
+
/** 1회용 초대 코드를 만든다. 관리자만 */
|
|
120
|
+
invite(person, now = Date.now()) {
|
|
121
|
+
const roster = this.requireAdmin();
|
|
122
|
+
if (!isPersonName(person))
|
|
123
|
+
throw new PeerNetworkError('이름은 글자, 숫자, `.`, `_`, `-`로 32자까지예요');
|
|
124
|
+
const id = randomBytes(9).toString('base64url');
|
|
125
|
+
const secret = randomBytes(32).toString('base64url');
|
|
126
|
+
const expiresAt = now + INVITE_TTL_MS;
|
|
127
|
+
this.options.state.insertInvite({ id, secret, person, createdAt: now, expiresAt });
|
|
128
|
+
this.options.state.audit('invite_create', null, JSON.stringify({ invite: id, person }));
|
|
129
|
+
const code = encodeInvite({
|
|
130
|
+
v: 1,
|
|
131
|
+
team: roster.team,
|
|
132
|
+
relay: roster.relay,
|
|
133
|
+
admin: this.key.id,
|
|
134
|
+
adminBox: this.key.boxKey,
|
|
135
|
+
id,
|
|
136
|
+
secret,
|
|
137
|
+
person,
|
|
138
|
+
expiresAt,
|
|
139
|
+
});
|
|
140
|
+
return { code, expiresAt };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 초대를 받는다. 릴레이에 join 모드로 붙어 초대한 관리자에게 봉인한 요청을 넘기고 관리자가
|
|
144
|
+
* 나를 넣은 명단에 서명해 돌릴 때까지 기다린다.
|
|
145
|
+
*/
|
|
146
|
+
async join(code, timeoutMs = 60_000) {
|
|
147
|
+
const invite = decodeInvite(code);
|
|
148
|
+
if (this.team && this.roster && memberOf(this.roster.roster, this.key.id)) {
|
|
149
|
+
throw new PeerNetworkError(`이미 팀에 들어 있어요 (${this.team.team})`);
|
|
150
|
+
}
|
|
151
|
+
if (Date.now() >= invite.expiresAt)
|
|
152
|
+
throw new PeerNetworkError('초대 코드가 만료됐어요');
|
|
153
|
+
const t = new RelayTransport({
|
|
154
|
+
url: invite.relay,
|
|
155
|
+
team: invite.team,
|
|
156
|
+
key: this.key,
|
|
157
|
+
mode: 'join',
|
|
158
|
+
reconnect: false,
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
const roster = await new Promise((resolve, reject) => {
|
|
162
|
+
const timer = setTimeout(() => reject(new PeerNetworkError('관리자가 아직 명단에 서명하지 않았어요. 관리자 데몬이 떠 있는지 확인하고 다시 join해 주세요')), timeoutMs);
|
|
163
|
+
timer.unref();
|
|
164
|
+
const done = (fn) => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
fn();
|
|
167
|
+
};
|
|
168
|
+
t.on('welcome', (incoming) => {
|
|
169
|
+
const verdict = checkRosterUpdate(null, incoming, {
|
|
170
|
+
team: invite.team,
|
|
171
|
+
anchor: invite.admin,
|
|
172
|
+
});
|
|
173
|
+
if (!verdict.ok)
|
|
174
|
+
return done(() => reject(new PeerNetworkError(verdict.reason)));
|
|
175
|
+
if (!memberOf(incoming.roster, this.key.id)) {
|
|
176
|
+
return done(() => reject(new PeerNetworkError('받은 명단에 이 기기가 없어요')));
|
|
177
|
+
}
|
|
178
|
+
done(() => resolve(incoming));
|
|
179
|
+
});
|
|
180
|
+
t.on('pending', () => {
|
|
181
|
+
const body = {
|
|
182
|
+
t: 'join',
|
|
183
|
+
invite: invite.id,
|
|
184
|
+
proof: hmac(Buffer.from(invite.secret, 'base64url'), joinProofText(invite.id, this.key.id, this.key.boxKey)),
|
|
185
|
+
box: this.key.boxKey,
|
|
186
|
+
};
|
|
187
|
+
const box = sealBox(this.key, invite.admin, invite.adminBox, JSON.stringify(body));
|
|
188
|
+
t.sendJoin(invite.admin, Buffer.from(JSON.stringify(box)));
|
|
189
|
+
});
|
|
190
|
+
t.on('state', (s) => {
|
|
191
|
+
if (s === 'offline' || s === 'removed') {
|
|
192
|
+
done(() => reject(new PeerNetworkError('릴레이 연결이 끊겨 join을 못 마쳤어요')));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
t.connect(null).catch((err) => done(() => reject(err)));
|
|
196
|
+
});
|
|
197
|
+
const me = memberOf(roster.roster, this.key.id);
|
|
198
|
+
const team = {
|
|
199
|
+
team: invite.team,
|
|
200
|
+
relay: roster.roster.relay,
|
|
201
|
+
person: me.person,
|
|
202
|
+
anchor: invite.admin,
|
|
203
|
+
};
|
|
204
|
+
// 다른 팀에서 넘어오면 옛 명단을 버린다. 판 비교는 같은 팀 안에서만 뜻이 있다
|
|
205
|
+
if (this.team && this.team.team !== team.team)
|
|
206
|
+
this.roster = null;
|
|
207
|
+
this.team = team;
|
|
208
|
+
this.options.state.setTeam(team);
|
|
209
|
+
this.applyRoster(roster, 'join');
|
|
210
|
+
this.options.state.audit('team_join', null, JSON.stringify({ team: team.team, person: me.person }));
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
await t.close();
|
|
214
|
+
}
|
|
215
|
+
// 명단에서 빠져 removed로 굳은 통로나 옛 팀 통로는 다시 안 쓴다
|
|
216
|
+
const old = this.transport;
|
|
217
|
+
this.transport = null;
|
|
218
|
+
await old?.close();
|
|
219
|
+
this.connect();
|
|
220
|
+
return this.status();
|
|
221
|
+
}
|
|
222
|
+
/** 사람 이름으로 그 사람의 기기를 모두 뺀다. 관리자만 */
|
|
223
|
+
remove(person) {
|
|
224
|
+
const roster = this.requireAdmin();
|
|
225
|
+
const me = memberOf(roster, this.key.id);
|
|
226
|
+
if (person === me.person)
|
|
227
|
+
throw new PeerNetworkError('자기 자신은 뺄 수 없어요');
|
|
228
|
+
const keep = roster.members.filter((m) => m.person !== person);
|
|
229
|
+
const removed = roster.members.length - keep.length;
|
|
230
|
+
if (removed === 0)
|
|
231
|
+
throw new PeerNetworkError(`명단에 없는 이름이에요: ${person}`);
|
|
232
|
+
const signed = signRoster(nextRoster(roster, keep, this.key.id), this.key);
|
|
233
|
+
this.applyRoster(signed, 'local');
|
|
234
|
+
this.options.state.audit('member_remove', null, JSON.stringify({ person, removed, epoch: signed.roster.epoch }));
|
|
235
|
+
this.transport?.publishRoster(signed);
|
|
236
|
+
return { removed, epoch: signed.roster.epoch };
|
|
237
|
+
}
|
|
238
|
+
/** 내 공유의 링크. 팀이 없으면 undefined */
|
|
239
|
+
linkFor(shareId, shareKey) {
|
|
240
|
+
if (!this.roster)
|
|
241
|
+
return undefined;
|
|
242
|
+
return formatShareLink(this.roster.roster.relay, shareId, shareKey);
|
|
243
|
+
}
|
|
244
|
+
/** 지금 연 공유 id 전부를 릴레이에 알린다 */
|
|
245
|
+
announce(shareIds) {
|
|
246
|
+
this.announced = [...shareIds];
|
|
247
|
+
this.transport?.announce(this.announced);
|
|
248
|
+
}
|
|
249
|
+
lookupWatch(id) {
|
|
250
|
+
const w = this.watches.get(id);
|
|
251
|
+
return w ? { stream: w.feed, token: w.token } : undefined;
|
|
252
|
+
}
|
|
253
|
+
listWatches() {
|
|
254
|
+
return [...this.watches.values()].map((w) => ({
|
|
255
|
+
id: w.id,
|
|
256
|
+
share: w.share,
|
|
257
|
+
owner: w.feed.ownerPerson,
|
|
258
|
+
status: w.feed.currentStatus,
|
|
259
|
+
events: w.feed.eventCount,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
/** 링크로 원격 공유를 보기 시작한다. 주인 데몬이 받아 줄 때까지 기다린다 */
|
|
263
|
+
async watch(link) {
|
|
264
|
+
const parsed = parseShareLink(link);
|
|
265
|
+
const roster = this.roster;
|
|
266
|
+
if (!this.team || !roster)
|
|
267
|
+
throw new PeerNetworkError('아직 팀에 안 들었어요. `gestalt peer join <코드>`를 먼저 해 주세요');
|
|
268
|
+
const relayHost = new URL(roster.roster.relay).host;
|
|
269
|
+
if (parsed.relayHost !== relayHost) {
|
|
270
|
+
throw new PeerNetworkError(`우리 팀 릴레이(${relayHost})의 링크가 아니에요: ${parsed.relayHost}`);
|
|
271
|
+
}
|
|
272
|
+
const t = this.transport;
|
|
273
|
+
if (!t || t.state !== 'online')
|
|
274
|
+
throw new PeerNetworkError(`릴레이에 안 붙어 있어요 (${t?.state ?? 'idle'})`);
|
|
275
|
+
const owner = await t.resolveShare(parsed.shareId);
|
|
276
|
+
if (!owner)
|
|
277
|
+
throw new PeerNetworkError('그 공유를 연 기기가 릴레이에 없어요. 주인 데몬이 오프라인이거나 공유가 닫혔어요');
|
|
278
|
+
const ownerMember = memberOf(roster.roster, owner);
|
|
279
|
+
if (!ownerMember)
|
|
280
|
+
throw new PeerNetworkError('공유를 연 기기가 명단에 없어요');
|
|
281
|
+
if (owner === this.key.id)
|
|
282
|
+
throw new PeerNetworkError('내 공유는 `gestalt peer shares`의 주소로 봐요');
|
|
283
|
+
const id = randomBytes(6).toString('base64url');
|
|
284
|
+
const w = {
|
|
285
|
+
id,
|
|
286
|
+
token: randomBytes(24).toString('base64url'),
|
|
287
|
+
share: parsed.shareId,
|
|
288
|
+
shareKey: Buffer.from(parsed.shareKey, 'base64url'),
|
|
289
|
+
owner,
|
|
290
|
+
feed: new MirrorFeed(id, owner, ownerMember.person, parsed.shareId),
|
|
291
|
+
};
|
|
292
|
+
this.watches.set(id, w);
|
|
293
|
+
try {
|
|
294
|
+
await this.requestWatch(w);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
this.watches.delete(id);
|
|
298
|
+
w.feed.close('watch_failed');
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
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 };
|
|
303
|
+
}
|
|
304
|
+
unwatch(id) {
|
|
305
|
+
const w = this.watches.get(id);
|
|
306
|
+
if (!w)
|
|
307
|
+
return false;
|
|
308
|
+
if (w.sub)
|
|
309
|
+
this.sendBox(w.owner, { t: 'unwatch', share: w.share, sub: w.sub });
|
|
310
|
+
this.closeWatch(w, 'unwatch');
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
// ── 릴레이 ──────────────────────────────────────────────
|
|
314
|
+
connect() {
|
|
315
|
+
if (this.transport || !this.team || !this.roster)
|
|
316
|
+
return;
|
|
317
|
+
const t = new RelayTransport({
|
|
318
|
+
url: this.roster.roster.relay,
|
|
319
|
+
team: this.team.team,
|
|
320
|
+
key: this.key,
|
|
321
|
+
mode: 'member',
|
|
322
|
+
...this.options.transport,
|
|
323
|
+
});
|
|
324
|
+
this.transport = t;
|
|
325
|
+
t.announce(this.announced);
|
|
326
|
+
t.on('welcome', (roster, online) => this.onWelcome(roster, online));
|
|
327
|
+
t.on('roster', (roster) => this.onRemoteRoster(roster));
|
|
328
|
+
t.on('presence', (key, isOnline) => this.onPresence(key, isOnline));
|
|
329
|
+
t.on('join', (from, data) => this.onJoinRequest(from, data));
|
|
330
|
+
t.on('state', (s) => this.onTransportState(s));
|
|
331
|
+
t.onReceive((from, data) => this.onReceive(from, data));
|
|
332
|
+
t.connect(this.roster).catch((err) => {
|
|
333
|
+
process.stderr.write(`[peer] relay: ${errMessage(err)}\n`);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
onWelcome(roster, online) {
|
|
337
|
+
this.online = new Set(Array.isArray(online) ? online.filter(isRawKey) : []);
|
|
338
|
+
this.onRemoteRoster(roster);
|
|
339
|
+
// 다시 붙었으면 끊긴 동안 멈춘 구독을 다시 청한다
|
|
340
|
+
for (const w of [...this.watches.values()]) {
|
|
341
|
+
if (this.online.has(w.owner))
|
|
342
|
+
this.rewatch(w);
|
|
343
|
+
else
|
|
344
|
+
w.feed.markOffline();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
onTransportState(s) {
|
|
348
|
+
if (s === 'offline') {
|
|
349
|
+
this.online.clear();
|
|
350
|
+
for (const sub of [...this.subs.values()])
|
|
351
|
+
this.endSub(sub, 'relay_lost', false);
|
|
352
|
+
for (const w of this.watches.values())
|
|
353
|
+
w.feed.markOffline();
|
|
354
|
+
}
|
|
355
|
+
else if (s === 'removed') {
|
|
356
|
+
this.online.clear();
|
|
357
|
+
for (const sub of [...this.subs.values()])
|
|
358
|
+
this.endSub(sub, 'removed', false);
|
|
359
|
+
for (const w of [...this.watches.values()])
|
|
360
|
+
this.closeWatch(w, 'removed');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
onPresence(key, isOnline) {
|
|
364
|
+
if (!isRawKey(key))
|
|
365
|
+
return;
|
|
366
|
+
if (isOnline)
|
|
367
|
+
this.online.add(key);
|
|
368
|
+
else
|
|
369
|
+
this.online.delete(key);
|
|
370
|
+
for (const sub of [...this.subs.values()]) {
|
|
371
|
+
if (!isOnline && sub.watcher === key)
|
|
372
|
+
this.endSub(sub, 'watcher_offline', false);
|
|
373
|
+
}
|
|
374
|
+
for (const w of [...this.watches.values()]) {
|
|
375
|
+
if (w.owner !== key)
|
|
376
|
+
continue;
|
|
377
|
+
if (isOnline)
|
|
378
|
+
this.rewatch(w);
|
|
379
|
+
else
|
|
380
|
+
w.feed.markOffline();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
onRemoteRoster(incoming) {
|
|
384
|
+
if (!this.team)
|
|
385
|
+
return;
|
|
386
|
+
const current = this.roster;
|
|
387
|
+
if (current && current.sig === incoming?.sig)
|
|
388
|
+
return;
|
|
389
|
+
this.applyRoster(incoming, 'relay');
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* 명단을 받는다. 서명이 안 맞거나 판이 낮으면 버리고 감사 로그에 남긴다. 받으면 명단에서
|
|
393
|
+
* 빠진 기기의 원격 구독을 바로 끊는다.
|
|
394
|
+
*/
|
|
395
|
+
applyRoster(incoming, source) {
|
|
396
|
+
const team = this.team;
|
|
397
|
+
const verdict = checkRosterUpdate(this.roster, incoming, {
|
|
398
|
+
team: team.team,
|
|
399
|
+
anchor: team.anchor,
|
|
400
|
+
});
|
|
401
|
+
if (!verdict.ok) {
|
|
402
|
+
this.options.state.audit('roster_reject', null, JSON.stringify({ source, reason: verdict.reason, epoch: incoming?.roster?.epoch ?? null }));
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
this.roster = incoming;
|
|
406
|
+
this.options.state.setRoster(incoming);
|
|
407
|
+
this.transport?.setRoster(incoming);
|
|
408
|
+
this.options.state.audit('roster_accept', null, JSON.stringify({ source, epoch: incoming.roster.epoch }));
|
|
409
|
+
const r = incoming.roster;
|
|
410
|
+
for (const sub of [...this.subs.values()]) {
|
|
411
|
+
if (!memberOf(r, sub.watcher))
|
|
412
|
+
this.endSub(sub, 'removed', false);
|
|
413
|
+
}
|
|
414
|
+
for (const w of [...this.watches.values()]) {
|
|
415
|
+
if (!memberOf(r, w.owner))
|
|
416
|
+
this.closeWatch(w, 'owner_removed');
|
|
417
|
+
}
|
|
418
|
+
if (!memberOf(r, this.key.id)) {
|
|
419
|
+
for (const w of [...this.watches.values()])
|
|
420
|
+
this.closeWatch(w, 'removed');
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
requireAdmin() {
|
|
425
|
+
if (!this.roster)
|
|
426
|
+
throw new PeerNetworkError('아직 팀에 안 들었어요');
|
|
427
|
+
if (!isAdmin(this.roster.roster, this.key.id))
|
|
428
|
+
throw new PeerNetworkError('관리자만 할 수 있어요');
|
|
429
|
+
return this.roster.roster;
|
|
430
|
+
}
|
|
431
|
+
// ── 받기 ──────────────────────────────────────────────
|
|
432
|
+
onReceive(from, data) {
|
|
433
|
+
let env;
|
|
434
|
+
try {
|
|
435
|
+
env = JSON.parse(Buffer.from(data).toString('utf8'));
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (typeof env !== 'object' || env === null)
|
|
441
|
+
return;
|
|
442
|
+
const kind = env.k;
|
|
443
|
+
if (kind === 's')
|
|
444
|
+
return this.onStream(from, env);
|
|
445
|
+
if (kind !== 'box')
|
|
446
|
+
return;
|
|
447
|
+
// 명단 밖 기기가 보낸 건 열지도 않는다
|
|
448
|
+
if (!this.roster || !memberOf(this.roster.roster, from))
|
|
449
|
+
return;
|
|
450
|
+
const plain = openBox(this.key, from, env);
|
|
451
|
+
if (plain === null)
|
|
452
|
+
return;
|
|
453
|
+
let msg;
|
|
454
|
+
try {
|
|
455
|
+
msg = JSON.parse(plain);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
switch (msg.t) {
|
|
461
|
+
case 'watch':
|
|
462
|
+
return this.onWatchRequest(from, msg);
|
|
463
|
+
case 'watch_ok':
|
|
464
|
+
case 'watch_err':
|
|
465
|
+
return this.onWatchReply(from, msg);
|
|
466
|
+
case 'watch_end':
|
|
467
|
+
return this.onWatchEnd(from, msg);
|
|
468
|
+
case 'unwatch': {
|
|
469
|
+
const sub = this.subs.get(msg.sub);
|
|
470
|
+
if (sub && sub.watcher === from)
|
|
471
|
+
this.endSub(sub, 'unwatch', false);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
default:
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
onJoinRequest(from, data) {
|
|
479
|
+
try {
|
|
480
|
+
this.handleJoin(from, data);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
process.stderr.write(`[peer] join failed: ${errMessage(err)}\n`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
handleJoin(from, data) {
|
|
487
|
+
if (!this.roster || !isAdmin(this.roster.roster, this.key.id) || !isRawKey(from))
|
|
488
|
+
return;
|
|
489
|
+
let env;
|
|
490
|
+
try {
|
|
491
|
+
env = JSON.parse(Buffer.from(data).toString('utf8'));
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const plain = openBox(this.key, from, env);
|
|
497
|
+
if (plain === null)
|
|
498
|
+
return;
|
|
499
|
+
const msg = JSON.parse(plain);
|
|
500
|
+
if (msg.t !== 'join' || typeof msg.invite !== 'string' || !isRawKey(msg.box))
|
|
501
|
+
return;
|
|
502
|
+
const invite = this.options.state.getInvite(msg.invite);
|
|
503
|
+
if (!invite || Date.now() >= invite.expiresAt)
|
|
504
|
+
return;
|
|
505
|
+
const expected = hmac(Buffer.from(invite.secret, 'base64url'), joinProofText(invite.id, from, msg.box));
|
|
506
|
+
if (!safeEqual(msg.proof, expected))
|
|
507
|
+
return;
|
|
508
|
+
const roster = this.roster.roster;
|
|
509
|
+
if (memberOf(roster, from)) {
|
|
510
|
+
// 같은 초대로 다시 왔다. 이미 넣었으니 명단만 다시 돌린다
|
|
511
|
+
this.transport?.publishRoster(this.roster);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// 이미 쓴 초대다. 명단에 없으면 그사이 뺀 기기라 다시 넣지 않는다. 봉인에 시각이 없어서
|
|
515
|
+
// 옛 join 요청을 그대로 다시 보내도 증명은 맞는다
|
|
516
|
+
if (invite.usedBy !== null)
|
|
517
|
+
return;
|
|
518
|
+
const member = {
|
|
519
|
+
person: invite.person,
|
|
520
|
+
machineKey: from,
|
|
521
|
+
boxKey: msg.box,
|
|
522
|
+
role: 'member',
|
|
523
|
+
addedAt: Date.now(),
|
|
524
|
+
};
|
|
525
|
+
const signed = signRoster(nextRoster(roster, [...roster.members, member], this.key.id), this.key);
|
|
526
|
+
if (!this.applyRoster(signed, 'local'))
|
|
527
|
+
return;
|
|
528
|
+
this.options.state.useInvite(invite.id, from);
|
|
529
|
+
this.options.state.audit('member_join', null, JSON.stringify({ person: invite.person, invite: invite.id, epoch: signed.roster.epoch }));
|
|
530
|
+
this.transport?.publishRoster(signed);
|
|
531
|
+
}
|
|
532
|
+
// ── 주인 쪽 ──────────────────────────────────────────────
|
|
533
|
+
onWatchRequest(from, msg) {
|
|
534
|
+
const reply = (reason) => {
|
|
535
|
+
this.sendBox(from, {
|
|
536
|
+
t: 'watch_err',
|
|
537
|
+
share: String(msg.share),
|
|
538
|
+
nonce: String(msg.nonce),
|
|
539
|
+
reason,
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
if (typeof msg.share !== 'string' || typeof msg.nonce !== 'string')
|
|
543
|
+
return;
|
|
544
|
+
const owned = this.options.lookupShare(msg.share);
|
|
545
|
+
if (!owned || owned.stream.isClosed)
|
|
546
|
+
return reply('not_found');
|
|
547
|
+
const shareKey = Buffer.from(owned.shareKey, 'base64url');
|
|
548
|
+
if (!safeEqual(msg.proof, hmac(shareKey, watchProofText(msg.share, from, msg.nonce)))) {
|
|
549
|
+
return reply('bad_proof');
|
|
550
|
+
}
|
|
551
|
+
if (!this.rememberWatchNonce(from, msg.nonce))
|
|
552
|
+
return reply('replay');
|
|
553
|
+
// 같은 사람이 다시 청하면 앞의 구독을 바꾼다. 보는 쪽이 모르는 교체일 수 있어서 알린다
|
|
554
|
+
for (const s of [...this.subs.values()]) {
|
|
555
|
+
if (s.watcher === from && s.share === msg.share)
|
|
556
|
+
this.endSub(s, 'replaced', true);
|
|
557
|
+
}
|
|
558
|
+
if (owned.stream.viewerCount >= MAX_VIEWERS_PER_SHARE)
|
|
559
|
+
return reply('too_many');
|
|
560
|
+
const sub = randomBytes(9).toString('base64url');
|
|
561
|
+
const salt = randomBytes(32);
|
|
562
|
+
const remote = {
|
|
563
|
+
sub,
|
|
564
|
+
share: msg.share,
|
|
565
|
+
watcher: from,
|
|
566
|
+
key: streamKey(shareKey, salt, msg.share, from, sub),
|
|
567
|
+
stream: owned.stream,
|
|
568
|
+
unsubscribe: () => { },
|
|
569
|
+
ended: false,
|
|
570
|
+
};
|
|
571
|
+
this.subs.set(sub, remote);
|
|
572
|
+
this.sendBox(from, {
|
|
573
|
+
t: 'watch_ok',
|
|
574
|
+
share: msg.share,
|
|
575
|
+
nonce: msg.nonce,
|
|
576
|
+
sub,
|
|
577
|
+
salt: salt.toString('base64url'),
|
|
578
|
+
});
|
|
579
|
+
const person = this.roster ? memberOf(this.roster.roster, from)?.person : undefined;
|
|
580
|
+
this.options.state.audit('remote_watch', msg.share, JSON.stringify({ person: person ?? null, sub }));
|
|
581
|
+
this.pump(remote, Number.isSafeInteger(msg.after) && msg.after > 0 ? msg.after : 0);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* 처음 보는 nonce면 기억하고 true. 공유는 데몬이 다시 뜨면 닫히므로 메모리에만 둔다.
|
|
585
|
+
* 10분 지난 것과 상한을 넘는 오래된 것부터 잊는다.
|
|
586
|
+
*/
|
|
587
|
+
rememberWatchNonce(from, nonce, now = Date.now()) {
|
|
588
|
+
const key = `${from}|${nonce}`;
|
|
589
|
+
if (this.seenWatch.has(key))
|
|
590
|
+
return false;
|
|
591
|
+
for (const [k, at] of this.seenWatch) {
|
|
592
|
+
if (now - at < WATCH_NONCE_TTL_MS && this.seenWatch.size < MAX_SEEN_WATCH)
|
|
593
|
+
break;
|
|
594
|
+
this.seenWatch.delete(k);
|
|
595
|
+
}
|
|
596
|
+
this.seenWatch.set(key, now);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
/** 뷰어 서버의 pump와 같다. 릴레이 소켓이 비는 만큼씩 앞부분을 보내고 다 따라잡으면 바로 붙는다 */
|
|
600
|
+
pump(remote, after) {
|
|
601
|
+
const stream = remote.stream;
|
|
602
|
+
let cursor = after;
|
|
603
|
+
const step = () => {
|
|
604
|
+
if (remote.ended)
|
|
605
|
+
return;
|
|
606
|
+
const t = this.transport;
|
|
607
|
+
if (!t || t.state !== 'online')
|
|
608
|
+
return this.endSub(remote, 'relay_lost', false);
|
|
609
|
+
while (!stream.isClosed) {
|
|
610
|
+
if (t.bufferedAmount > this.maxBuffered) {
|
|
611
|
+
setTimeout(step, 20).unref();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const { dropped, items } = stream.backlogAfter(cursor, REPLAY_CHUNK_BYTES);
|
|
615
|
+
const frames = [];
|
|
616
|
+
if (dropped > 0) {
|
|
617
|
+
frames.push({ type: 'trimmed', dropped });
|
|
618
|
+
cursor += dropped;
|
|
619
|
+
}
|
|
620
|
+
if (items.length === 0) {
|
|
621
|
+
if (frames.length > 0)
|
|
622
|
+
this.post(remote, frames);
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
for (const item of items) {
|
|
626
|
+
frames.push({ type: 'event', ...item });
|
|
627
|
+
cursor = item.seq;
|
|
628
|
+
}
|
|
629
|
+
this.post(remote, frames);
|
|
630
|
+
}
|
|
631
|
+
remote.unsubscribe = stream.subscribe((frame) => this.onLocalFrame(remote, frame), cursor);
|
|
632
|
+
if (remote.ended)
|
|
633
|
+
remote.unsubscribe();
|
|
634
|
+
};
|
|
635
|
+
this.post(remote, [
|
|
636
|
+
{ type: 'state', status: stream.currentStatus },
|
|
637
|
+
{ type: 'stats', unknownLines: stream.unknownLines },
|
|
638
|
+
]);
|
|
639
|
+
step();
|
|
640
|
+
}
|
|
641
|
+
onLocalFrame(remote, frame) {
|
|
642
|
+
if (remote.ended)
|
|
643
|
+
return;
|
|
644
|
+
const t = this.transport;
|
|
645
|
+
if (!t || t.state !== 'online')
|
|
646
|
+
return this.endSub(remote, 'relay_lost', false);
|
|
647
|
+
if (frame.type !== 'closed' && t.bufferedAmount > this.maxBuffered) {
|
|
648
|
+
// 이어 받기는 보는 쪽이 다시 청해서 한다
|
|
649
|
+
return this.endSub(remote, 'slow', true);
|
|
650
|
+
}
|
|
651
|
+
this.post(remote, [frame]);
|
|
652
|
+
if (frame.type === 'closed')
|
|
653
|
+
this.endSub(remote, 'share_closed', false);
|
|
654
|
+
}
|
|
655
|
+
post(remote, frames) {
|
|
656
|
+
const env = {
|
|
657
|
+
k: 's',
|
|
658
|
+
share: remote.share,
|
|
659
|
+
sub: remote.sub,
|
|
660
|
+
d: aeadSeal(remote.key, Buffer.from(JSON.stringify(frames)), streamAad(remote.share, remote.sub, remote.watcher)),
|
|
661
|
+
};
|
|
662
|
+
this.transport?.post(remote.watcher, Buffer.from(JSON.stringify(env)));
|
|
663
|
+
}
|
|
664
|
+
endSub(remote, reason, notify) {
|
|
665
|
+
if (remote.ended)
|
|
666
|
+
return;
|
|
667
|
+
remote.ended = true;
|
|
668
|
+
remote.unsubscribe();
|
|
669
|
+
this.subs.delete(remote.sub);
|
|
670
|
+
if (notify)
|
|
671
|
+
this.sendBox(remote.watcher, {
|
|
672
|
+
t: 'watch_end',
|
|
673
|
+
share: remote.share,
|
|
674
|
+
sub: remote.sub,
|
|
675
|
+
reason,
|
|
676
|
+
});
|
|
677
|
+
try {
|
|
678
|
+
this.options.state.audit('remote_watch_end', remote.share, JSON.stringify({ sub: remote.sub, reason }));
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// 데몬이 멈추는 중이면 DB가 이미 닫혔을 수 있다
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// ── 보는 쪽 ──────────────────────────────────────────────
|
|
685
|
+
requestWatch(w) {
|
|
686
|
+
return new Promise((resolve, reject) => {
|
|
687
|
+
if (w.pending) {
|
|
688
|
+
clearTimeout(w.pending.timer);
|
|
689
|
+
w.pending.reject(new PeerNetworkError('다시 청해서 앞 요청을 버렸어요'));
|
|
690
|
+
}
|
|
691
|
+
const nonce = randomBytes(16).toString('base64url');
|
|
692
|
+
const timer = setTimeout(() => {
|
|
693
|
+
w.pending = undefined;
|
|
694
|
+
reject(new PeerNetworkError('주인 데몬이 답하지 않았어요'));
|
|
695
|
+
}, WATCH_TIMEOUT_MS);
|
|
696
|
+
timer.unref();
|
|
697
|
+
w.pending = { nonce, resolve, reject, timer };
|
|
698
|
+
w.sub = undefined;
|
|
699
|
+
w.key = undefined;
|
|
700
|
+
const sent = this.sendBox(w.owner, {
|
|
701
|
+
t: 'watch',
|
|
702
|
+
share: w.share,
|
|
703
|
+
nonce,
|
|
704
|
+
proof: hmac(w.shareKey, watchProofText(w.share, this.key.id, nonce)),
|
|
705
|
+
after: w.feed.eventCount,
|
|
706
|
+
});
|
|
707
|
+
if (!sent) {
|
|
708
|
+
clearTimeout(timer);
|
|
709
|
+
w.pending = undefined;
|
|
710
|
+
reject(new PeerNetworkError('릴레이에 안 붙어 있어요'));
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
rewatch(w) {
|
|
715
|
+
this.requestWatch(w).catch((err) => {
|
|
716
|
+
if (err instanceof PeerNetworkError && /not_found|bad_proof/.test(err.message)) {
|
|
717
|
+
this.closeWatch(w, 'share_closed');
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
w.feed.markOffline();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
onWatchReply(from, msg) {
|
|
725
|
+
const w = [...this.watches.values()].find((x) => x.owner === from && x.share === msg.share && x.pending?.nonce === msg.nonce);
|
|
726
|
+
if (!w || !w.pending)
|
|
727
|
+
return;
|
|
728
|
+
const pending = w.pending;
|
|
729
|
+
w.pending = undefined;
|
|
730
|
+
clearTimeout(pending.timer);
|
|
731
|
+
if (msg.t === 'watch_err') {
|
|
732
|
+
pending.reject(new PeerNetworkError(`주인 데몬이 거절했어요: ${msg.reason}`));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (typeof msg.sub !== 'string' || typeof msg.salt !== 'string') {
|
|
736
|
+
pending.reject(new PeerNetworkError('주인 데몬 답이 맞지 않아요'));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
w.sub = msg.sub;
|
|
740
|
+
w.key = streamKey(w.shareKey, Buffer.from(msg.salt, 'base64url'), w.share, this.key.id, msg.sub);
|
|
741
|
+
pending.resolve();
|
|
742
|
+
}
|
|
743
|
+
onWatchEnd(from, msg) {
|
|
744
|
+
const w = [...this.watches.values()].find((x) => x.owner === from && x.sub === msg.sub);
|
|
745
|
+
if (!w)
|
|
746
|
+
return;
|
|
747
|
+
w.sub = undefined;
|
|
748
|
+
w.key = undefined;
|
|
749
|
+
this.rewatch(w);
|
|
750
|
+
}
|
|
751
|
+
onStream(from, env) {
|
|
752
|
+
if (typeof env.share !== 'string' || typeof env.sub !== 'string')
|
|
753
|
+
return;
|
|
754
|
+
const w = [...this.watches.values()].find((x) => x.owner === from && x.sub === env.sub && x.share === env.share);
|
|
755
|
+
if (!w || !w.key)
|
|
756
|
+
return;
|
|
757
|
+
const plain = aeadOpen(w.key, env.d, streamAad(env.share, env.sub, this.key.id));
|
|
758
|
+
if (!plain)
|
|
759
|
+
return;
|
|
760
|
+
let frames;
|
|
761
|
+
try {
|
|
762
|
+
frames = JSON.parse(plain.toString('utf8'));
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (!Array.isArray(frames))
|
|
768
|
+
return;
|
|
769
|
+
w.feed.apply(frames);
|
|
770
|
+
if (w.feed.isClosed)
|
|
771
|
+
this.closeWatch(w, w.feed.closeReason ?? 'closed');
|
|
772
|
+
}
|
|
773
|
+
closeWatch(w, reason) {
|
|
774
|
+
if (!this.watches.has(w.id))
|
|
775
|
+
return;
|
|
776
|
+
this.watches.delete(w.id);
|
|
777
|
+
if (w.pending) {
|
|
778
|
+
clearTimeout(w.pending.timer);
|
|
779
|
+
w.pending.reject(new PeerNetworkError(`보기가 끝났어요: ${reason}`));
|
|
780
|
+
w.pending = undefined;
|
|
781
|
+
}
|
|
782
|
+
w.feed.close(reason);
|
|
783
|
+
try {
|
|
784
|
+
this.options.state.audit('watch_close', null, JSON.stringify({ watch: w.id, share: w.share, reason }));
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
// 데몬이 멈추는 중이면 DB가 이미 닫혔을 수 있다
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
sendBox(to, msg) {
|
|
791
|
+
const t = this.transport;
|
|
792
|
+
const member = this.roster ? memberOf(this.roster.roster, to) : undefined;
|
|
793
|
+
if (!t || !member)
|
|
794
|
+
return false;
|
|
795
|
+
const box = sealBox(this.key, to, member.boxKey, JSON.stringify(msg));
|
|
796
|
+
return t.post(to, Buffer.from(JSON.stringify(box)));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/** 링크를 가진 쪽만 만드는 증명. 보는 기기 id와 nonce를 넣어 남의 요청을 다시 못 쓰게 한다 */
|
|
800
|
+
function watchProofText(share, watcher, nonce) {
|
|
801
|
+
return canonical({ p: 'gestalt-peer-watch-v1', share, watcher, nonce });
|
|
802
|
+
}
|
|
803
|
+
function streamKey(shareKey, salt, share, watcher, sub) {
|
|
804
|
+
return deriveKey(shareKey, salt, `gestalt-peer-stream-v1|${share}|${watcher}|${sub}`);
|
|
805
|
+
}
|
|
806
|
+
function streamAad(share, sub, watcher) {
|
|
807
|
+
return `gestalt-peer-stream-v1|${share}|${sub}|${watcher}`;
|
|
808
|
+
}
|
|
809
|
+
//# sourceMappingURL=network.js.map
|