@dsbot-channel/p2p 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/assets/skill.md +17 -0
- package/cordis.patch.yml +3 -0
- package/dist/adapter.d.ts +82 -0
- package/dist/adapter.js +324 -0
- package/dist/guidance.d.ts +2 -0
- package/dist/guidance.js +2 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +142 -0
- package/dist/p2p-auth.d.ts +40 -0
- package/dist/p2p-auth.js +44 -0
- package/dist/p2p-dedupe.d.ts +37 -0
- package/dist/p2p-dedupe.js +107 -0
- package/dist/p2p-listener.d.ts +43 -0
- package/dist/p2p-listener.js +168 -0
- package/dist/p2p-mapping.d.ts +29 -0
- package/dist/p2p-mapping.js +80 -0
- package/dist/p2p-protocol.d.ts +94 -0
- package/dist/p2p-protocol.js +229 -0
- package/dist/p2p-secrets.d.ts +28 -0
- package/dist/p2p-secrets.js +50 -0
- package/dist/p2p-send.d.ts +37 -0
- package/dist/p2p-send.js +137 -0
- package/dist/p2p-status.d.ts +1 -0
- package/dist/p2p-status.js +1 -0
- package/dist/p2p-wire.d.ts +18 -0
- package/dist/p2p-wire.js +1 -0
- package/package.json +50 -3
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { makeAdapterPlugin } from '@dsbot-channel/core/adapter-plugin.js';
|
|
2
|
+
import { ConfigError } from '@dsbot-channel/core/config.js';
|
|
3
|
+
import { P2PAdapter } from './adapter.js';
|
|
4
|
+
const FIELDS = new Set([
|
|
5
|
+
'peers',
|
|
6
|
+
'listen',
|
|
7
|
+
'dedupe',
|
|
8
|
+
'inflightTtlMs',
|
|
9
|
+
'sentAtWindowMs',
|
|
10
|
+
'maxBodyBytes',
|
|
11
|
+
'waitMs',
|
|
12
|
+
]);
|
|
13
|
+
function refuse(message, field) {
|
|
14
|
+
throw new ConfigError(message, field === undefined ? ['adapters', 'transport'] : ['adapters', 'transport', field]);
|
|
15
|
+
}
|
|
16
|
+
function record(value, field) {
|
|
17
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
18
|
+
refuse(`transport.${field} must be an object`, field);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function requiredText(value, field) {
|
|
22
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
23
|
+
refuse(`transport.${field} must be a non-empty string`, field);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function optionalText(value, field) {
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
30
|
+
refuse(`transport.${field} must be a non-empty string`, field);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function optionalPort(value, field) {
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
37
|
+
refuse(`transport.${field} must be a whole port number of at least 0`, field);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function optionalWhole(value, field) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
45
|
+
refuse(`transport.${field} must be a whole number of at least 1`, field);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function parsePeers(value) {
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
if (value.length === 0)
|
|
52
|
+
refuse('transport.peers must list at least one peer', 'peers');
|
|
53
|
+
const peers = value.map((entry, index) => {
|
|
54
|
+
const peer = record(entry, `peers[${index}]`);
|
|
55
|
+
if (peer.instance === undefined && peer.secret === undefined) {
|
|
56
|
+
refuse(`transport.peers mixes the list form and the mapping form: peers[${index}] is neither a peer nor a mapping`, 'peers');
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
instance: requiredText(peer.instance, `peers[${index}].instance`),
|
|
60
|
+
secret: requiredText(peer.secret, `peers[${index}].secret`),
|
|
61
|
+
...(optionalText(peer.url, `peers[${index}].url`) === undefined ? {} : { url: peer.url }),
|
|
62
|
+
...(optionalText(peer.host, `peers[${index}].host`) === undefined ? {} : { host: peer.host }),
|
|
63
|
+
...(optionalText(peer.botName, `peers[${index}].botName`) === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { botName: peer.botName }),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
const references = new Map();
|
|
69
|
+
for (const [index, peer] of peers.entries()) {
|
|
70
|
+
const seen = references.get(peer.instance) ?? new Set();
|
|
71
|
+
if (seen.has(peer.secret)) {
|
|
72
|
+
refuse(`transport.peers lists instance "${peer.instance}" twice with the same secret reference; ` +
|
|
73
|
+
'entries for one instance must reference different secrets', `peers[${index}].secret`);
|
|
74
|
+
}
|
|
75
|
+
seen.add(peer.secret);
|
|
76
|
+
references.set(peer.instance, seen);
|
|
77
|
+
}
|
|
78
|
+
return peers;
|
|
79
|
+
}
|
|
80
|
+
if (value !== null && typeof value === 'object') {
|
|
81
|
+
const mapping = value;
|
|
82
|
+
if (Object.hasOwn(mapping, 'instance') && Object.hasOwn(mapping, 'secret')) {
|
|
83
|
+
refuse('transport.peers reads as one peer written without the list brackets; the list form is peers: [{ instance, secret }]', 'peers');
|
|
84
|
+
}
|
|
85
|
+
const entries = Object.entries(mapping);
|
|
86
|
+
if (entries.length === 0)
|
|
87
|
+
refuse('transport.peers must list at least one peer', 'peers');
|
|
88
|
+
return entries.map(([instance, secret]) => ({
|
|
89
|
+
instance: requiredText(instance, `peers.${instance}`),
|
|
90
|
+
secret: requiredText(secret, `peers.${instance}`),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
refuse('transport.peers must be a list of peers or a mapping of instance to secret reference', 'peers');
|
|
94
|
+
}
|
|
95
|
+
function parseListen(value) {
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
const listen = record(value, 'listen');
|
|
99
|
+
return {
|
|
100
|
+
...(optionalText(listen.host, 'listen.host') === undefined ? {} : { host: listen.host }),
|
|
101
|
+
...(optionalPort(listen.port, 'listen.port') === undefined ? {} : { port: listen.port }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function parseDedupe(value) {
|
|
105
|
+
if (value === undefined)
|
|
106
|
+
return undefined;
|
|
107
|
+
const dedupe = record(value, 'dedupe');
|
|
108
|
+
return {
|
|
109
|
+
...(optionalPort(dedupe.ttlMs, 'dedupe.ttlMs') === undefined ? {} : { ttlMs: dedupe.ttlMs }),
|
|
110
|
+
...(optionalWhole(dedupe.limit, 'dedupe.limit') === undefined ? {} : { limit: dedupe.limit }),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Reads the transport block a profile declared, refusing a field this adapter never reads. */
|
|
114
|
+
export function parseP2PConfig(mount) {
|
|
115
|
+
const raw = mount.transport;
|
|
116
|
+
const unknown = Object.keys(raw).filter((key) => !FIELDS.has(key));
|
|
117
|
+
if (unknown.length > 0)
|
|
118
|
+
refuse(`transport names a field this adapter does not read: ${unknown.join(', ')}`);
|
|
119
|
+
return {
|
|
120
|
+
channel: mount.channel,
|
|
121
|
+
botName: mount.wireBotName ?? mount.botName,
|
|
122
|
+
...(mount.status === undefined ? {} : { status: mount.status }),
|
|
123
|
+
peers: parsePeers(raw.peers),
|
|
124
|
+
...(parseListen(raw.listen) === undefined ? {} : { listen: parseListen(raw.listen) }),
|
|
125
|
+
...(parseDedupe(raw.dedupe) === undefined ? {} : { dedupe: parseDedupe(raw.dedupe) }),
|
|
126
|
+
...(optionalWhole(raw.inflightTtlMs, 'inflightTtlMs') === undefined ? {} : { inflightTtlMs: raw.inflightTtlMs }),
|
|
127
|
+
...(optionalWhole(raw.sentAtWindowMs, 'sentAtWindowMs') === undefined ? {} : { sentAtWindowMs: raw.sentAtWindowMs }),
|
|
128
|
+
...(optionalWhole(raw.maxBodyBytes, 'maxBodyBytes') === undefined ? {} : { maxBodyBytes: raw.maxBodyBytes }),
|
|
129
|
+
...(optionalWhole(raw.waitMs, 'waitMs') === undefined ? {} : { waitMs: raw.waitMs }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export const createP2PAdapter = (mount, options) => new P2PAdapter(parseP2PConfig(mount), options);
|
|
133
|
+
const adapter = makeAdapterPlugin({
|
|
134
|
+
name: 'dsbot-channel/p2p',
|
|
135
|
+
kind: 'p2p',
|
|
136
|
+
factory: createP2PAdapter,
|
|
137
|
+
check: parseP2PConfig,
|
|
138
|
+
});
|
|
139
|
+
export const Config = adapter.Config;
|
|
140
|
+
export const apply = adapter.apply;
|
|
141
|
+
export const plugin = adapter.plugin;
|
|
142
|
+
export default adapter.plugin;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { P2PMediaItem } from './p2p-wire.js';
|
|
2
|
+
export interface PeerSecret {
|
|
3
|
+
instance: string;
|
|
4
|
+
secret: string;
|
|
5
|
+
}
|
|
6
|
+
export interface AuthResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
instance?: string | undefined;
|
|
9
|
+
status?: 401 | 403 | undefined;
|
|
10
|
+
reason?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** Everything the signature covers, in the order both sides build it. */
|
|
13
|
+
export interface Envelope {
|
|
14
|
+
eventId: string;
|
|
15
|
+
/** The root event of the conversation this message belongs to; signed like eventId. */
|
|
16
|
+
chainId: string;
|
|
17
|
+
to: string;
|
|
18
|
+
sender: string;
|
|
19
|
+
instance: string;
|
|
20
|
+
body: string;
|
|
21
|
+
sentAt: number;
|
|
22
|
+
status?: string | undefined;
|
|
23
|
+
reason?: string | undefined;
|
|
24
|
+
final?: boolean | undefined;
|
|
25
|
+
inReplyTo?: string | undefined;
|
|
26
|
+
inReplyToExcerpt?: string | undefined;
|
|
27
|
+
/** Only present when the message carries media, so a text-only envelope keeps its old bytes. */
|
|
28
|
+
media?: readonly P2PMediaItem[] | undefined;
|
|
29
|
+
}
|
|
30
|
+
export interface AuthInput {
|
|
31
|
+
peers: readonly PeerSecret[];
|
|
32
|
+
signature?: string | null | undefined;
|
|
33
|
+
claimedInstance: string;
|
|
34
|
+
envelope?: Envelope | undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The bytes a signature covers: every field of the envelope, in one fixed order. */
|
|
37
|
+
export declare function canonicalEnvelope(envelope: Envelope): string;
|
|
38
|
+
export declare function signEnvelope(secret: string, envelope: Envelope): string;
|
|
39
|
+
/** Resolves the peer instance from the credential, never from the request body. */
|
|
40
|
+
export declare function verifyRequest(input: AuthInput): AuthResult;
|
package/dist/p2p-auth.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
function sameSecret(a, b) {
|
|
3
|
+
const left = createHash('sha256').update(a, 'utf8').digest();
|
|
4
|
+
const right = createHash('sha256').update(b, 'utf8').digest();
|
|
5
|
+
return timingSafeEqual(left, right);
|
|
6
|
+
}
|
|
7
|
+
/** The bytes a signature covers: every field of the envelope, in one fixed order. */
|
|
8
|
+
export function canonicalEnvelope(envelope) {
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
eventId: envelope.eventId,
|
|
11
|
+
chainId: envelope.chainId,
|
|
12
|
+
to: envelope.to,
|
|
13
|
+
sender: envelope.sender,
|
|
14
|
+
instance: envelope.instance,
|
|
15
|
+
body: envelope.body,
|
|
16
|
+
sentAt: envelope.sentAt,
|
|
17
|
+
...(envelope.status === undefined ? {} : { status: envelope.status }),
|
|
18
|
+
...(envelope.reason === undefined ? {} : { reason: envelope.reason }),
|
|
19
|
+
...(envelope.final === undefined ? {} : { final: envelope.final }),
|
|
20
|
+
...(envelope.inReplyTo === undefined ? {} : { inReplyTo: envelope.inReplyTo }),
|
|
21
|
+
...(envelope.inReplyToExcerpt === undefined ? {} : { inReplyToExcerpt: envelope.inReplyToExcerpt }),
|
|
22
|
+
...(envelope.media === undefined || envelope.media.length === 0 ? {} : { media: envelope.media }),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function signEnvelope(secret, envelope) {
|
|
26
|
+
return createHash('sha256').update(`${secret}.${canonicalEnvelope(envelope)}`, 'utf8').digest('base64');
|
|
27
|
+
}
|
|
28
|
+
/** Resolves the peer instance from the credential, never from the request body. */
|
|
29
|
+
export function verifyRequest(input) {
|
|
30
|
+
if (!input.peers.length)
|
|
31
|
+
throw new Error('p2p: at least one peer secret is required to start');
|
|
32
|
+
if (!input.signature || input.envelope === undefined)
|
|
33
|
+
return { ok: false, status: 401, reason: 'missing signature' };
|
|
34
|
+
for (const peer of input.peers) {
|
|
35
|
+
const expected = signEnvelope(peer.secret, input.envelope);
|
|
36
|
+
if (sameSecret(expected, input.signature)) {
|
|
37
|
+
if (input.claimedInstance && input.claimedInstance !== peer.instance) {
|
|
38
|
+
return { ok: false, status: 403, reason: 'instance does not match the signing key' };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, instance: peer.instance };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { ok: false, status: 401, reason: 'bad signature' };
|
|
44
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface DedupeOptions {
|
|
2
|
+
ttlMs: number;
|
|
3
|
+
limit: number;
|
|
4
|
+
inflightTtlMs?: number;
|
|
5
|
+
maxTurnMs?: number;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
}
|
|
8
|
+
export interface DedupeEntry {
|
|
9
|
+
messageId: string;
|
|
10
|
+
at: number;
|
|
11
|
+
}
|
|
12
|
+
/** Dedupe table with single-flight: a concurrent duplicate waits instead of reprocessing. */
|
|
13
|
+
export declare class DedupeTable {
|
|
14
|
+
private readonly options;
|
|
15
|
+
/** When this table started; a replay carried into a table with a newer epoch crossed a restart. */
|
|
16
|
+
readonly epoch: number;
|
|
17
|
+
private readonly done;
|
|
18
|
+
private readonly inflight;
|
|
19
|
+
private readonly now;
|
|
20
|
+
constructor(options: DedupeOptions);
|
|
21
|
+
has(key: string): DedupeEntry | undefined;
|
|
22
|
+
/** Claims the key. Returns the finished entry, or null when this call now owns the work. */
|
|
23
|
+
claim(key: string, waitMs: number): Promise<{
|
|
24
|
+
done: true;
|
|
25
|
+
entry: DedupeEntry | null;
|
|
26
|
+
} | {
|
|
27
|
+
done: false;
|
|
28
|
+
}>;
|
|
29
|
+
private expired;
|
|
30
|
+
/** Drops a reservation left by a crashed handler so the event can be retried. */
|
|
31
|
+
private reap;
|
|
32
|
+
commit(key: string, messageId: string): void;
|
|
33
|
+
abort(key: string): void;
|
|
34
|
+
private evict;
|
|
35
|
+
size(): number;
|
|
36
|
+
inflightSize(): number;
|
|
37
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Dedupe table with single-flight: a concurrent duplicate waits instead of reprocessing. */
|
|
2
|
+
export class DedupeTable {
|
|
3
|
+
options;
|
|
4
|
+
/** When this table started; a replay carried into a table with a newer epoch crossed a restart. */
|
|
5
|
+
epoch;
|
|
6
|
+
done = new Map();
|
|
7
|
+
inflight = new Map();
|
|
8
|
+
now;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
if (options.inflightTtlMs !== undefined && options.maxTurnMs !== undefined && options.inflightTtlMs <= options.maxTurnMs) {
|
|
12
|
+
throw new Error('inflightTtlMs must exceed maxTurnMs');
|
|
13
|
+
}
|
|
14
|
+
this.now = options.now ?? Date.now;
|
|
15
|
+
this.epoch = this.now();
|
|
16
|
+
}
|
|
17
|
+
has(key) {
|
|
18
|
+
const entry = this.done.get(key);
|
|
19
|
+
if (!entry)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (this.now() - entry.at > this.options.ttlMs) {
|
|
22
|
+
this.done.delete(key);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return entry;
|
|
26
|
+
}
|
|
27
|
+
/** Claims the key. Returns the finished entry, or null when this call now owns the work. */
|
|
28
|
+
async claim(key, waitMs) {
|
|
29
|
+
const finished = this.has(key);
|
|
30
|
+
if (finished)
|
|
31
|
+
return { done: true, entry: finished };
|
|
32
|
+
const stale = this.inflight.get(key);
|
|
33
|
+
if (stale && this.expired(stale))
|
|
34
|
+
this.reap(key, stale);
|
|
35
|
+
const running = this.inflight.get(key);
|
|
36
|
+
if (running) {
|
|
37
|
+
const entry = await new Promise((resolve) => {
|
|
38
|
+
let settled = false;
|
|
39
|
+
const finish = (value) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
const index = running.waiters.indexOf(finish);
|
|
45
|
+
if (index >= 0)
|
|
46
|
+
running.waiters.splice(index, 1);
|
|
47
|
+
resolve(value);
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => finish(null), waitMs);
|
|
50
|
+
running.waiters.push(finish);
|
|
51
|
+
});
|
|
52
|
+
return { done: true, entry };
|
|
53
|
+
}
|
|
54
|
+
this.inflight.set(key, { at: this.now(), waiters: [] });
|
|
55
|
+
return { done: false };
|
|
56
|
+
}
|
|
57
|
+
expired(entry) {
|
|
58
|
+
const ttl = this.options.inflightTtlMs;
|
|
59
|
+
return ttl !== undefined && this.now() - entry.at > ttl;
|
|
60
|
+
}
|
|
61
|
+
/** Drops a reservation left by a crashed handler so the event can be retried. */
|
|
62
|
+
reap(key, entry) {
|
|
63
|
+
this.inflight.delete(key);
|
|
64
|
+
for (const waiter of entry.waiters)
|
|
65
|
+
waiter(null);
|
|
66
|
+
}
|
|
67
|
+
commit(key, messageId) {
|
|
68
|
+
const entry = { messageId, at: this.now() };
|
|
69
|
+
this.done.set(key, entry);
|
|
70
|
+
this.evict();
|
|
71
|
+
const running = this.inflight.get(key);
|
|
72
|
+
if (running) {
|
|
73
|
+
this.inflight.delete(key);
|
|
74
|
+
for (const waiter of running.waiters)
|
|
75
|
+
waiter(entry);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
abort(key) {
|
|
79
|
+
const running = this.inflight.get(key);
|
|
80
|
+
if (!running)
|
|
81
|
+
return;
|
|
82
|
+
this.inflight.delete(key);
|
|
83
|
+
for (const waiter of running.waiters)
|
|
84
|
+
waiter(null);
|
|
85
|
+
}
|
|
86
|
+
evict() {
|
|
87
|
+
if (this.done.size <= this.options.limit)
|
|
88
|
+
return;
|
|
89
|
+
const at = this.now();
|
|
90
|
+
for (const [key, entry] of this.done) {
|
|
91
|
+
if (at - entry.at > this.options.ttlMs)
|
|
92
|
+
this.done.delete(key);
|
|
93
|
+
}
|
|
94
|
+
while (this.done.size > this.options.limit) {
|
|
95
|
+
const oldest = this.done.keys().next();
|
|
96
|
+
if (oldest.done)
|
|
97
|
+
break;
|
|
98
|
+
this.done.delete(oldest.value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
size() {
|
|
102
|
+
return this.done.size;
|
|
103
|
+
}
|
|
104
|
+
inflightSize() {
|
|
105
|
+
return this.inflight.size;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import type { DedupeTable } from './p2p-dedupe.js';
|
|
4
|
+
import type { ChannelStats } from '@dsbot-channel/core/channel-stats.js';
|
|
5
|
+
import type { AdapterStatus } from '@dsbot-channel/core/adapter-status.js';
|
|
6
|
+
import type { P2PConfig } from './p2p-protocol.js';
|
|
7
|
+
import type { InboundRequest } from './p2p-wire.js';
|
|
8
|
+
export declare const DEFAULT_HOST = "127.0.0.1";
|
|
9
|
+
export declare const DEFAULT_PORT = 7123;
|
|
10
|
+
export declare const SIGNATURE_HEADER = "x-p2p-signature";
|
|
11
|
+
export interface ListenerConfig extends P2PConfig {
|
|
12
|
+
listen: {
|
|
13
|
+
host?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Checks the bind address before anything touches the network. */
|
|
18
|
+
export declare function checkListenerConfig(config: ListenerConfig): string;
|
|
19
|
+
export interface ListenerOptions {
|
|
20
|
+
config: ListenerConfig;
|
|
21
|
+
table: DedupeTable;
|
|
22
|
+
/** Runs after the dedupe claim and must return before the turn finishes.
|
|
23
|
+
* A 200 means accepted, not completed. */
|
|
24
|
+
onMessage?: (request: InboundRequest) => void | Promise<void>;
|
|
25
|
+
/** Receives a rejected asynchronous hand off so it never becomes a crash. */
|
|
26
|
+
onError?: (error: unknown, request: InboundRequest) => void;
|
|
27
|
+
/** Collects what this channel can report about itself. */
|
|
28
|
+
stats?: ChannelStats | undefined;
|
|
29
|
+
/** Answers a loopback only status read; absent means the route does not exist. */
|
|
30
|
+
status?: {
|
|
31
|
+
route?: string | undefined;
|
|
32
|
+
report: () => AdapterStatus;
|
|
33
|
+
} | undefined;
|
|
34
|
+
}
|
|
35
|
+
export interface Listener {
|
|
36
|
+
/** Bound address once listening. */
|
|
37
|
+
address(): AddressInfo;
|
|
38
|
+
/** Stops accepting connections and resolves once closed. */
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/** Starts the self hosted peer listener on the configured address. */
|
|
43
|
+
export declare function startListener(options: ListenerOptions): Promise<Listener>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { assertBindHost } from '@dsbot-channel/core/config.js';
|
|
3
|
+
import { STATUS_ROUTE } from './p2p-status.js';
|
|
4
|
+
import { answerStatus } from '@dsbot-channel/core/adapters/shared/status-route.js';
|
|
5
|
+
import { MAX_BODY_BYTES, P2P_ROUTE, handleMessage, parseRequest } from './p2p-protocol.js';
|
|
6
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
7
|
+
export const DEFAULT_PORT = 7123;
|
|
8
|
+
export const SIGNATURE_HEADER = 'x-p2p-signature';
|
|
9
|
+
/** Checks the bind address before anything touches the network. */
|
|
10
|
+
export function checkListenerConfig(config) {
|
|
11
|
+
const host = config.listen.host ?? DEFAULT_HOST;
|
|
12
|
+
assertBindHost(host, ['listen', 'host']);
|
|
13
|
+
if (!config.peers.length)
|
|
14
|
+
throw new Error('p2p: at least one peer secret is required to start');
|
|
15
|
+
return host;
|
|
16
|
+
}
|
|
17
|
+
function readBody(request, limit) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
let settled = false;
|
|
22
|
+
const settle = (value) => {
|
|
23
|
+
if (settled)
|
|
24
|
+
return;
|
|
25
|
+
settled = true;
|
|
26
|
+
resolve(value);
|
|
27
|
+
};
|
|
28
|
+
request.on('data', (chunk) => {
|
|
29
|
+
size += chunk.length;
|
|
30
|
+
if (size > limit) {
|
|
31
|
+
chunks.length = 0;
|
|
32
|
+
settle({ ok: false });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
chunks.push(chunk);
|
|
36
|
+
});
|
|
37
|
+
request.on('end', () => settle({ ok: true, text: Buffer.concat(chunks).toString('utf8') }));
|
|
38
|
+
request.on('error', () => settle({ ok: false }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function readSignature(request) {
|
|
42
|
+
const header = request.headers[SIGNATURE_HEADER];
|
|
43
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
44
|
+
return value ?? null;
|
|
45
|
+
}
|
|
46
|
+
function send(response, status, body) {
|
|
47
|
+
const text = JSON.stringify(body);
|
|
48
|
+
response.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
|
|
49
|
+
response.end(text);
|
|
50
|
+
}
|
|
51
|
+
/** Starts the self hosted peer listener on the configured address. */
|
|
52
|
+
export async function startListener(options) {
|
|
53
|
+
const host = checkListenerConfig(options.config);
|
|
54
|
+
const port = options.config.listen.port ?? DEFAULT_PORT;
|
|
55
|
+
const limit = options.config.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
56
|
+
const handle = async (request, response) => {
|
|
57
|
+
const status = options.status;
|
|
58
|
+
if (status !== undefined &&
|
|
59
|
+
answerStatus(request, response, {
|
|
60
|
+
route: status.route ?? STATUS_ROUTE,
|
|
61
|
+
report: status.report,
|
|
62
|
+
onRefused: () => options.stats?.unknownPath(),
|
|
63
|
+
})) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (request.method !== 'POST' || request.url !== P2P_ROUTE) {
|
|
67
|
+
options.stats?.unknownPath();
|
|
68
|
+
send(response, 404, { error: 'not found' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const raw = await readBody(request, limit);
|
|
72
|
+
let payload;
|
|
73
|
+
if (!raw.ok) {
|
|
74
|
+
options.stats?.rejected('body exceeds the size limit');
|
|
75
|
+
send(response, 413, { error: 'body exceeds the size limit' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(raw.text);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
options.stats?.rejected('body must be valid json');
|
|
83
|
+
send(response, 400, { error: 'body must be valid json' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const claimed = typeof payload.instance === 'string' ? payload.instance : '';
|
|
87
|
+
const onMessage = options.onMessage;
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await handleMessage({
|
|
91
|
+
config: options.config,
|
|
92
|
+
table: options.table,
|
|
93
|
+
auth: {
|
|
94
|
+
signature: readSignature(request),
|
|
95
|
+
claimedInstance: claimed,
|
|
96
|
+
},
|
|
97
|
+
payload,
|
|
98
|
+
...(options.stats === undefined ? {} : { stats: options.stats }),
|
|
99
|
+
...(onMessage === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: {
|
|
102
|
+
process: (eventId) => {
|
|
103
|
+
const parsed = parseRequest(payload, limit);
|
|
104
|
+
if (parsed.ok && parsed.value.eventId === eventId) {
|
|
105
|
+
const request = parsed.value;
|
|
106
|
+
const report = (error) => {
|
|
107
|
+
const onError = options.onError;
|
|
108
|
+
if (onError === undefined)
|
|
109
|
+
return;
|
|
110
|
+
void Promise.resolve()
|
|
111
|
+
.then(() => onError(error, request))
|
|
112
|
+
.catch(() => undefined);
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
const dispatched = onMessage(request);
|
|
116
|
+
if (dispatched instanceof Promise)
|
|
117
|
+
void dispatched.catch(report);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
report(error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Promise.resolve(true);
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
options.stats?.rejected(error instanceof Error ? error.message : 'internal error');
|
|
130
|
+
send(response, 500, { error: error instanceof Error ? error.message : 'internal error' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (result.status === 401)
|
|
134
|
+
options.stats?.authFailure();
|
|
135
|
+
if (result.status !== 200)
|
|
136
|
+
options.stats?.rejected(result.body.error);
|
|
137
|
+
send(response, result.status, result.body);
|
|
138
|
+
};
|
|
139
|
+
const server = createServer((request, response) => {
|
|
140
|
+
void handle(request, response);
|
|
141
|
+
});
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
server.once('error', reject);
|
|
144
|
+
server.listen(port, host, () => {
|
|
145
|
+
server.off('error', reject);
|
|
146
|
+
resolve();
|
|
147
|
+
});
|
|
148
|
+
}).catch((error) => {
|
|
149
|
+
options.stats?.failed(error instanceof Error ? error.message : String(error));
|
|
150
|
+
throw error;
|
|
151
|
+
});
|
|
152
|
+
options.stats?.listening(server.address().port);
|
|
153
|
+
return {
|
|
154
|
+
address: () => server.address(),
|
|
155
|
+
close: () => new Promise((resolve, reject) => {
|
|
156
|
+
server.close((error) => {
|
|
157
|
+
if (error) {
|
|
158
|
+
options.stats?.failed(error.message);
|
|
159
|
+
reject(error);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
options.stats?.closed();
|
|
163
|
+
resolve();
|
|
164
|
+
});
|
|
165
|
+
}),
|
|
166
|
+
handle,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { P2PMediaItem } from './p2p-wire.js';
|
|
2
|
+
import type { Event } from '@dsbot-channel/core/event-types.js';
|
|
3
|
+
/** How much of the message being answered travels back with the reply. */
|
|
4
|
+
export declare const EXCERPT_CHARS = 200;
|
|
5
|
+
/** How much of a peer's own text reaches a line of ours. */
|
|
6
|
+
export declare const PEER_TEXT_MAX = 200;
|
|
7
|
+
/** How much of a wire name one log line keeps, escapes included. */
|
|
8
|
+
export declare const LOG_NAME_MAX = 64;
|
|
9
|
+
/** A wire name as one log-safe literal: control characters escaped, length bounded. */
|
|
10
|
+
export declare function logName(value: string): string;
|
|
11
|
+
/** A peer's own words for a line of ours: one line, bounded, nothing that breaks the line. */
|
|
12
|
+
export declare function oneLine(text: unknown, limit?: number): string;
|
|
13
|
+
/** Keeps a peer from shaping the header: no control characters, one line, no quotes. */
|
|
14
|
+
export declare function cleanExcerpt(text: string, limit?: number): string;
|
|
15
|
+
export interface P2PInbound {
|
|
16
|
+
instance: string;
|
|
17
|
+
sender: string;
|
|
18
|
+
messageId: string;
|
|
19
|
+
body: string;
|
|
20
|
+
media?: readonly P2PMediaItem[];
|
|
21
|
+
sentAt: number;
|
|
22
|
+
inReplyTo?: string;
|
|
23
|
+
inReplyToExcerpt?: string;
|
|
24
|
+
status?: 'declined';
|
|
25
|
+
reason?: string;
|
|
26
|
+
final?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Maps a peer message onto the event model with the peer identity as the source. */
|
|
29
|
+
export declare function toP2PEvent(inbound: P2PInbound): Event;
|