@dsbot-channel/p2p 0.0.0-stage → 0.7.1

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.
@@ -0,0 +1,80 @@
1
+ import { buildEvent } from '@dsbot-channel/core/event-build.js';
2
+ /** How much of the message being answered travels back with the reply. */
3
+ export const EXCERPT_CHARS = 200;
4
+ /** How much of a peer's own text reaches a line of ours. */
5
+ export const PEER_TEXT_MAX = 200;
6
+ /** How much of a wire name one log line keeps, escapes included. */
7
+ export const LOG_NAME_MAX = 64;
8
+ function logChar(char) {
9
+ const code = char.codePointAt(0) ?? 0;
10
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029) {
11
+ return `\\u${code.toString(16).padStart(4, '0')}`;
12
+ }
13
+ return char;
14
+ }
15
+ /** A wire name as one log-safe literal: control characters escaped, length bounded. */
16
+ export function logName(value) {
17
+ let out = '';
18
+ for (const char of value) {
19
+ const piece = logChar(char);
20
+ if (out.length + piece.length > LOG_NAME_MAX - 1)
21
+ return `${out}…`;
22
+ out += piece;
23
+ }
24
+ return out;
25
+ }
26
+ function flatten(text) {
27
+ let out = '';
28
+ for (const char of text) {
29
+ const code = char.codePointAt(0) ?? 0;
30
+ out += code < 0x20 || code === 0x7f ? ' ' : char;
31
+ }
32
+ return out;
33
+ }
34
+ /** A peer's own words for a line of ours: one line, bounded, nothing that breaks the line. */
35
+ export function oneLine(text, limit = PEER_TEXT_MAX) {
36
+ const flat = flatten(typeof text === 'string' ? text : String(text ?? '')).replace(/\s+/g, ' ').trim();
37
+ return Array.from(flat).slice(0, limit).join('').trim();
38
+ }
39
+ /** Keeps a peer from shaping the header: no control characters, one line, no quotes. */
40
+ export function cleanExcerpt(text, limit = EXCERPT_CHARS) {
41
+ const flat = flatten(text)
42
+ .replace(/["'`]+/g, '')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ return Array.from(flat).slice(0, limit).join('').trim();
46
+ }
47
+ /** Maps a peer message onto the event model with the peer identity as the source. */
48
+ export function toP2PEvent(inbound) {
49
+ const text = inbound.body;
50
+ return buildEvent({
51
+ source: { kind: 'p2p', instance: inbound.instance, peer: inbound.instance, scope: 'p2p' },
52
+ author: { senderId: inbound.sender, kind: 'bot', personId: null },
53
+ body: {
54
+ text,
55
+ media: (inbound.media ?? []).map(([type, url, name]) => ({
56
+ type: type,
57
+ url,
58
+ handle: null,
59
+ ...(name === undefined ? {} : { name }),
60
+ })),
61
+ replyTo: inbound.inReplyTo ?? null,
62
+ },
63
+ transport: {
64
+ messageId: inbound.messageId,
65
+ timestamp: inbound.sentAt,
66
+ mentioned: { self: false, others: false, all: false },
67
+ isReply: inbound.inReplyTo !== undefined,
68
+ signature: null,
69
+ },
70
+ declared: { nickname: '', avatar: '' },
71
+ adapter: {
72
+ sentAt: inbound.sentAt,
73
+ ...(inbound.inReplyTo === undefined ? {} : { inReplyTo: inbound.inReplyTo }),
74
+ ...(inbound.inReplyToExcerpt === undefined ? {} : { inReplyToExcerpt: cleanExcerpt(inbound.inReplyToExcerpt) }),
75
+ ...(inbound.status === undefined ? {} : { status: inbound.status }),
76
+ ...(inbound.reason === undefined ? {} : { reason: inbound.reason }),
77
+ ...(inbound.final === undefined ? {} : { final: inbound.final }),
78
+ },
79
+ });
80
+ }
@@ -0,0 +1,94 @@
1
+ import type { CredentialsLike, PeerRef } from './p2p-secrets.js';
2
+ import type { DedupeTable } from './p2p-dedupe.js';
3
+ import type { ChannelStats } from '@dsbot-channel/core/channel-stats.js';
4
+ import type { P2PMediaItem } from './p2p-wire.js';
5
+ export declare const P2P_ROUTE = "/p2p/v1/message";
6
+ export declare const MAX_BODY_BYTES: number;
7
+ export declare const DEFAULT_SENT_AT_WINDOW_MS = 300000;
8
+ /** The reference is a token, not free text: it lands in a header line. */
9
+ export declare const IN_REPLY_TO_MAX = 128;
10
+ /** An event id is a token the adapter builds; a reply's own id stays this short. */
11
+ export declare const EVENT_ID_MAX = 256;
12
+ /** A chain root is an event id, so it gets the same room. */
13
+ export declare const CHAIN_ID_MAX = 256;
14
+ export declare const MEDIA_MAX_ITEMS = 16;
15
+ export declare const MEDIA_URL_MAX = 2048;
16
+ export declare const MEDIA_TOTAL_MAX: number;
17
+ export interface P2PConfig {
18
+ botName: string;
19
+ peers: readonly PeerRef[];
20
+ credentials: CredentialsLike;
21
+ inflightTtlMs: number;
22
+ maxBodyBytes: number;
23
+ waitMs?: number;
24
+ sentAtWindowMs?: number;
25
+ now?: () => number;
26
+ warn?: (message: string) => void;
27
+ }
28
+ export interface P2PPayload {
29
+ eventId?: unknown;
30
+ to?: unknown;
31
+ sender?: unknown;
32
+ instance?: unknown;
33
+ body?: unknown;
34
+ media?: unknown;
35
+ sentAt?: unknown;
36
+ [key: string]: unknown;
37
+ }
38
+ export interface AuthHeader {
39
+ signature?: string | null;
40
+ claimedInstance: string;
41
+ }
42
+ export declare const DECLINED = "declined";
43
+ export interface ParsedMessage {
44
+ eventId: string;
45
+ chainId: string;
46
+ to: string;
47
+ sender: string;
48
+ instance: string;
49
+ body: string;
50
+ media: readonly P2PMediaItem[];
51
+ sentAt: number;
52
+ status?: typeof DECLINED;
53
+ reason?: string;
54
+ final?: boolean;
55
+ inReplyTo?: string;
56
+ inReplyToExcerpt?: string;
57
+ }
58
+ export type Parsed = {
59
+ ok: true;
60
+ value: ParsedMessage;
61
+ } | {
62
+ ok: false;
63
+ status: 400 | 401 | 413;
64
+ reason: string;
65
+ protocolMismatch?: boolean;
66
+ };
67
+ export declare function parseRequest(payload: P2PPayload, maxBodyBytes?: number): Parsed;
68
+ export interface HandleInput {
69
+ config: P2PConfig;
70
+ table: DedupeTable;
71
+ auth: AuthHeader;
72
+ payload: P2PPayload;
73
+ process?: (eventId: string) => Promise<boolean>;
74
+ stats?: ChannelStats | undefined;
75
+ }
76
+ export type HandleResult = {
77
+ ok: true;
78
+ status: 200;
79
+ body: {
80
+ duplicate: boolean;
81
+ messageId: string;
82
+ eventId: string;
83
+ };
84
+ } | {
85
+ ok: false;
86
+ status: 400 | 401 | 403 | 413 | 503;
87
+ body: {
88
+ error: string;
89
+ code?: string;
90
+ sentAt?: number;
91
+ now?: number;
92
+ };
93
+ };
94
+ export declare function handleMessage(input: HandleInput): Promise<HandleResult>;
@@ -0,0 +1,229 @@
1
+ import { verifyRequest } from './p2p-auth.js';
2
+ import { SecretRefError, resolvePeers } from './p2p-secrets.js';
3
+ import { EXCERPT_CHARS, logName } from './p2p-mapping.js';
4
+ import { MEDIA_TYPES, mediaNameProblem } from '@dsbot-channel/core/event-types.js';
5
+ export const P2P_ROUTE = '/p2p/v1/message';
6
+ export const MAX_BODY_BYTES = 64 * 1024;
7
+ export const DEFAULT_SENT_AT_WINDOW_MS = 300_000;
8
+ /** The reference is a token, not free text: it lands in a header line. */
9
+ export const IN_REPLY_TO_MAX = 128;
10
+ /** An event id is a token the adapter builds; a reply's own id stays this short. */
11
+ export const EVENT_ID_MAX = 256;
12
+ /** A chain root is an event id, so it gets the same room. */
13
+ export const CHAIN_ID_MAX = 256;
14
+ export const MEDIA_MAX_ITEMS = 16;
15
+ export const MEDIA_URL_MAX = 2048;
16
+ export const MEDIA_TOTAL_MAX = 8 * 1024;
17
+ function isToken(text) {
18
+ for (const char of text) {
19
+ const code = char.codePointAt(0) ?? 0;
20
+ if (code < 0x21 || code > 0x7e)
21
+ return false;
22
+ }
23
+ return true;
24
+ }
25
+ export const DECLINED = 'declined';
26
+ export function parseRequest(payload, maxBodyBytes = MAX_BODY_BYTES) {
27
+ const { eventId, to, sender, instance, body, sentAt } = payload;
28
+ const parsed = parseMedia(payload.media);
29
+ if (!parsed.ok)
30
+ return { ok: false, status: 400, reason: parsed.reason };
31
+ // An empty body is accepted only when the request carries media; a message with neither is refused.
32
+ const carriesMedia = parsed.value.length > 0;
33
+ for (const [name, value] of Object.entries({ eventId, to, sender, instance, body })) {
34
+ if (name === 'body' && carriesMedia && typeof value === 'string')
35
+ continue;
36
+ if (typeof value !== 'string' || value.length === 0)
37
+ return { ok: false, status: 400, reason: `${name} must be a non-empty string` };
38
+ }
39
+ if (typeof sentAt !== 'number' || !Number.isFinite(sentAt))
40
+ return { ok: false, status: 400, reason: 'sentAt must be a number' };
41
+ if (eventId.length > EVENT_ID_MAX || !isToken(eventId)) {
42
+ return { ok: false, status: 400, reason: `eventId must be a token of at most ${EVENT_ID_MAX} characters` };
43
+ }
44
+ if (!isToken(sender)) {
45
+ return { ok: false, status: 400, reason: 'sender must be a token: printable characters, no whitespace' };
46
+ }
47
+ const text = typeof body === 'string' ? body : '';
48
+ if (Buffer.byteLength(text, 'utf8') > maxBodyBytes)
49
+ return { ok: false, status: 413, reason: 'body exceeds the size limit' };
50
+ const status = payload.status;
51
+ if (status !== undefined && status !== DECLINED) {
52
+ return { ok: false, status: 400, reason: `status must be "${DECLINED}" when it is present` };
53
+ }
54
+ const reason = payload.reason;
55
+ if (reason !== undefined && typeof reason !== 'string')
56
+ return { ok: false, status: 400, reason: 'reason must be a string' };
57
+ const final = payload.final;
58
+ if (final !== undefined && typeof final !== 'boolean')
59
+ return { ok: false, status: 400, reason: 'final must be a boolean' };
60
+ const chainId = payload.chainId;
61
+ if (chainId === undefined) {
62
+ return {
63
+ ok: false,
64
+ status: 401,
65
+ reason: 'chainId is required: this peer speaks the chain-root protocol',
66
+ protocolMismatch: true,
67
+ };
68
+ }
69
+ if (typeof chainId !== 'string' || !isToken(chainId) || chainId.length > CHAIN_ID_MAX) {
70
+ return { ok: false, status: 400, reason: `chainId must be a token of at most ${CHAIN_ID_MAX} characters` };
71
+ }
72
+ const inReplyTo = payload.inReplyTo;
73
+ if (inReplyTo !== undefined) {
74
+ if (typeof inReplyTo !== 'string' || inReplyTo.length === 0 || inReplyTo.length > IN_REPLY_TO_MAX || !isToken(inReplyTo)) {
75
+ return { ok: false, status: 400, reason: `inReplyTo must be a token of at most ${IN_REPLY_TO_MAX} characters` };
76
+ }
77
+ }
78
+ const rawExcerpt = payload.inReplyToExcerpt;
79
+ if (rawExcerpt !== undefined && typeof rawExcerpt !== 'string') {
80
+ return { ok: false, status: 400, reason: 'inReplyToExcerpt must be a string' };
81
+ }
82
+ const inReplyToExcerpt = rawExcerpt === undefined ? undefined : Array.from(rawExcerpt).slice(0, EXCERPT_CHARS).join('');
83
+ return {
84
+ ok: true,
85
+ value: {
86
+ eventId: eventId,
87
+ chainId,
88
+ to: to,
89
+ sender: sender,
90
+ instance: instance,
91
+ body: text,
92
+ media: parsed.value,
93
+ sentAt,
94
+ ...(status === undefined ? {} : { status }),
95
+ ...(reason === undefined ? {} : { reason }),
96
+ ...(final === undefined ? {} : { final }),
97
+ ...(inReplyTo === undefined ? {} : { inReplyTo }),
98
+ ...(inReplyToExcerpt === undefined ? {} : { inReplyToExcerpt }),
99
+ },
100
+ };
101
+ }
102
+ /** Media is a list of [type, url, name?] items; anything else is refused whole, never trimmed. */
103
+ function parseMedia(raw) {
104
+ if (raw === undefined)
105
+ return { ok: true, value: [] };
106
+ if (!Array.isArray(raw))
107
+ return { ok: false, reason: 'media must be an array of [type, url] or [type, url, name] items' };
108
+ if (raw.length > MEDIA_MAX_ITEMS)
109
+ return { ok: false, reason: `media carries at most ${MEDIA_MAX_ITEMS} items` };
110
+ const value = [];
111
+ for (const item of raw) {
112
+ if (!Array.isArray(item) || (item.length !== 2 && item.length !== 3)) {
113
+ return { ok: false, reason: 'each media item must be a [type, url] or [type, url, name] item' };
114
+ }
115
+ const [type, url, name] = item;
116
+ if (typeof type !== 'string' || !MEDIA_TYPES.includes(type)) {
117
+ return { ok: false, reason: `media type must be one of ${MEDIA_TYPES.join(', ')}` };
118
+ }
119
+ if (typeof url !== 'string' || url.length === 0 || url.length > MEDIA_URL_MAX || !/^https?:\/\/[^\s]+$/i.test(url)) {
120
+ return { ok: false, reason: `media url must be an http(s) address of at most ${MEDIA_URL_MAX} characters` };
121
+ }
122
+ if (item.length === 2) {
123
+ value.push([type, url]);
124
+ continue;
125
+ }
126
+ const nameProblem = mediaNameProblem(name);
127
+ if (nameProblem !== undefined)
128
+ return { ok: false, reason: nameProblem };
129
+ value.push([type, url, name]);
130
+ }
131
+ if (JSON.stringify(value).length > MEDIA_TOTAL_MAX)
132
+ return { ok: false, reason: `media must fit in ${MEDIA_TOTAL_MAX} bytes` };
133
+ return { ok: true, value };
134
+ }
135
+ function messageIdFor(eventId) {
136
+ return `p2p-${eventId}`;
137
+ }
138
+ export async function handleMessage(input) {
139
+ const { config, table, auth, payload } = input;
140
+ const parsed = parseRequest(payload, config.maxBodyBytes);
141
+ if (!parsed.ok) {
142
+ if (parsed.protocolMismatch)
143
+ input.stats?.protocolMismatch();
144
+ return {
145
+ ok: false,
146
+ status: parsed.status,
147
+ body: { error: parsed.reason, ...(parsed.protocolMismatch ? { code: 'protocol_mismatch' } : {}) },
148
+ };
149
+ }
150
+ const request = parsed.value;
151
+ const roster = config.peers.map((peer) => peer.instance);
152
+ if (!roster.includes(request.instance)) {
153
+ const name = logName(request.instance);
154
+ const firstForInstance = input.stats === undefined || input.stats.unknownInstance(name);
155
+ if (firstForInstance) {
156
+ config.warn?.(`dsbot-channel: refused a message from unknown instance "${name}" for ${config.botName}; ` +
157
+ `configured peers are ${roster.map(logName).join(', ')}`);
158
+ }
159
+ }
160
+ let resolved;
161
+ try {
162
+ resolved = await resolvePeers(config.peers, config.credentials);
163
+ }
164
+ catch (error) {
165
+ if (error instanceof SecretRefError)
166
+ return { ok: false, status: 401, body: { error: 'peer secret is not configured' } };
167
+ throw error;
168
+ }
169
+ const verified = verifyRequest({
170
+ peers: resolved,
171
+ signature: auth.signature ?? null,
172
+ claimedInstance: auth.claimedInstance,
173
+ envelope: request,
174
+ });
175
+ if (!verified.ok)
176
+ return { ok: false, status: verified.status ?? 401, body: { error: verified.reason ?? 'unauthorized' } };
177
+ input.stats?.authenticated();
178
+ const window = config.sentAtWindowMs ?? DEFAULT_SENT_AT_WINDOW_MS;
179
+ const at = config.now?.() ?? Date.now();
180
+ const skewMs = request.sentAt - at;
181
+ input.stats?.skew(skewMs);
182
+ if (Math.abs(skewMs) > window) {
183
+ const sender = verified.instance ?? request.instance;
184
+ const firstForInstance = input.stats === undefined || input.stats.stale(sender);
185
+ if (firstForInstance) {
186
+ config.warn?.(`dsbot-channel: refused a message from ${sender} for ${config.botName}: ` +
187
+ `sentAt is ${skewMs}ms from the local clock, outside the ${window}ms window`);
188
+ }
189
+ return {
190
+ ok: false,
191
+ status: 403,
192
+ body: {
193
+ error: `sentAt is outside the accepted window of ${window}ms`,
194
+ code: 'stale_sent_at',
195
+ sentAt: request.sentAt,
196
+ now: at,
197
+ },
198
+ };
199
+ }
200
+ if (request.instance !== verified.instance)
201
+ return { ok: false, status: 403, body: { error: 'instance does not match the bound secret' } };
202
+ if (request.to !== config.botName)
203
+ return { ok: false, status: 403, body: { error: 'message is addressed to another bot' } };
204
+ const key = `${request.instance}|${request.to}|${request.eventId}`;
205
+ const stats = input.stats;
206
+ stats?.beginDelivery();
207
+ try {
208
+ const claim = await table.claim(key, config.waitMs ?? 10_000);
209
+ if (claim.done) {
210
+ if (!claim.entry)
211
+ return { ok: false, status: 503, body: { error: 'the first delivery is still being processed' } };
212
+ return { ok: true, status: 200, body: { duplicate: true, messageId: claim.entry.messageId, eventId: request.eventId } };
213
+ }
214
+ const messageId = messageIdFor(request.eventId);
215
+ try {
216
+ if (input.process)
217
+ await input.process(request.eventId);
218
+ table.commit(key, messageId);
219
+ }
220
+ catch (error) {
221
+ table.abort(key);
222
+ throw error;
223
+ }
224
+ return { ok: true, status: 200, body: { duplicate: false, messageId, eventId: request.eventId } };
225
+ }
226
+ finally {
227
+ stats?.endDelivery();
228
+ }
229
+ }
@@ -0,0 +1,28 @@
1
+ import type { PeerSecret } from './p2p-auth.js';
2
+ export interface PeerRef {
3
+ instance: string;
4
+ secret: string;
5
+ /** The name the peer answers to; an active push needs it, a reply borrows the sender's. */
6
+ botName?: string;
7
+ host?: string;
8
+ }
9
+ export interface ResolvedSecret {
10
+ value: string;
11
+ source?: string;
12
+ }
13
+ export interface CredentialsLike {
14
+ resolve(reference: string): Promise<ResolvedSecret | undefined>;
15
+ }
16
+ export type SecretRefReason = 'unresolved' | 'blank';
17
+ export declare class SecretRefError extends Error {
18
+ readonly instance: string;
19
+ readonly reference: string;
20
+ readonly reason: SecretRefReason;
21
+ constructor(instance: string, reference: string, reason?: SecretRefReason);
22
+ }
23
+ /** Resolves each peer reference through the host credentials service, at call time. */
24
+ export declare function resolvePeers(refs: readonly PeerRef[], credentials: CredentialsLike): Promise<PeerSecret[]>;
25
+ /** Startup check so a missing reference fails the mount instead of the first request. */
26
+ export declare function assertPeersResolvable(refs: readonly PeerRef[], credentials: CredentialsLike): Promise<void>;
27
+ /** In-memory source for tests only; a real profile resolves through the host service. */
28
+ export declare function staticCredentials(values: Record<string, string>): CredentialsLike;
@@ -0,0 +1,50 @@
1
+ export class SecretRefError extends Error {
2
+ instance;
3
+ reference;
4
+ reason;
5
+ constructor(instance, reference, reason = 'unresolved') {
6
+ super(reason === 'blank'
7
+ ? `peer "${instance}" refers to credential "${reference}", which resolved to a blank value`
8
+ : `peer "${instance}" refers to credential "${reference}", which did not resolve`);
9
+ this.name = 'SecretRefError';
10
+ this.instance = instance;
11
+ this.reference = reference;
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ /** Resolves each peer reference through the host credentials service, at call time. */
16
+ export async function resolvePeers(refs, credentials) {
17
+ const resolved = [];
18
+ for (const ref of refs) {
19
+ let found;
20
+ try {
21
+ found = await credentials.resolve(ref.secret);
22
+ }
23
+ catch {
24
+ throw new SecretRefError(ref.instance, ref.secret, 'unresolved');
25
+ }
26
+ if (!found)
27
+ throw new SecretRefError(ref.instance, ref.secret, 'unresolved');
28
+ if (!found.value?.trim())
29
+ throw new SecretRefError(ref.instance, ref.secret, 'blank');
30
+ resolved.push({ instance: ref.instance, secret: found.value });
31
+ }
32
+ return resolved;
33
+ }
34
+ /** Startup check so a missing reference fails the mount instead of the first request. */
35
+ export async function assertPeersResolvable(refs, credentials) {
36
+ if (!refs.length)
37
+ throw new Error('p2p: at least one peer is required to start');
38
+ await resolvePeers(refs, credentials);
39
+ }
40
+ /** In-memory source for tests only; a real profile resolves through the host service. */
41
+ export function staticCredentials(values) {
42
+ return {
43
+ resolve: async (reference) => {
44
+ const value = values[reference];
45
+ if (!value)
46
+ return undefined;
47
+ return { value, source: 'static' };
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,37 @@
1
+ import type { P2PMediaItem } from './p2p-wire.js';
2
+ import type { CredentialsLike, PeerRef } from './p2p-secrets.js';
3
+ import type { SendResult } from '@dsbot-channel/core/contract.js';
4
+ export type { SendFailure, SendFailureCode, SendResult, SendSuccess } from '@dsbot-channel/core/contract.js';
5
+ export declare const DEFAULT_SEND_TIMEOUT_MS = 10000;
6
+ export interface PeerTarget extends PeerRef {
7
+ url?: string;
8
+ /** Sent as the Host header, for a peer reached by address behind a name router.
9
+ * http only: https checks the certificate against the url host, not this header. */
10
+ host?: string;
11
+ }
12
+ export interface P2PSendInput {
13
+ peer: PeerTarget;
14
+ credentials: CredentialsLike;
15
+ to: string;
16
+ /** The root event of the conversation; a new conversation uses its own event id. */
17
+ chainId?: string;
18
+ body: string;
19
+ eventId: string;
20
+ sender: string;
21
+ instance: string;
22
+ sentAt?: number;
23
+ timeoutMs?: number;
24
+ waitMs?: number;
25
+ fetchImpl?: typeof fetch;
26
+ /** Marks the message as a refusal; the peer runs no answer for it. */
27
+ status?: 'declined';
28
+ reason?: string;
29
+ final?: boolean;
30
+ /** The event this message answers, so the peer knows what it refers to. */
31
+ inReplyTo?: string;
32
+ inReplyToExcerpt?: string;
33
+ /** Media to carry, as `[type, url]`; every url must be an http(s) address. */
34
+ media?: readonly P2PMediaItem[];
35
+ }
36
+ /** Posts one signed message to a peer; the eventId doubles as the retry key. */
37
+ export declare function sendToPeer(input: P2PSendInput): Promise<SendResult>;
@@ -0,0 +1,137 @@
1
+ import { request as httpRequest } from 'node:http';
2
+ import { request as httpsRequest } from 'node:https';
3
+ import { canonicalEnvelope, signEnvelope } from './p2p-auth.js';
4
+ import { SecretRefError, resolvePeers } from './p2p-secrets.js';
5
+ import { P2P_ROUTE } from './p2p-protocol.js';
6
+ import { oneLine } from './p2p-mapping.js';
7
+ export const DEFAULT_SEND_TIMEOUT_MS = 10_000;
8
+ function failure(code, retryable, reason) {
9
+ return { ok: false, code, retryable, reason };
10
+ }
11
+ /** Posts the signed bytes with an explicit Host header, which fetch refuses to set. */
12
+ function postWithHost(url, host, payload, signature, timeoutMs) {
13
+ return new Promise((resolve) => {
14
+ const target = new URL(url);
15
+ const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
16
+ const request = send({
17
+ protocol: target.protocol,
18
+ hostname: target.hostname,
19
+ port: target.port,
20
+ path: `${target.pathname.replace(/\/$/, '')}${P2P_ROUTE}`,
21
+ method: 'POST',
22
+ headers: {
23
+ 'content-type': 'application/json',
24
+ 'content-length': Buffer.byteLength(payload),
25
+ 'x-p2p-signature': signature,
26
+ host,
27
+ },
28
+ signal: AbortSignal.timeout(timeoutMs),
29
+ }, (response) => {
30
+ const chunks = [];
31
+ response.on('data', (chunk) => chunks.push(chunk));
32
+ response.on('end', () => {
33
+ const raw = Buffer.concat(chunks).toString('utf8');
34
+ let body;
35
+ try {
36
+ body = raw.length === 0 ? {} : JSON.parse(raw);
37
+ }
38
+ catch {
39
+ resolve(failure('rejected', false, `the peer answered ${response.statusCode ?? 0} without json`));
40
+ return;
41
+ }
42
+ resolve({ status: response.statusCode ?? 0, body });
43
+ });
44
+ });
45
+ request.on('error', (error) => {
46
+ if (error.name === 'AbortError' || error.name === 'TimeoutError' || error.code === 'ABORT_ERR') {
47
+ resolve(failure('timeout', false, 'the peer did not answer in time'));
48
+ return;
49
+ }
50
+ resolve(failure('unreachable', true, 'the peer could not be reached'));
51
+ });
52
+ request.end(payload);
53
+ });
54
+ }
55
+ /** Posts one signed message to a peer; the eventId doubles as the retry key. */
56
+ export async function sendToPeer(input) {
57
+ const url = input.peer.url;
58
+ if (!url)
59
+ return failure('no_url', false, `peer "${input.peer.instance}" has no url configured`);
60
+ let secret;
61
+ try {
62
+ const resolved = await resolvePeers([input.peer], input.credentials);
63
+ secret = resolved[0].secret;
64
+ }
65
+ catch (error) {
66
+ if (error instanceof SecretRefError)
67
+ return failure('credential', false, error.message);
68
+ throw error;
69
+ }
70
+ const sentAt = input.sentAt ?? Date.now();
71
+ // A name is a file's label: any other type loses it here, so one peer that never read a
72
+ // three-element item keeps seeing the two elements it always did.
73
+ const media = (input.media ?? []).map(([type, url, name]) => type === 'file' && name !== undefined ? [type, url, name] : [type, url]);
74
+ const envelope = {
75
+ eventId: input.eventId,
76
+ chainId: input.chainId ?? input.eventId,
77
+ to: input.to,
78
+ sender: input.sender,
79
+ instance: input.instance,
80
+ body: input.body,
81
+ sentAt,
82
+ ...(input.status === undefined ? {} : { status: input.status }),
83
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
84
+ ...(input.final === undefined ? {} : { final: input.final }),
85
+ ...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }),
86
+ ...(input.inReplyToExcerpt === undefined ? {} : { inReplyToExcerpt: input.inReplyToExcerpt }),
87
+ ...(media.length === 0 ? {} : { media }),
88
+ };
89
+ const signature = signEnvelope(secret, envelope);
90
+ const payload = canonicalEnvelope(envelope);
91
+ const timeoutMs = input.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
92
+ let answered;
93
+ if (input.peer.host === undefined) {
94
+ const send = input.fetchImpl ?? fetch;
95
+ try {
96
+ const response = await send(`${url.replace(/\/$/, '')}${P2P_ROUTE}`, {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/json', 'x-p2p-signature': signature },
99
+ body: payload,
100
+ signal: AbortSignal.timeout(timeoutMs),
101
+ });
102
+ let parsed;
103
+ try {
104
+ parsed = await response.json();
105
+ }
106
+ catch {
107
+ return failure('rejected', false, `the peer answered ${response.status} without json`);
108
+ }
109
+ answered = { status: response.status, body: parsed };
110
+ }
111
+ catch (error) {
112
+ const name = error.name;
113
+ if (name === 'TimeoutError' || name === 'AbortError')
114
+ return failure('timeout', false, 'the peer did not answer in time');
115
+ return failure('unreachable', true, 'the peer could not be reached');
116
+ }
117
+ }
118
+ else {
119
+ answered = await postWithHost(url, input.peer.host, payload, signature, timeoutMs);
120
+ }
121
+ if (!('status' in answered))
122
+ return answered;
123
+ const body = answered.body;
124
+ if (answered.status === 200) {
125
+ return { ok: true, messageId: body.messageId ?? `p2p-${input.eventId}`, duplicate: body.duplicate === true };
126
+ }
127
+ if (answered.status === 401 && body.code === 'protocol_mismatch') {
128
+ return failure('protocol_mismatch', false, oneLine(body.error ?? 'the peer speaks another protocol'));
129
+ }
130
+ if (answered.status === 401)
131
+ return failure('unauthorized', false, oneLine(body.error ?? 'unauthorized'));
132
+ if (answered.status === 403)
133
+ return failure('forbidden', false, oneLine(body.error ?? 'forbidden'));
134
+ if (answered.status === 503)
135
+ return failure('busy', true, oneLine(body.error ?? 'the peer is still working on this event'));
136
+ return failure('rejected', answered.status >= 500, oneLine(body.error ?? `the peer answered ${answered.status}`));
137
+ }
@@ -0,0 +1 @@
1
+ export declare const STATUS_ROUTE = "/p2p/v1/status";
@@ -0,0 +1 @@
1
+ export const STATUS_ROUTE = '/p2p/v1/status';
@@ -0,0 +1,18 @@
1
+ /** `[type, url]`, with a file's name appended when the sender gave one. */
2
+ export type P2PMediaItem = readonly [type: string, url: string, name?: string];
3
+ export interface InboundRequest {
4
+ eventId: string;
5
+ chainId: string;
6
+ to: string;
7
+ sender: string;
8
+ instance: string;
9
+ body: string;
10
+ media?: readonly P2PMediaItem[];
11
+ sentAt: number;
12
+ /** The peer refused this request, so its turn runs but answers nothing. */
13
+ status?: 'declined';
14
+ reason?: string;
15
+ final?: boolean;
16
+ inReplyTo?: string;
17
+ inReplyToExcerpt?: string;
18
+ }