@ghostlytools/cli 1.3.0 → 1.3.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.
- package/README.md +9 -3
- package/dist/assets/{api-CrxVxtY1.js → api-Dq2QXQDk.js} +141 -40
- package/dist/assets/api-Dq2QXQDk.js.map +1 -0
- package/dist/assets/{api-BiaX6l6f.js → api-TiB5QSG9.js} +2 -2
- package/dist/assets/{apiKit-CajiDxdK.js → apiKit-CA2x5GJF.js} +7 -3
- package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
- package/dist/assets/{apps-CbfiCSIw.js → apps-BxV9i_Y4.js} +3 -4
- package/dist/assets/{apps-CbfiCSIw.js.map → apps-BxV9i_Y4.js.map} +1 -1
- package/dist/assets/{apps-t23gkc28.js → apps-C-T8jeji.js} +1 -1
- package/dist/assets/{arkade-D4BVJFEQ.js → arkade-C_qDmHnm.js} +2 -2
- package/dist/assets/{arkade-D4BVJFEQ.js.map → arkade-C_qDmHnm.js.map} +1 -1
- package/dist/assets/{backup-Cg52WeQi.js → backup-Bugnn3Ue.js} +1 -1
- package/dist/assets/{backup-BAMi2lAq.js → backup-CnMN2oUK.js} +8 -8
- package/dist/assets/backup-CnMN2oUK.js.map +1 -0
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoind-CZchoh43.js +349 -0
- package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
- package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
- package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
- package/dist/assets/{buttons-CX1pKB0j.js → buttons-CvNBzh2S.js} +4 -5
- package/dist/assets/{buttons-CX1pKB0j.js.map → buttons-CvNBzh2S.js.map} +1 -1
- package/dist/assets/{endpoint-Dn7H8qE1.js → endpoint-BOTFMpfS.js} +44 -12
- package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
- package/dist/assets/{envelope-BvU0fLEt.js → envelope-WjingYSc.js} +2 -2
- package/dist/assets/{envelope-BvU0fLEt.js.map → envelope-WjingYSc.js.map} +1 -1
- package/dist/assets/{errors-my6VJR_0.js → errors-DO5z6h_l.js} +29 -1
- package/dist/assets/errors-DO5z6h_l.js.map +1 -0
- package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
- package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
- package/dist/assets/{host-Cmd205Dc.js → host-CWt-jtqz.js} +41 -16
- package/dist/assets/host-CWt-jtqz.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-CEmBpT0Q.js → hyperdhtRelay-CPYwSLLS.js} +2 -3
- package/dist/assets/{hyperdhtRelay-CEmBpT0Q.js.map → hyperdhtRelay-CPYwSLLS.js.map} +1 -1
- package/dist/assets/{irohWeb--YyEA-hX.js → irohWeb-4oChIOIp.js} +2 -3
- package/dist/assets/{irohWeb--YyEA-hX.js.map → irohWeb-4oChIOIp.js.map} +1 -1
- package/dist/assets/{main-BzNN6bkx.js → main-DbqcdShN.js} +142 -35
- package/dist/assets/main-DbqcdShN.js.map +1 -0
- package/dist/assets/{mainline-D5D5C-jT.js → mainline-ZkiOnevs.js} +2 -2
- package/dist/assets/{mainline-D5D5C-jT.js.map → mainline-ZkiOnevs.js.map} +1 -1
- package/dist/assets/{networkReason-DxZWHw6v.js → networkReason-CetKkTPo.js} +2 -2
- package/dist/assets/{networkReason-DxZWHw6v.js.map → networkReason-CetKkTPo.js.map} +1 -1
- package/dist/assets/nwc-CgkqyvsE.js +612 -0
- package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
- package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
- package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
- package/dist/assets/{profiles-B6F4oa9K.js → profiles-BHc9pEYg.js} +2 -2
- package/dist/assets/{profiles-B6F4oa9K.js.map → profiles-BHc9pEYg.js.map} +1 -1
- package/dist/assets/registry-DlAKO0Fy.js +3793 -0
- package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
- package/dist/assets/registry-OBJBnHVQ.js +2 -0
- package/dist/assets/resolve-CouHhCpJ.js +591 -0
- package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
- package/dist/assets/{server-CBCEYKMN.js → server-Ss0gJ-nw.js} +184 -1181
- package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
- package/dist/assets/{src-DhfGgAUu.js → src-DDzSERyc.js} +2249 -72
- package/dist/assets/src-DDzSERyc.js.map +1 -0
- package/dist/assets/{turnRefs-ChEfgbSX.js → turnRefs-B87dtrHM.js} +2 -2
- package/dist/assets/{turnRefs-ChEfgbSX.js.map → turnRefs-B87dtrHM.js.map} +1 -1
- package/dist/assets/types-C4_KafL_.js +122 -0
- package/dist/assets/types-C4_KafL_.js.map +1 -0
- package/dist/assets/{usdt-C1Xao2xH.js → usdt-Fj8CQO2z.js} +3 -3
- package/dist/assets/{usdt-C1Xao2xH.js.map → usdt-Fj8CQO2z.js.map} +1 -1
- package/dist/ghostly.mjs +1 -1
- package/package.json +1 -1
- package/dist/assets/api-CrxVxtY1.js.map +0 -1
- package/dist/assets/apiKit-CajiDxdK.js.map +0 -1
- package/dist/assets/backup-BAMi2lAq.js.map +0 -1
- package/dist/assets/buttons-CWl2yNJu.js +0 -2
- package/dist/assets/endpoint-Dn7H8qE1.js.map +0 -1
- package/dist/assets/errors-my6VJR_0.js.map +0 -1
- package/dist/assets/groupFiles-BNTSDaoq.js +0 -7685
- package/dist/assets/groupFiles-BNTSDaoq.js.map +0 -1
- package/dist/assets/groupFiles-DBbjOLuD.js +0 -2088
- package/dist/assets/groupFiles-DBbjOLuD.js.map +0 -1
- package/dist/assets/host-Cmd205Dc.js.map +0 -1
- package/dist/assets/main-BzNN6bkx.js.map +0 -1
- package/dist/assets/server-CBCEYKMN.js.map +0 -1
- package/dist/assets/src-DhfGgAUu.js.map +0 -1
|
@@ -1,2088 +0,0 @@
|
|
|
1
|
-
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
2
|
-
import { sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
-
//#region ../core/src/bytes.ts
|
|
4
|
-
var textEncoder = new TextEncoder();
|
|
5
|
-
var textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
6
|
-
function utf8Encode(str) {
|
|
7
|
-
return textEncoder.encode(str);
|
|
8
|
-
}
|
|
9
|
-
function utf8Decode(bytes) {
|
|
10
|
-
return textDecoder.decode(bytes);
|
|
11
|
-
}
|
|
12
|
-
function concatBytes(...parts) {
|
|
13
|
-
let length = 0;
|
|
14
|
-
for (const p of parts) length += p.length;
|
|
15
|
-
const out = new Uint8Array(length);
|
|
16
|
-
let offset = 0;
|
|
17
|
-
for (const p of parts) {
|
|
18
|
-
out.set(p, offset);
|
|
19
|
-
offset += p.length;
|
|
20
|
-
}
|
|
21
|
-
return out;
|
|
22
|
-
}
|
|
23
|
-
function bytesEqual(a, b) {
|
|
24
|
-
if (a.length !== b.length) return false;
|
|
25
|
-
let diff = 0;
|
|
26
|
-
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
27
|
-
return diff === 0;
|
|
28
|
-
}
|
|
29
|
-
/** `Uint8Array.prototype.toBase64` (Node 25+, Chrome 140+, Firefox 133+, Safari 18.2+): ~200x the JS below. */
|
|
30
|
-
var nativeToBase64 = Uint8Array.prototype.toBase64;
|
|
31
|
-
var tableOf = (chars) => Uint8Array.from(chars, (c) => c.charCodeAt(0));
|
|
32
|
-
var BASE64_TABLE = tableOf("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
|
|
33
|
-
var BASE64URL_TABLE = tableOf("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_");
|
|
34
|
-
/**
|
|
35
|
-
* Base64 where the runtime has no native encoder (Node 22 and 24, older WebViews): the characters go into bytes and
|
|
36
|
-
* become one string at the end. Every file chunk is encoded (files/3 `pf-data`), so this is on a transfer's path.
|
|
37
|
-
*/
|
|
38
|
-
function encodeBase64(bytes, table, pad) {
|
|
39
|
-
const out = new Uint8Array(Math.ceil(bytes.length / 3) * 4);
|
|
40
|
-
let o = 0, i = 0;
|
|
41
|
-
for (; i + 2 < bytes.length; i += 3) {
|
|
42
|
-
const n = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
|
|
43
|
-
out[o++] = table[n >>> 18];
|
|
44
|
-
out[o++] = table[n >>> 12 & 63];
|
|
45
|
-
out[o++] = table[n >>> 6 & 63];
|
|
46
|
-
out[o++] = table[n & 63];
|
|
47
|
-
}
|
|
48
|
-
const rest = bytes.length - i;
|
|
49
|
-
if (rest) {
|
|
50
|
-
const n = bytes[i] << 16 | (rest === 2 ? bytes[i + 1] << 8 : 0);
|
|
51
|
-
out[o++] = table[n >>> 18];
|
|
52
|
-
out[o++] = table[n >>> 12 & 63];
|
|
53
|
-
if (rest === 2) out[o++] = table[n >>> 6 & 63];
|
|
54
|
-
else if (pad) out[o++] = 61;
|
|
55
|
-
if (pad) out[o++] = 61;
|
|
56
|
-
}
|
|
57
|
-
return textDecoder.decode(out.subarray(0, o));
|
|
58
|
-
}
|
|
59
|
-
function toBase64(bytes) {
|
|
60
|
-
return nativeToBase64 ? nativeToBase64.call(bytes) : encodeBase64(bytes, BASE64_TABLE, true);
|
|
61
|
-
}
|
|
62
|
-
function fromBase64(str) {
|
|
63
|
-
const binary = atob(str);
|
|
64
|
-
const bytes = new Uint8Array(binary.length);
|
|
65
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
66
|
-
return bytes;
|
|
67
|
-
}
|
|
68
|
-
function toBase64Url(bytes) {
|
|
69
|
-
return nativeToBase64 ? nativeToBase64.call(bytes, {
|
|
70
|
-
alphabet: "base64url",
|
|
71
|
-
omitPadding: true
|
|
72
|
-
}) : encodeBase64(bytes, BASE64URL_TABLE, false);
|
|
73
|
-
}
|
|
74
|
-
function fromBase64Url(str) {
|
|
75
|
-
let b = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
76
|
-
while (b.length % 4) b += "=";
|
|
77
|
-
return fromBase64(b);
|
|
78
|
-
}
|
|
79
|
-
var Z32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769";
|
|
80
|
-
function toZ32(bytes) {
|
|
81
|
-
let out = "";
|
|
82
|
-
let bits = 0;
|
|
83
|
-
let value = 0;
|
|
84
|
-
for (const byte of bytes) {
|
|
85
|
-
value = value << 8 | byte;
|
|
86
|
-
bits += 8;
|
|
87
|
-
while (bits >= 5) {
|
|
88
|
-
out += Z32_ALPHABET[value >>> bits - 5 & 31];
|
|
89
|
-
bits -= 5;
|
|
90
|
-
}
|
|
91
|
-
value &= (1 << bits) - 1;
|
|
92
|
-
}
|
|
93
|
-
if (bits > 0) out += Z32_ALPHABET[value << 5 - bits & 31];
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
96
|
-
function fromZ32(str) {
|
|
97
|
-
const out = new Uint8Array(Math.floor(str.length * 5 / 8));
|
|
98
|
-
let bits = 0;
|
|
99
|
-
let value = 0;
|
|
100
|
-
let index = 0;
|
|
101
|
-
for (const char of str) {
|
|
102
|
-
const v = Z32_ALPHABET.indexOf(char);
|
|
103
|
-
if (v < 0) throw new Error(`Invalid z-base-32 character: ${char}`);
|
|
104
|
-
value = value << 5 | v;
|
|
105
|
-
bits += 5;
|
|
106
|
-
if (bits >= 8) {
|
|
107
|
-
out[index++] = value >>> bits - 8 & 255;
|
|
108
|
-
bits -= 8;
|
|
109
|
-
}
|
|
110
|
-
value &= (1 << bits) - 1;
|
|
111
|
-
}
|
|
112
|
-
return out;
|
|
113
|
-
}
|
|
114
|
-
function randomBytes(length) {
|
|
115
|
-
const bytes = new Uint8Array(length);
|
|
116
|
-
crypto.getRandomValues(bytes);
|
|
117
|
-
return bytes;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Invisible and direction-changing characters. Unlike a file name, display
|
|
121
|
-
* text may legitimately hold emoji and Indic or Arabic joiners, so the two
|
|
122
|
-
* joiners stay and the rest of Cf goes: bidi overrides and isolates reorder
|
|
123
|
-
* what follows them, and the others hide text or let two different names
|
|
124
|
-
* render identically.
|
|
125
|
-
*/
|
|
126
|
-
var HIDDEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
127
|
-
/** Zero-width non-joiner and zero-width joiner. */
|
|
128
|
-
var KEEP = /[\u200c\u200d]/;
|
|
129
|
-
/** `text` without its invisible and direction-changing characters, at any length: for display text with a cut of its own. */
|
|
130
|
-
function stripHidden(text) {
|
|
131
|
-
return text.replace(HIDDEN, (char) => KEEP.test(char) ? char : "");
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* What draws nothing when it stands alone and `HIDDEN` leaves in, since text needs it beside something else: the
|
|
135
|
-
* joiners, the fillers, the variation selectors and the other default-ignorable code points, combining marks, white
|
|
136
|
-
* space and the blank Braille pattern.
|
|
137
|
-
*/
|
|
138
|
-
var UNSEEN = /^[\p{Default_Ignorable_Code_Point}\p{M}\p{White_Space}\u2800]*$/u;
|
|
139
|
-
/** Whether `text` holds a character that shows by itself: a name of joiners, fillers or marks alone has none. */
|
|
140
|
-
function hasVisible(text) {
|
|
141
|
-
return !UNSEEN.test(text);
|
|
142
|
-
}
|
|
143
|
-
/** Undefined when nothing visible is left, so callers can fall back. */
|
|
144
|
-
function sanitizeDisplayText(text, max) {
|
|
145
|
-
const clean = [...stripHidden(text)].slice(0, max).join("").trim();
|
|
146
|
-
return hasVisible(clean) ? clean : void 0;
|
|
147
|
-
}
|
|
148
|
-
/** A nickname as it arrived from a peer, ready to show. */
|
|
149
|
-
function sanitizeNick(nick) {
|
|
150
|
-
return typeof nick === "string" ? sanitizeDisplayText(nick, 64) : void 0;
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* `text` without the characters of `chars` it ends with, in one pass from the end. A `/[…]+$/` replace would retry
|
|
154
|
-
* from every character of a run that does not reach the end.
|
|
155
|
-
*/
|
|
156
|
-
function trimEndOf(text, chars) {
|
|
157
|
-
let end = text.length;
|
|
158
|
-
while (end > 0 && chars.includes(text[end - 1])) end--;
|
|
159
|
-
return end === text.length ? text : text.slice(0, end);
|
|
160
|
-
}
|
|
161
|
-
//#endregion
|
|
162
|
-
//#region ../core/src/frames.ts
|
|
163
|
-
/**
|
|
164
|
-
* Framing on the `ghostly/1` DataChannel (ordered, reliable).
|
|
165
|
-
*
|
|
166
|
-
* - Text messages are control frames: compact JSON with a `t` discriminator,
|
|
167
|
-
* the same shape Ghostly already uses for `_msgs` and `_call`.
|
|
168
|
-
* - Binary messages are body chunks that belong to a stream opened by a
|
|
169
|
-
* control frame: `<kind u8><stream id u32 BE><flags u8><payload>`.
|
|
170
|
-
*
|
|
171
|
-
* Everything here handles data from the remote peer and must never trust it.
|
|
172
|
-
*/
|
|
173
|
-
var LIMITS = {
|
|
174
|
-
/** Largest control frame accepted, headers included. */
|
|
175
|
-
maxControlFrameBytes: 61440,
|
|
176
|
-
/** Longest payment endpoint value (a Cashu token, an invoice) a payment frame may carry; a longer one is dropped. */
|
|
177
|
-
maxPaymentEndpointChars: 32768,
|
|
178
|
-
/** Largest DataChannel message sent. 16 KiB is safe across WebRTC stacks. */
|
|
179
|
-
maxChunkMessageBytes: 16384,
|
|
180
|
-
maxRequestBodyBytes: 8388608,
|
|
181
|
-
maxResponseBodyBytes: 67108864,
|
|
182
|
-
maxHeaderCount: 128,
|
|
183
|
-
maxHeaderValueBytes: 16384,
|
|
184
|
-
maxPathBytes: 8192,
|
|
185
|
-
/** Requests a host serves at once for one peer. */
|
|
186
|
-
maxConcurrentRequestsPerPeer: 32,
|
|
187
|
-
/** Requests a client keeps in flight; the rest wait in a queue. */
|
|
188
|
-
maxClientInFlight: 16,
|
|
189
|
-
maxChatMessageBytes: 16384,
|
|
190
|
-
maxFileBytes: 104857600,
|
|
191
|
-
/** Files a peer may be sending us at once. */
|
|
192
|
-
maxIncomingFilesPerPeer: 3,
|
|
193
|
-
/** Received bytes kept for one peer, in flight included. Past it new files are refused. */
|
|
194
|
-
maxStoredIncomingBytesPerPeer: 524288e3,
|
|
195
|
-
requestTimeoutMs: 6e4,
|
|
196
|
-
bodyIdleTimeoutMs: 3e4,
|
|
197
|
-
/** Stop writing to the channel above this many buffered bytes. */
|
|
198
|
-
sendHighWaterMark: 1048576,
|
|
199
|
-
sendLowWaterMark: 262144
|
|
200
|
-
};
|
|
201
|
-
var CHUNK_KIND = {
|
|
202
|
-
requestBody: 1,
|
|
203
|
-
responseBody: 2,
|
|
204
|
-
fileBody: 3
|
|
205
|
-
};
|
|
206
|
-
var CHUNK_HEADER_BYTES = 6;
|
|
207
|
-
var MAX_CHUNK_PAYLOAD_BYTES = LIMITS.maxChunkMessageBytes - CHUNK_HEADER_BYTES;
|
|
208
|
-
function encodeControl(frame) {
|
|
209
|
-
const json = JSON.stringify(frame);
|
|
210
|
-
if (utf8Encode(json).length > LIMITS.maxControlFrameBytes) throw new Error("Control frame too large");
|
|
211
|
-
return json;
|
|
212
|
-
}
|
|
213
|
-
function isHeaderList(value) {
|
|
214
|
-
if (!Array.isArray(value) || value.length > LIMITS.maxHeaderCount) return false;
|
|
215
|
-
return value.every((h) => Array.isArray(h) && h.length === 2 && typeof h[0] === "string" && typeof h[1] === "string" && h[0].length <= 256 && h[1].length <= LIMITS.maxHeaderValueBytes);
|
|
216
|
-
}
|
|
217
|
-
function isStreamId(value) {
|
|
218
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4294967295;
|
|
219
|
-
}
|
|
220
|
-
function isPayId(value) {
|
|
221
|
-
return typeof value === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(value);
|
|
222
|
-
}
|
|
223
|
-
/** Decimal text, as in Paykit's Payment Amount: no floats on the wire. */
|
|
224
|
-
function isAmount(value) {
|
|
225
|
-
return typeof value === "string" && /^(0|[1-9]\d{0,15})(\.\d{1,8})?$/.test(value);
|
|
226
|
-
}
|
|
227
|
-
function isUnit(value) {
|
|
228
|
-
return typeof value === "string" && /^[a-z0-9]{1,12}$/.test(value);
|
|
229
|
-
}
|
|
230
|
-
function isEndpoint(value) {
|
|
231
|
-
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && value[0].length <= 64 && /^[a-z0-9][a-z0-9-]*(?:\/[1-9][0-9]*)?$/.test(value[0]) && typeof value[1] === "string" && value[1].length <= LIMITS.maxPaymentEndpointChars;
|
|
232
|
-
}
|
|
233
|
-
/** Returns null for anything malformed; callers drop the frame. */
|
|
234
|
-
function decodeControl(text) {
|
|
235
|
-
if (text.length > LIMITS.maxControlFrameBytes || utf8Encode(text).length > LIMITS.maxControlFrameBytes) return null;
|
|
236
|
-
let raw;
|
|
237
|
-
try {
|
|
238
|
-
raw = JSON.parse(text);
|
|
239
|
-
} catch {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
if (!raw || typeof raw !== "object") return null;
|
|
243
|
-
const f = raw;
|
|
244
|
-
switch (f.t) {
|
|
245
|
-
case "hello":
|
|
246
|
-
if (typeof f.v !== "number") return null;
|
|
247
|
-
return {
|
|
248
|
-
t: "hello",
|
|
249
|
-
v: f.v,
|
|
250
|
-
svc: f.svc,
|
|
251
|
-
nick: sanitizeNick(f.nick)
|
|
252
|
-
};
|
|
253
|
-
case "m":
|
|
254
|
-
if (typeof f.ts !== "number" || typeof f.m !== "string") return null;
|
|
255
|
-
if (utf8Encode(f.m).length > LIMITS.maxChatMessageBytes) return null;
|
|
256
|
-
return {
|
|
257
|
-
t: "m",
|
|
258
|
-
ts: f.ts,
|
|
259
|
-
m: f.m
|
|
260
|
-
};
|
|
261
|
-
case "call":
|
|
262
|
-
if (typeof f.s !== "string" || utf8Encode(f.s).length > LIMITS.maxChatMessageBytes) return null;
|
|
263
|
-
return {
|
|
264
|
-
t: "call",
|
|
265
|
-
s: f.s
|
|
266
|
-
};
|
|
267
|
-
case "svc": return {
|
|
268
|
-
t: "svc",
|
|
269
|
-
svc: f.svc
|
|
270
|
-
};
|
|
271
|
-
case "req":
|
|
272
|
-
if (!isStreamId(f.id) || typeof f.s !== "string" || typeof f.m !== "string") return null;
|
|
273
|
-
if (typeof f.p !== "string" || f.p.length > LIMITS.maxPathBytes || !isHeaderList(f.h)) return null;
|
|
274
|
-
return {
|
|
275
|
-
t: "req",
|
|
276
|
-
id: f.id,
|
|
277
|
-
s: f.s,
|
|
278
|
-
m: f.m,
|
|
279
|
-
p: f.p,
|
|
280
|
-
h: f.h,
|
|
281
|
-
b: f.b === true
|
|
282
|
-
};
|
|
283
|
-
case "res":
|
|
284
|
-
if (!isStreamId(f.id) || typeof f.st !== "number" || !Number.isInteger(f.st)) return null;
|
|
285
|
-
if (f.st < 100 || f.st > 599 || !isHeaderList(f.h)) return null;
|
|
286
|
-
return {
|
|
287
|
-
t: "res",
|
|
288
|
-
id: f.id,
|
|
289
|
-
st: f.st,
|
|
290
|
-
h: f.h,
|
|
291
|
-
b: f.b === true
|
|
292
|
-
};
|
|
293
|
-
case "file":
|
|
294
|
-
if (!isStreamId(f.id) || typeof f.f !== "string" || typeof f.ts !== "number") return null;
|
|
295
|
-
if (typeof f.n !== "string" || typeof f.m !== "string") return null;
|
|
296
|
-
if (typeof f.s !== "number" || !Number.isSafeInteger(f.s) || f.s < 0) return null;
|
|
297
|
-
return {
|
|
298
|
-
t: "file",
|
|
299
|
-
id: f.id,
|
|
300
|
-
f: f.f,
|
|
301
|
-
ts: f.ts,
|
|
302
|
-
n: f.n,
|
|
303
|
-
s: f.s,
|
|
304
|
-
m: f.m
|
|
305
|
-
};
|
|
306
|
-
case "pay-req":
|
|
307
|
-
case "pay": {
|
|
308
|
-
if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
|
|
309
|
-
const memo = typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0;
|
|
310
|
-
if (f.t === "pay-req") {
|
|
311
|
-
if (!Array.isArray(f.e) || f.e.length === 0 || f.e.length > 8 || !f.e.every(isEndpoint)) return null;
|
|
312
|
-
return {
|
|
313
|
-
t: "pay-req",
|
|
314
|
-
id: f.id,
|
|
315
|
-
ts: f.ts,
|
|
316
|
-
v: f.v,
|
|
317
|
-
u: f.u,
|
|
318
|
-
memo,
|
|
319
|
-
e: f.e,
|
|
320
|
-
a: isPayId(f.a) ? f.a : void 0,
|
|
321
|
-
n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
if (!isEndpoint(f.e) || f.rid !== void 0 && !isPayId(f.rid)) return null;
|
|
325
|
-
return {
|
|
326
|
-
t: "pay",
|
|
327
|
-
id: f.id,
|
|
328
|
-
ts: f.ts,
|
|
329
|
-
rid: f.rid,
|
|
330
|
-
v: f.v,
|
|
331
|
-
u: f.u,
|
|
332
|
-
memo,
|
|
333
|
-
e: f.e
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
case "pay-ask":
|
|
337
|
-
if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
|
|
338
|
-
if (f.m !== "arkade" && f.m !== "usdt" && f.m !== "bark" && f.m !== "bitcoin" && f.m !== "fedimint" && f.m !== "spark") return null;
|
|
339
|
-
return {
|
|
340
|
-
t: "pay-ask",
|
|
341
|
-
id: f.id,
|
|
342
|
-
ts: f.ts,
|
|
343
|
-
v: f.v,
|
|
344
|
-
u: f.u,
|
|
345
|
-
m: f.m,
|
|
346
|
-
memo: typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0,
|
|
347
|
-
n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
|
|
348
|
-
};
|
|
349
|
-
case "pay-res":
|
|
350
|
-
if (!isPayId(f.id) || typeof f.ok !== "boolean") return null;
|
|
351
|
-
if (f.v !== void 0 && !isAmount(f.v)) return null;
|
|
352
|
-
return {
|
|
353
|
-
t: "pay-res",
|
|
354
|
-
id: f.id,
|
|
355
|
-
ok: f.ok,
|
|
356
|
-
v: f.v,
|
|
357
|
-
err: typeof f.err === "string" ? f.err.slice(0, 200) : void 0,
|
|
358
|
-
...f.c === true && !f.ok ? { c: true } : {}
|
|
359
|
-
};
|
|
360
|
-
case "rst":
|
|
361
|
-
if (!isStreamId(f.id) || f.d !== "q" && f.d !== "s" && f.d !== "f") return null;
|
|
362
|
-
return {
|
|
363
|
-
t: "rst",
|
|
364
|
-
id: f.id,
|
|
365
|
-
d: f.d,
|
|
366
|
-
e: typeof f.e === "string" ? f.e.slice(0, 256) : ""
|
|
367
|
-
};
|
|
368
|
-
case "ping":
|
|
369
|
-
case "pong":
|
|
370
|
-
if (typeof f.ts !== "number") return null;
|
|
371
|
-
return {
|
|
372
|
-
t: f.t,
|
|
373
|
-
ts: f.ts
|
|
374
|
-
};
|
|
375
|
-
default: return null;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
function encodeChunk(chunk) {
|
|
379
|
-
if (chunk.payload.length > MAX_CHUNK_PAYLOAD_BYTES) throw new Error("Chunk payload too large");
|
|
380
|
-
const out = new Uint8Array(CHUNK_HEADER_BYTES + chunk.payload.length);
|
|
381
|
-
const view = new DataView(out.buffer);
|
|
382
|
-
view.setUint8(0, chunk.kind);
|
|
383
|
-
view.setUint32(1, chunk.id);
|
|
384
|
-
view.setUint8(5, chunk.end ? 1 : 0);
|
|
385
|
-
out.set(chunk.payload, CHUNK_HEADER_BYTES);
|
|
386
|
-
return out;
|
|
387
|
-
}
|
|
388
|
-
function decodeChunk(data) {
|
|
389
|
-
if (data.length < CHUNK_HEADER_BYTES || data.length > LIMITS.maxChunkMessageBytes) return null;
|
|
390
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
391
|
-
const kind = view.getUint8(0);
|
|
392
|
-
if (kind !== CHUNK_KIND.requestBody && kind !== CHUNK_KIND.responseBody && kind !== CHUNK_KIND.fileBody) return null;
|
|
393
|
-
return {
|
|
394
|
-
kind,
|
|
395
|
-
id: view.getUint32(1),
|
|
396
|
-
end: (view.getUint8(5) & 1) !== 0,
|
|
397
|
-
payload: data.subarray(CHUNK_HEADER_BYTES)
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
function wrapDataChannel(dc) {
|
|
401
|
-
dc.binaryType = "arraybuffer";
|
|
402
|
-
dc.bufferedAmountLowThreshold = LIMITS.sendLowWaterMark;
|
|
403
|
-
const channel = {
|
|
404
|
-
send(data) {
|
|
405
|
-
if (dc.readyState !== "open") throw new Error("Data link is not open");
|
|
406
|
-
if (typeof data === "string") dc.send(data);
|
|
407
|
-
else dc.send(data);
|
|
408
|
-
},
|
|
409
|
-
get bufferedAmount() {
|
|
410
|
-
return dc.bufferedAmount;
|
|
411
|
-
},
|
|
412
|
-
drained() {
|
|
413
|
-
if (dc.bufferedAmount <= LIMITS.sendLowWaterMark || dc.readyState !== "open") return Promise.resolve();
|
|
414
|
-
return new Promise((resolve) => {
|
|
415
|
-
const done = () => {
|
|
416
|
-
dc.removeEventListener("bufferedamountlow", done);
|
|
417
|
-
dc.removeEventListener("close", done);
|
|
418
|
-
resolve();
|
|
419
|
-
};
|
|
420
|
-
dc.addEventListener("bufferedamountlow", done);
|
|
421
|
-
dc.addEventListener("close", done);
|
|
422
|
-
});
|
|
423
|
-
},
|
|
424
|
-
close() {
|
|
425
|
-
dc.close();
|
|
426
|
-
},
|
|
427
|
-
binary: true,
|
|
428
|
-
onMessage: null,
|
|
429
|
-
onClose: null
|
|
430
|
-
};
|
|
431
|
-
dc.addEventListener("message", (event) => {
|
|
432
|
-
const data = event.data;
|
|
433
|
-
if (typeof data === "string") channel.onMessage?.(data);
|
|
434
|
-
else if (data instanceof ArrayBuffer) channel.onMessage?.(new Uint8Array(data));
|
|
435
|
-
});
|
|
436
|
-
dc.addEventListener("close", () => channel.onClose?.());
|
|
437
|
-
return channel;
|
|
438
|
-
}
|
|
439
|
-
/** Writes a body as chunks, honouring backpressure. Always ends with an END chunk. */
|
|
440
|
-
async function sendBody(channel, kind, id, body, isCancelled = () => false) {
|
|
441
|
-
for await (const part of body) for (let offset = 0; offset < part.length; offset += MAX_CHUNK_PAYLOAD_BYTES) {
|
|
442
|
-
if (isCancelled()) return;
|
|
443
|
-
if (channel.bufferedAmount > LIMITS.sendHighWaterMark) await channel.drained();
|
|
444
|
-
const payload = part.subarray(offset, offset + MAX_CHUNK_PAYLOAD_BYTES);
|
|
445
|
-
channel.send(encodeChunk({
|
|
446
|
-
kind,
|
|
447
|
-
id,
|
|
448
|
-
end: false,
|
|
449
|
-
payload
|
|
450
|
-
}));
|
|
451
|
-
}
|
|
452
|
-
if (isCancelled()) return;
|
|
453
|
-
channel.send(encodeChunk({
|
|
454
|
-
kind,
|
|
455
|
-
id,
|
|
456
|
-
end: true,
|
|
457
|
-
payload: /* @__PURE__ */ new Uint8Array(0)
|
|
458
|
-
}));
|
|
459
|
-
}
|
|
460
|
-
//#endregion
|
|
461
|
-
//#region ../core/src/canonicalJson.ts
|
|
462
|
-
/** True when `s` holds a lone UTF-16 surrogate, which I-JSON (and so JCS) does not allow. */
|
|
463
|
-
function hasLoneSurrogate(s) {
|
|
464
|
-
for (let i = 0; i < s.length; i++) {
|
|
465
|
-
const c = s.charCodeAt(i);
|
|
466
|
-
if (c >= 55296 && c <= 56319) {
|
|
467
|
-
const next = s.charCodeAt(i + 1);
|
|
468
|
-
if (next >= 56320 && next <= 57343) {
|
|
469
|
-
i++;
|
|
470
|
-
continue;
|
|
471
|
-
}
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
if (c >= 56320 && c <= 57343) return true;
|
|
475
|
-
}
|
|
476
|
-
return false;
|
|
477
|
-
}
|
|
478
|
-
function write(value, depth) {
|
|
479
|
-
if (depth > 64) throw new Error("JSON nested deeper than 64 levels");
|
|
480
|
-
if (value === null) return "null";
|
|
481
|
-
if (typeof value === "boolean") return value ? "true" : "false";
|
|
482
|
-
if (typeof value === "number") {
|
|
483
|
-
if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
|
|
484
|
-
return JSON.stringify(value);
|
|
485
|
-
}
|
|
486
|
-
if (typeof value === "string") {
|
|
487
|
-
if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
|
|
488
|
-
return JSON.stringify(value);
|
|
489
|
-
}
|
|
490
|
-
if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
|
|
491
|
-
if (typeof value === "object") {
|
|
492
|
-
const record = value;
|
|
493
|
-
return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
|
|
494
|
-
if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
|
|
495
|
-
return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
|
|
496
|
-
}).join(",")}}`;
|
|
497
|
-
}
|
|
498
|
-
throw new Error(`Not a JSON value: ${typeof value}`);
|
|
499
|
-
}
|
|
500
|
-
/** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
|
|
501
|
-
function canonicalJson(value) {
|
|
502
|
-
return write(value, 0);
|
|
503
|
-
}
|
|
504
|
-
/** The canonical UTF-8 bytes of a JSON value. */
|
|
505
|
-
function canonicalJsonBytes(value) {
|
|
506
|
-
return utf8Encode(canonicalJson(value));
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* Reads bytes that must be the canonical form of a JSON value: `not-json` when they are not UTF-8 JSON, `not-canonical`
|
|
510
|
-
* when writing the value again gives other bytes.
|
|
511
|
-
*/
|
|
512
|
-
function readCanonicalJson(bytes) {
|
|
513
|
-
let value;
|
|
514
|
-
try {
|
|
515
|
-
value = JSON.parse(utf8Decode(bytes));
|
|
516
|
-
} catch (error) {
|
|
517
|
-
return {
|
|
518
|
-
ok: false,
|
|
519
|
-
reason: "not-json",
|
|
520
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
let again;
|
|
524
|
-
try {
|
|
525
|
-
again = canonicalJsonBytes(value);
|
|
526
|
-
} catch (error) {
|
|
527
|
-
return {
|
|
528
|
-
ok: false,
|
|
529
|
-
reason: "not-canonical",
|
|
530
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
if (!bytesEqual(again, bytes)) return {
|
|
534
|
-
ok: false,
|
|
535
|
-
reason: "not-canonical"
|
|
536
|
-
};
|
|
537
|
-
return {
|
|
538
|
-
ok: true,
|
|
539
|
-
value
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
//#endregion
|
|
543
|
-
//#region ../core/src/appStatements.ts
|
|
544
|
-
/** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
|
|
545
|
-
var APP_PREFIXES = {
|
|
546
|
-
app: "ghostly-app/1",
|
|
547
|
-
store: "ghostly-store/1",
|
|
548
|
-
revoke: "ghostly-revoke/1"
|
|
549
|
-
};
|
|
550
|
-
var APP_STATEMENT_LIMITS = {
|
|
551
|
-
/** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
|
|
552
|
-
signatureBytes: 1024,
|
|
553
|
-
/** Digests named by one revocation. */
|
|
554
|
-
revokeDigests: 64,
|
|
555
|
-
/** A revocation's reason, in characters. */
|
|
556
|
-
reason: 200,
|
|
557
|
-
/** Revocations in one `ghostly-revoke.json`. */
|
|
558
|
-
revocations: 4096
|
|
559
|
-
};
|
|
560
|
-
/** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
|
|
561
|
-
function isAppKey(value) {
|
|
562
|
-
if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
|
|
563
|
-
const bytes = fromZ32(value);
|
|
564
|
-
return bytes.length === 32 && toZ32(bytes) === value;
|
|
565
|
-
}
|
|
566
|
-
/** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
|
|
567
|
-
function isAppHash(value) {
|
|
568
|
-
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
|
|
569
|
-
return toBase64Url(fromBase64Url(value)) === value;
|
|
570
|
-
}
|
|
571
|
-
/** An app reference, `<publisher key>/<name>`. */
|
|
572
|
-
var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
573
|
-
function isAppRef(value) {
|
|
574
|
-
if (typeof value !== "string") return false;
|
|
575
|
-
return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
|
|
576
|
-
}
|
|
577
|
-
function appRef(publisher, name) {
|
|
578
|
-
return `${publisher}/${name}`;
|
|
579
|
-
}
|
|
580
|
-
/** The publisher key of a reference. */
|
|
581
|
-
function appRefPublisher(ref) {
|
|
582
|
-
return ref.slice(0, 52);
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
|
|
586
|
-
*/
|
|
587
|
-
function appFingerprint(key) {
|
|
588
|
-
return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
|
|
589
|
-
}
|
|
590
|
-
/** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
|
|
591
|
-
function appSignedBytes(prefix, canonical) {
|
|
592
|
-
const p = utf8Encode(prefix);
|
|
593
|
-
const out = new Uint8Array(p.length + 1 + 32);
|
|
594
|
-
out.set(p, 0);
|
|
595
|
-
out.set(sha256(canonical), p.length + 1);
|
|
596
|
-
return out;
|
|
597
|
-
}
|
|
598
|
-
/** Signs an object under a prefix: its canonical bytes and the signature statement. */
|
|
599
|
-
async function signAppObject(prefix, value, signer) {
|
|
600
|
-
const bytes = canonicalJsonBytes(value);
|
|
601
|
-
const sig = await signer.sign(appSignedBytes(prefix, bytes));
|
|
602
|
-
return {
|
|
603
|
-
bytes,
|
|
604
|
-
signature: {
|
|
605
|
-
alg: "ed25519",
|
|
606
|
-
key: toZ32(signer.publicKey),
|
|
607
|
-
sig: toBase64Url(sig)
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
}
|
|
611
|
-
/**
|
|
612
|
-
* Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
|
|
613
|
-
* so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
|
|
614
|
-
*/
|
|
615
|
-
function strictVerify(signature, message, publicKey) {
|
|
616
|
-
try {
|
|
617
|
-
return ed25519.verify(signature, message, publicKey, { zip215: false });
|
|
618
|
-
} catch {
|
|
619
|
-
return false;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
var exactKeys = (value, keys, optional = []) => {
|
|
623
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
624
|
-
const have = Object.keys(value);
|
|
625
|
-
return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
|
|
626
|
-
};
|
|
627
|
-
/** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
|
|
628
|
-
function isAppSignature(value) {
|
|
629
|
-
if (!exactKeys(value, [
|
|
630
|
-
"alg",
|
|
631
|
-
"key",
|
|
632
|
-
"sig"
|
|
633
|
-
])) return false;
|
|
634
|
-
if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
|
|
635
|
-
return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
|
|
636
|
-
}
|
|
637
|
-
/** Reads a signature statement from its bytes, which must be canonical. */
|
|
638
|
-
function readAppSignature(bytes) {
|
|
639
|
-
if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
|
|
640
|
-
ok: false,
|
|
641
|
-
reason: "bad-signature-statement",
|
|
642
|
-
detail: "Too large"
|
|
643
|
-
};
|
|
644
|
-
const read = readCanonicalJson(bytes);
|
|
645
|
-
if (!read.ok) return {
|
|
646
|
-
ok: false,
|
|
647
|
-
reason: "bad-signature-statement",
|
|
648
|
-
detail: read.reason
|
|
649
|
-
};
|
|
650
|
-
if (!isAppSignature(read.value)) return {
|
|
651
|
-
ok: false,
|
|
652
|
-
reason: "bad-signature-statement",
|
|
653
|
-
detail: "Not {alg, key, sig}"
|
|
654
|
-
};
|
|
655
|
-
return {
|
|
656
|
-
ok: true,
|
|
657
|
-
signature: read.value
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
/**
|
|
661
|
-
* Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
|
|
662
|
-
* statement names another key, `bad-signature` when it does not verify.
|
|
663
|
-
*/
|
|
664
|
-
function verifyAppSignature(prefix, canonical, signature, key) {
|
|
665
|
-
if (!isAppSignature(signature)) return {
|
|
666
|
-
ok: false,
|
|
667
|
-
reason: "bad-signature-statement"
|
|
668
|
-
};
|
|
669
|
-
if (signature.key !== key) return {
|
|
670
|
-
ok: false,
|
|
671
|
-
reason: "signature-key"
|
|
672
|
-
};
|
|
673
|
-
return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
|
|
674
|
-
ok: false,
|
|
675
|
-
reason: "bad-signature"
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
|
|
679
|
-
var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
|
|
680
|
-
/** Checks a revocation statement's shape; the reason it is refused, or null. */
|
|
681
|
-
function checkAppRevokeStatement(value) {
|
|
682
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
|
|
683
|
-
const v = value;
|
|
684
|
-
const byDigests = "digests" in v;
|
|
685
|
-
if (!exactKeys(v, [
|
|
686
|
-
"ghostlyRevoke",
|
|
687
|
-
"app",
|
|
688
|
-
byDigests ? "digests" : "upTo"
|
|
689
|
-
], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
|
|
690
|
-
if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
|
|
691
|
-
if (!isAppRef(v.app)) return "app is not a reference";
|
|
692
|
-
if (byDigests) {
|
|
693
|
-
const d = v.digests;
|
|
694
|
-
if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
|
|
695
|
-
if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
|
|
696
|
-
} else if (!isSequence(v.upTo)) return "upTo is not a sequence";
|
|
697
|
-
if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
/** Signs a revocation with the publisher's key. */
|
|
701
|
-
async function signAppRevocation(statement, signer) {
|
|
702
|
-
const problem = checkAppRevokeStatement(statement);
|
|
703
|
-
if (problem) throw new Error(`Not a revocation: ${problem}`);
|
|
704
|
-
if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
|
|
705
|
-
const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
|
|
706
|
-
return {
|
|
707
|
-
statement,
|
|
708
|
-
signature
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
/** What a checked revocation is kept by: the SHA-256 of its canonical bytes (statement and signature), base64url. */
|
|
712
|
-
function appRevocationId(revocation) {
|
|
713
|
-
return toBase64Url(sha256(canonicalJsonBytes(revocation)));
|
|
714
|
-
}
|
|
715
|
-
/**
|
|
716
|
-
* Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
|
|
717
|
-
* the signature under `ghostly-revoke/1` by the publisher its `app` names. One that `checked` holds verified before, so
|
|
718
|
-
* its signature is not checked again; one that verifies now is added to it.
|
|
719
|
-
*/
|
|
720
|
-
function readAppRevocation(value, checked) {
|
|
721
|
-
const read = readAppRevocationShape(value);
|
|
722
|
-
if (!read.ok) return read;
|
|
723
|
-
const verified = verifyAppRevocation(read.revocation, checked);
|
|
724
|
-
return verified.ok ? read : verified;
|
|
725
|
-
}
|
|
726
|
-
/** The first half of `readAppRevocation`: the shape of a signed revocation, with no signature checked. */
|
|
727
|
-
function readAppRevocationShape(value) {
|
|
728
|
-
if (!exactKeys(value, ["statement", "signature"])) return {
|
|
729
|
-
ok: false,
|
|
730
|
-
reason: "bad-revocation",
|
|
731
|
-
detail: "Keys are not statement and signature"
|
|
732
|
-
};
|
|
733
|
-
const problem = checkAppRevokeStatement(value.statement);
|
|
734
|
-
if (problem) return {
|
|
735
|
-
ok: false,
|
|
736
|
-
reason: "bad-revocation",
|
|
737
|
-
detail: problem
|
|
738
|
-
};
|
|
739
|
-
if (!isAppSignature(value.signature)) return {
|
|
740
|
-
ok: false,
|
|
741
|
-
reason: "bad-signature-statement"
|
|
742
|
-
};
|
|
743
|
-
return {
|
|
744
|
-
ok: true,
|
|
745
|
-
revocation: {
|
|
746
|
-
statement: value.statement,
|
|
747
|
-
signature: value.signature
|
|
748
|
-
}
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
/** The second half: the signature of a revocation of the right shape, unless `checked` holds it. */
|
|
752
|
-
function verifyAppRevocation(revocation, checked) {
|
|
753
|
-
const id = checked && appRevocationId(revocation);
|
|
754
|
-
if (id !== void 0 && checked.has(id)) return { ok: true };
|
|
755
|
-
const verified = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(revocation.statement), revocation.signature, appRefPublisher(revocation.statement.app));
|
|
756
|
-
if (verified.ok && id !== void 0) checked.add(id);
|
|
757
|
-
return verified;
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
|
|
761
|
-
* file, as a store index's `revoked` does.
|
|
762
|
-
*/
|
|
763
|
-
function readAppRevocations(bytes) {
|
|
764
|
-
const read = readCanonicalJson(bytes);
|
|
765
|
-
if (!read.ok) return read;
|
|
766
|
-
if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
|
|
767
|
-
ok: false,
|
|
768
|
-
reason: "bad-revocation",
|
|
769
|
-
detail: "Not a list of at most 4096 revocations"
|
|
770
|
-
};
|
|
771
|
-
const revocations = [];
|
|
772
|
-
for (const entry of read.value) {
|
|
773
|
-
const one = readAppRevocation(entry);
|
|
774
|
-
if (!one.ok) return one;
|
|
775
|
-
revocations.push(one.revocation);
|
|
776
|
-
}
|
|
777
|
-
return {
|
|
778
|
-
ok: true,
|
|
779
|
-
revocations
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
//#endregion
|
|
783
|
-
//#region ../core/src/messageTime.ts
|
|
784
|
-
/**
|
|
785
|
-
* How far ahead of this device's clock a number a peer made from its clock may be (a pin's number, a status card's
|
|
786
|
-
* times): clocks drift, but a time past this is the peer's choice, not its clock.
|
|
787
|
-
*/
|
|
788
|
-
var MESSAGE_CLOCK_SKEW_MS = 3e5;
|
|
789
|
-
/**
|
|
790
|
-
* The time a line of a group's history is kept under (a membership change, a rename): what its commit says, but at
|
|
791
|
-
* most `MESSAGE_CLOCK_SKEW_MS` past this clock, and now when it is not a positive number. Messages do not use it:
|
|
792
|
-
* they are placed where they arrive (below).
|
|
793
|
-
*/
|
|
794
|
-
function receivedTimestamp(ts, now = Date.now()) {
|
|
795
|
-
if (!Number.isFinite(ts) || ts <= 0) return now;
|
|
796
|
-
return Math.min(ts, now + MESSAGE_CLOCK_SKEW_MS);
|
|
797
|
-
}
|
|
798
|
-
/** The latest moment a date can hold. */
|
|
799
|
-
var MAX_DATE_MS = 864e13;
|
|
800
|
-
/** The time a sender says, as it is kept: a positive time a date can hold, or nothing. */
|
|
801
|
-
function claimedTime(ts) {
|
|
802
|
-
return typeof ts === "number" && Number.isFinite(ts) && ts > 0 && ts <= MAX_DATE_MS ? ts : void 0;
|
|
803
|
-
}
|
|
804
|
-
/**
|
|
805
|
-
* The place of a row stored now: this clock, and past the last place given in its chat, so rows that come in the
|
|
806
|
-
* same millisecond (a hold pickup, a group's catch-up) keep the order they came in.
|
|
807
|
-
*/
|
|
808
|
-
function arrivalKey(now, last = 0) {
|
|
809
|
-
return Math.max(now, last + 1);
|
|
810
|
-
}
|
|
811
|
-
/** The time a message shows: what its sender said, never later than when it came. A row of mine shows its own time. */
|
|
812
|
-
function shownTime(message) {
|
|
813
|
-
return message.sentAt === void 0 ? message.timestamp : Math.min(message.sentAt, message.timestamp);
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* When something a peer dated happened (an edit), for this device: what the peer says, now at the latest, and now
|
|
817
|
-
* when it says nothing a date can hold.
|
|
818
|
-
*/
|
|
819
|
-
function heardTime(ts, now = Date.now()) {
|
|
820
|
-
return Math.min(claimedTime(ts) ?? now, now);
|
|
821
|
-
}
|
|
822
|
-
//#endregion
|
|
823
|
-
//#region ../core/src/statusCards.ts
|
|
824
|
-
var STATUS_CARD_LIMITS = {
|
|
825
|
-
/** A card's JSON, as sent (UTF-8 bytes): with the fallback text it stays well inside every message bound. */
|
|
826
|
-
bytes: 8192,
|
|
827
|
-
/** The sender's id for the card: stable across its updates. */
|
|
828
|
-
id: 64,
|
|
829
|
-
title: 120,
|
|
830
|
-
/** The current step, a list item, a run's summary. */
|
|
831
|
-
line: 200,
|
|
832
|
-
branch: 120,
|
|
833
|
-
schedule: 80,
|
|
834
|
-
cron: 64,
|
|
835
|
-
linkLabel: 40,
|
|
836
|
-
url: 512,
|
|
837
|
-
items: 20,
|
|
838
|
-
links: 4,
|
|
839
|
-
runs: 10,
|
|
840
|
-
/** Any count on a card (a PR's number, lines, files; steps). */
|
|
841
|
-
count: 1e9,
|
|
842
|
-
/** How far ahead a routine's next run may be. */
|
|
843
|
-
nextRunMs: 316224e5,
|
|
844
|
-
/** Edits a message with a card takes (a text takes `MAX_EDITS_PER_MESSAGE`): a bot updates a long task often. */
|
|
845
|
-
edits: 5e3,
|
|
846
|
-
/** Labels on a task, and a label's length in characters. */
|
|
847
|
-
tags: 3,
|
|
848
|
-
tag: 24,
|
|
849
|
-
/** Buttons on a message (WISP 406 · Message Buttons). */
|
|
850
|
-
buttons: 6,
|
|
851
|
-
/** A button's id: what a press names. */
|
|
852
|
-
buttonId: 32,
|
|
853
|
-
/** A button's label, in characters. */
|
|
854
|
-
buttonLabel: 40,
|
|
855
|
-
/** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
|
|
856
|
-
appTitle: 40,
|
|
857
|
-
appVersion: 32,
|
|
858
|
-
/** A usage card's label ("Claude") and account, in characters (WISP 405 § Usage). */
|
|
859
|
-
usageLabel: 24,
|
|
860
|
-
/** A usage window's name ("5 h", "week"), in characters. */
|
|
861
|
-
usageWindow: 16,
|
|
862
|
-
/** Windows beside a usage card's own, shown in its details. */
|
|
863
|
-
usageWindows: 3
|
|
864
|
-
};
|
|
865
|
-
/** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
|
|
866
|
-
var BUTTON_STYLES = [
|
|
867
|
-
"primary",
|
|
868
|
-
"neutral",
|
|
869
|
-
"danger"
|
|
870
|
-
];
|
|
871
|
-
var TASK_STATUSES = [
|
|
872
|
-
"queued",
|
|
873
|
-
"running",
|
|
874
|
-
"blocked",
|
|
875
|
-
"done",
|
|
876
|
-
"failed",
|
|
877
|
-
"cancelled"
|
|
878
|
-
];
|
|
879
|
-
/** A task that is still going: shown first, and counted on the chat's Tasks button. */
|
|
880
|
-
var ACTIVE_TASK_STATUSES = [
|
|
881
|
-
"queued",
|
|
882
|
-
"running",
|
|
883
|
-
"blocked"
|
|
884
|
-
];
|
|
885
|
-
var ITEM_STATES = [
|
|
886
|
-
"pending",
|
|
887
|
-
"running",
|
|
888
|
-
"done",
|
|
889
|
-
"failed",
|
|
890
|
-
"skipped"
|
|
891
|
-
];
|
|
892
|
-
var ROUTINE_STATES = ["active", "paused"];
|
|
893
|
-
var RUN_RESULTS = [
|
|
894
|
-
"ok",
|
|
895
|
-
"failed",
|
|
896
|
-
"skipped"
|
|
897
|
-
];
|
|
898
|
-
/**
|
|
899
|
-
* Where a task's pull request stands: `draft` (not ready for review), `open` (ready for review), `merged`, `closed`
|
|
900
|
-
* (closed without a merge). A reader that does not know the field shows the pull request as before.
|
|
901
|
-
*/
|
|
902
|
-
var PR_STATES = [
|
|
903
|
-
"draft",
|
|
904
|
-
"open",
|
|
905
|
-
"merged",
|
|
906
|
-
"closed"
|
|
907
|
-
];
|
|
908
|
-
/** How a pull request's checks stand. */
|
|
909
|
-
var PR_CHECKS = [
|
|
910
|
-
"passing",
|
|
911
|
-
"failing",
|
|
912
|
-
"pending"
|
|
913
|
-
];
|
|
914
|
-
var ID = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,63}$/;
|
|
915
|
-
/** A button's id: 1 to 32 of `A-Z a-z 0-9 _ . : -`, not starting with `-`. */
|
|
916
|
-
var BUTTON_ID = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,31}$/;
|
|
917
|
-
var CRON = /^[0-9A-Za-z*,/?#\- ]{1,64}$/;
|
|
918
|
-
/**
|
|
919
|
-
* Whether a typed answer is a button's label or id (WISP 406 · Message Buttons): ignoring case and spaces at the ends.
|
|
920
|
-
* Not the device's locale's case: the presser's and the author's apps must agree ("I" is not "ı" anywhere).
|
|
921
|
-
*/
|
|
922
|
-
var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
923
|
-
/**
|
|
924
|
-
* Two buttons a typed answer could not tell apart, or none: a label that reads as an earlier label (`label`), or a
|
|
925
|
-
* label that reads as another button's id (`id`), both as `sameButtonText` compares them. The labels as a reader keeps
|
|
926
|
-
* them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
|
|
927
|
-
*/
|
|
928
|
-
function buttonLabelClash(buttons) {
|
|
929
|
-
const labels = buttons.map((button) => isObject(button) ? cardLine(button.label, Infinity) : void 0);
|
|
930
|
-
const ids = buttons.map((button) => isObject(button) && typeof button.id === "string" ? button.id : void 0);
|
|
931
|
-
for (const [at, label] of labels.entries()) {
|
|
932
|
-
if (!label) continue;
|
|
933
|
-
const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
|
|
934
|
-
if (twin !== -1) return {
|
|
935
|
-
at,
|
|
936
|
-
with: twin,
|
|
937
|
-
kind: "label"
|
|
938
|
-
};
|
|
939
|
-
const named = ids.findIndex((id, i) => i !== at && id !== void 0 && sameButtonText(id, label));
|
|
940
|
-
if (named !== -1) return {
|
|
941
|
-
at,
|
|
942
|
-
with: named,
|
|
943
|
-
kind: "id"
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
/** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
|
|
948
|
-
function appCardId(ref) {
|
|
949
|
-
return `${ref.slice(53)}.${ref.slice(0, 16)}`;
|
|
950
|
-
}
|
|
951
|
-
/** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
|
|
952
|
-
var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
953
|
-
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
954
|
-
/** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
|
|
955
|
-
function cardLine(value, max) {
|
|
956
|
-
if (typeof value !== "string") return void 0;
|
|
957
|
-
return sanitizeDisplayText(value.replace(/\s+/gu, " "), max);
|
|
958
|
-
}
|
|
959
|
-
/**
|
|
960
|
-
* An https link a card may show: no credentials, a host, no spaces or control characters, at most
|
|
961
|
-
* `STATUS_CARD_LIMITS.url` characters. Returned as the parser writes it (a non-ASCII host in punycode).
|
|
962
|
-
*/
|
|
963
|
-
function cardUrl(value) {
|
|
964
|
-
if (typeof value !== "string" || !value || value.length > STATUS_CARD_LIMITS.url || /[\s\p{Cc}\p{Cf}]/u.test(value)) return void 0;
|
|
965
|
-
let url;
|
|
966
|
-
try {
|
|
967
|
-
url = new URL(value);
|
|
968
|
-
} catch {
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return void 0;
|
|
972
|
-
return url.href.length <= STATUS_CARD_LIMITS.url ? url.href : void 0;
|
|
973
|
-
}
|
|
974
|
-
var count = (value, min = 0) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= STATUS_CARD_LIMITS.count ? value : void 0;
|
|
975
|
-
/** A time on a card as a reader keeps it: a positive whole number, never later than `latest`. */
|
|
976
|
-
var time = (value, latest) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? Math.min(value, latest) : void 0;
|
|
977
|
-
var oneOf = (values, value) => typeof value === "string" && values.includes(value) ? value : void 0;
|
|
978
|
-
function readLinks(raw) {
|
|
979
|
-
if (!Array.isArray(raw)) return [];
|
|
980
|
-
const links = [];
|
|
981
|
-
for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
|
|
982
|
-
if (!isObject(entry)) continue;
|
|
983
|
-
const url = cardUrl(entry.url);
|
|
984
|
-
if (!url) continue;
|
|
985
|
-
const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
|
|
986
|
-
links.push({
|
|
987
|
-
url,
|
|
988
|
-
...label && { label }
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
return links;
|
|
992
|
-
}
|
|
993
|
-
/** A task's labels as a reader keeps them: the first `STATUS_CARD_LIMITS.tags` that hold, each one clean line, none twice. */
|
|
994
|
-
function readTags(raw) {
|
|
995
|
-
if (!Array.isArray(raw)) return [];
|
|
996
|
-
const tags = [];
|
|
997
|
-
for (const entry of raw) {
|
|
998
|
-
const tag = cardLine(entry, STATUS_CARD_LIMITS.tag);
|
|
999
|
-
if (tag && !tags.includes(tag)) tags.push(tag);
|
|
1000
|
-
if (tags.length === STATUS_CARD_LIMITS.tags) break;
|
|
1001
|
-
}
|
|
1002
|
-
return tags;
|
|
1003
|
-
}
|
|
1004
|
-
function readRun(raw, now) {
|
|
1005
|
-
if (!isObject(raw)) return void 0;
|
|
1006
|
-
const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
|
|
1007
|
-
if (!at || !result) return void 0;
|
|
1008
|
-
const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
|
|
1009
|
-
return {
|
|
1010
|
-
at,
|
|
1011
|
-
result,
|
|
1012
|
-
...summary && { summary }
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
/** A percent as a reader keeps it: a finite number, rounded and clamped into 0 to 100. */
|
|
1016
|
-
var percent = (value) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : void 0;
|
|
1017
|
-
/** A time ahead of the reader (a reset, a next run) as it keeps it: whole, positive, at most a year ahead. */
|
|
1018
|
-
var ahead = (value, now) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + STATUS_CARD_LIMITS.nextRunMs ? value : void 0;
|
|
1019
|
-
/** A usage card as a reader keeps it (WISP 405 § Usage): dropped without a percent left or a used of limit. */
|
|
1020
|
-
function readUsage(raw, id, now) {
|
|
1021
|
-
const left = percent(raw.left);
|
|
1022
|
-
let used = count(raw.used), limit = count(raw.limit, 1);
|
|
1023
|
-
if (used === void 0 || limit === void 0 || used > limit) used = limit = void 0;
|
|
1024
|
-
if (left === void 0 && limit === void 0) return void 0;
|
|
1025
|
-
const label = cardLine(raw.label, STATUS_CARD_LIMITS.usageLabel), account = cardLine(raw.account, STATUS_CARD_LIMITS.usageLabel);
|
|
1026
|
-
const window = cardLine(raw.window, STATUS_CARD_LIMITS.usageWindow);
|
|
1027
|
-
const resetsAt = ahead(raw.resetsAt, now), updatedAt = time(raw.updatedAt, now + MESSAGE_CLOCK_SKEW_MS);
|
|
1028
|
-
const windows = [];
|
|
1029
|
-
if (Array.isArray(raw.windows)) for (const entry of raw.windows.slice(0, STATUS_CARD_LIMITS.usageWindows)) {
|
|
1030
|
-
if (!isObject(entry)) continue;
|
|
1031
|
-
const name = cardLine(entry.window, STATUS_CARD_LIMITS.usageWindow), share = percent(entry.left);
|
|
1032
|
-
if (!name || share === void 0) continue;
|
|
1033
|
-
const at = ahead(entry.resetsAt, now);
|
|
1034
|
-
windows.push({
|
|
1035
|
-
window: name,
|
|
1036
|
-
left: share,
|
|
1037
|
-
...at && { resetsAt: at }
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
return {
|
|
1041
|
-
kind: "usage",
|
|
1042
|
-
id,
|
|
1043
|
-
...left !== void 0 && { left },
|
|
1044
|
-
...used !== void 0 && limit !== void 0 && {
|
|
1045
|
-
used,
|
|
1046
|
-
limit
|
|
1047
|
-
},
|
|
1048
|
-
...label && { label },
|
|
1049
|
-
...account && { account },
|
|
1050
|
-
...window && { window },
|
|
1051
|
-
...resetsAt && { resetsAt },
|
|
1052
|
-
...updatedAt && { updatedAt },
|
|
1053
|
-
...windows.length && { windows }
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
/** How much of a usage card's quota is left, 0 to 100: its own percent, else worked out from used of limit. */
|
|
1057
|
-
function usageLeft(card) {
|
|
1058
|
-
if (card.left !== void 0) return card.left;
|
|
1059
|
-
return card.used !== void 0 && card.limit ? Math.round((card.limit - card.used) / card.limit * 100) : 0;
|
|
1060
|
-
}
|
|
1061
|
-
/** The bytes a card takes on the wire: what `STATUS_CARD_LIMITS.bytes` bounds. */
|
|
1062
|
-
function statusCardBytes(card) {
|
|
1063
|
-
try {
|
|
1064
|
-
return utf8Encode(JSON.stringify(card) ?? "").length;
|
|
1065
|
-
} catch {
|
|
1066
|
-
return Infinity;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
/**
|
|
1070
|
-
* A card as a reader takes it from the wire (`sc`), or undefined when it cannot stand: not an object, past
|
|
1071
|
-
* `STATUS_CARD_LIMITS.bytes`, a kind this app does not know, no valid id, title or name, status or state. Fields that do
|
|
1072
|
-
* not hold are left out and lists are cut to their bound; times are kept no later than the reader's clock allows (a
|
|
1073
|
-
* routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
|
|
1074
|
-
*/
|
|
1075
|
-
function readStatusCard(raw, now = Date.now()) {
|
|
1076
|
-
if (!isObject(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
|
|
1077
|
-
const id = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : void 0;
|
|
1078
|
-
if (!id) return void 0;
|
|
1079
|
-
if (raw.kind === "app") {
|
|
1080
|
-
if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
|
|
1081
|
-
const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
|
|
1082
|
-
if (!title) return void 0;
|
|
1083
|
-
const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
|
|
1084
|
-
return {
|
|
1085
|
-
kind: "app",
|
|
1086
|
-
id,
|
|
1087
|
-
ref: raw.ref,
|
|
1088
|
-
digest: raw.digest,
|
|
1089
|
-
sequence: raw.sequence,
|
|
1090
|
-
title,
|
|
1091
|
-
...version && { version },
|
|
1092
|
-
...url && { url },
|
|
1093
|
-
...raw.opened === true && { opened: true }
|
|
1094
|
-
};
|
|
1095
|
-
}
|
|
1096
|
-
const latest = now + MESSAGE_CLOCK_SKEW_MS;
|
|
1097
|
-
if (raw.kind === "usage") return readUsage(raw, id, now);
|
|
1098
|
-
const links = readLinks(raw.links);
|
|
1099
|
-
if (raw.kind === "task") {
|
|
1100
|
-
const title = cardLine(raw.title, STATUS_CARD_LIMITS.title), status = oneOf(TASK_STATUSES, raw.status);
|
|
1101
|
-
if (!title || !status) return void 0;
|
|
1102
|
-
const progress = typeof raw.progress === "number" && Number.isFinite(raw.progress) ? Math.round(Math.min(100, Math.max(0, raw.progress))) : void 0;
|
|
1103
|
-
let done = count(raw.done), total = count(raw.total, 1);
|
|
1104
|
-
if (done === void 0 || total === void 0 || done > total) done = total = void 0;
|
|
1105
|
-
const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
|
|
1106
|
-
let pr;
|
|
1107
|
-
if (isObject(raw.pr)) {
|
|
1108
|
-
const url = cardUrl(raw.pr.url);
|
|
1109
|
-
if (url) {
|
|
1110
|
-
const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
|
|
1111
|
-
const state = oneOf(PR_STATES, raw.pr.state), checks = oneOf(PR_CHECKS, raw.pr.checks);
|
|
1112
|
-
pr = {
|
|
1113
|
-
url,
|
|
1114
|
-
...number !== void 0 && { number },
|
|
1115
|
-
...additions !== void 0 && { additions },
|
|
1116
|
-
...deletions !== void 0 && { deletions },
|
|
1117
|
-
...files !== void 0 && { files },
|
|
1118
|
-
...state && { state },
|
|
1119
|
-
...checks && { checks }
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
const items = [];
|
|
1124
|
-
if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
|
|
1125
|
-
if (!isObject(entry)) continue;
|
|
1126
|
-
const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
|
|
1127
|
-
if (text) items.push({
|
|
1128
|
-
text,
|
|
1129
|
-
state: oneOf(ITEM_STATES, entry.state) ?? "pending"
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
const startedAt = time(raw.startedAt, latest), updatedAt = time(raw.updatedAt, latest);
|
|
1133
|
-
const tags = readTags(raw.tags);
|
|
1134
|
-
const parent = typeof raw.parent === "string" && ID.test(raw.parent) && raw.parent !== id ? raw.parent : void 0;
|
|
1135
|
-
return {
|
|
1136
|
-
kind: "task",
|
|
1137
|
-
id,
|
|
1138
|
-
title,
|
|
1139
|
-
status,
|
|
1140
|
-
...progress !== void 0 && { progress },
|
|
1141
|
-
...done !== void 0 && total !== void 0 && {
|
|
1142
|
-
done,
|
|
1143
|
-
total
|
|
1144
|
-
},
|
|
1145
|
-
...step && { step },
|
|
1146
|
-
...pr && { pr },
|
|
1147
|
-
...branch && { branch },
|
|
1148
|
-
...startedAt && { startedAt },
|
|
1149
|
-
...updatedAt && { updatedAt },
|
|
1150
|
-
...items.length && { items },
|
|
1151
|
-
...links.length && { links },
|
|
1152
|
-
...tags.length && { tags },
|
|
1153
|
-
...parent && { parent }
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
if (raw.kind === "routine") {
|
|
1157
|
-
const name = cardLine(raw.name, STATUS_CARD_LIMITS.title), schedule = cardLine(raw.schedule, STATUS_CARD_LIMITS.schedule), state = oneOf(ROUTINE_STATES, raw.state);
|
|
1158
|
-
if (!name || !schedule || !state) return void 0;
|
|
1159
|
-
const cron = typeof raw.cron === "string" && CRON.test(raw.cron.trim()) ? raw.cron.trim() : void 0;
|
|
1160
|
-
const lastRun = readRun(raw.lastRun, now);
|
|
1161
|
-
const nextRunAt = typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs ? raw.nextRunAt : void 0;
|
|
1162
|
-
const runs = [];
|
|
1163
|
-
if (Array.isArray(raw.runs)) for (const entry of raw.runs.slice(0, STATUS_CARD_LIMITS.runs)) {
|
|
1164
|
-
const run = readRun(entry, now);
|
|
1165
|
-
if (run) runs.push(run);
|
|
1166
|
-
}
|
|
1167
|
-
return {
|
|
1168
|
-
kind: "routine",
|
|
1169
|
-
id,
|
|
1170
|
-
name,
|
|
1171
|
-
schedule,
|
|
1172
|
-
state,
|
|
1173
|
-
...cron && { cron },
|
|
1174
|
-
...lastRun && { lastRun },
|
|
1175
|
-
...nextRunAt && { nextRunAt },
|
|
1176
|
-
...runs.length && { runs },
|
|
1177
|
-
...links.length && { links }
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
1180
|
-
if (raw.kind === "buttons") {
|
|
1181
|
-
if (!Array.isArray(raw.buttons)) return void 0;
|
|
1182
|
-
const buttons = [], seen = /* @__PURE__ */ new Set();
|
|
1183
|
-
for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
|
|
1184
|
-
if (!isObject(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
|
|
1185
|
-
const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
|
|
1186
|
-
if (!label) continue;
|
|
1187
|
-
seen.add(entry.id);
|
|
1188
|
-
const style = oneOf(BUTTON_STYLES, entry.style);
|
|
1189
|
-
buttons.push({
|
|
1190
|
-
id: entry.id,
|
|
1191
|
-
label,
|
|
1192
|
-
...style && style !== "neutral" && { style },
|
|
1193
|
-
...entry.once === true && { once: true }
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1196
|
-
if (!buttons.length) return void 0;
|
|
1197
|
-
const chosen = typeof raw.chosen === "string" && seen.has(raw.chosen) ? raw.chosen : void 0;
|
|
1198
|
-
return {
|
|
1199
|
-
kind: "buttons",
|
|
1200
|
-
id,
|
|
1201
|
-
buttons,
|
|
1202
|
-
...chosen && { chosen },
|
|
1203
|
-
...raw.closed === true && { closed: true }
|
|
1204
|
-
};
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
/**
|
|
1208
|
-
* A card as a sender may send it, or why not: the reader's rule, but anything it would leave out or cut is refused
|
|
1209
|
-
* here instead (an unknown status, a link that is not https, a list past its bound, a line too long), so a bot learns
|
|
1210
|
-
* at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
|
|
1211
|
-
*/
|
|
1212
|
-
function checkStatusCard(raw, now = Date.now()) {
|
|
1213
|
-
if (!isObject(raw)) return { error: "A card is an object" };
|
|
1214
|
-
const bytes = statusCardBytes(raw);
|
|
1215
|
-
if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
|
|
1216
|
-
if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons" && raw.kind !== "app" && raw.kind !== "usage") return { error: "kind is task, routine, buttons, app or usage" };
|
|
1217
|
-
if (typeof raw.id !== "string" || !ID.test(raw.id)) return { error: `id is 1 to ${STATUS_CARD_LIMITS.id} of A-Z a-z 0-9 _ . : - and does not start with -` };
|
|
1218
|
-
const line = (field, value, max, required = false) => {
|
|
1219
|
-
if (value === void 0 && !required) return null;
|
|
1220
|
-
if (typeof value !== "string" || !cardLine(value, Infinity)) return `${field} is text`;
|
|
1221
|
-
if ([...cardLine(value, Infinity)].length > max) return `${field} is at most ${max} characters`;
|
|
1222
|
-
return null;
|
|
1223
|
-
};
|
|
1224
|
-
const url = (field, value) => cardUrl(value) ? null : `${field} must be an https link of at most ${STATUS_CARD_LIMITS.url} characters`;
|
|
1225
|
-
const whole = (field, value, min = 0) => value === void 0 || count(value, min) !== void 0 ? null : `${field} is a whole number from ${min}`;
|
|
1226
|
-
const errors = [];
|
|
1227
|
-
if (raw.links !== void 0) {
|
|
1228
|
-
if (!Array.isArray(raw.links) || raw.links.length > STATUS_CARD_LIMITS.links) errors.push(`links is a list of at most ${STATUS_CARD_LIMITS.links}`);
|
|
1229
|
-
else for (const [i, link] of raw.links.entries()) {
|
|
1230
|
-
if (!isObject(link)) {
|
|
1231
|
-
errors.push(`links[${i}] is an object`);
|
|
1232
|
-
continue;
|
|
1233
|
-
}
|
|
1234
|
-
errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
const share = (field, value) => value === void 0 || typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 ? null : `${field} is a number from 0 to 100`;
|
|
1238
|
-
const reset = (field, value) => value === void 0 || ahead(value, now) !== void 0 ? null : `${field} is a time in milliseconds, within a year`;
|
|
1239
|
-
if (raw.kind === "usage") {
|
|
1240
|
-
if (raw.links !== void 0) errors.push("a usage card takes no links");
|
|
1241
|
-
if (raw.left === void 0 && raw.used === void 0) errors.push("left (a percent) or used and limit");
|
|
1242
|
-
errors.push(share("left", raw.left));
|
|
1243
|
-
if (raw.used === void 0 !== (raw.limit === void 0)) errors.push("used and limit go together");
|
|
1244
|
-
else if (raw.used !== void 0) {
|
|
1245
|
-
errors.push(whole("used", raw.used), whole("limit", raw.limit, 1));
|
|
1246
|
-
if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.used > raw.limit) errors.push("used is at most limit");
|
|
1247
|
-
}
|
|
1248
|
-
errors.push(line("label", raw.label, STATUS_CARD_LIMITS.usageLabel), line("account", raw.account, STATUS_CARD_LIMITS.usageLabel), line("window", raw.window, STATUS_CARD_LIMITS.usageWindow), reset("resetsAt", raw.resetsAt));
|
|
1249
|
-
if (raw.updatedAt !== void 0 && !(typeof raw.updatedAt === "number" && Number.isSafeInteger(raw.updatedAt) && raw.updatedAt > 0 && raw.updatedAt <= now + 3e5)) errors.push("updatedAt is a time in milliseconds, not in the future");
|
|
1250
|
-
if (raw.windows !== void 0) {
|
|
1251
|
-
if (!Array.isArray(raw.windows) || raw.windows.length > STATUS_CARD_LIMITS.usageWindows) errors.push(`windows is a list of at most ${STATUS_CARD_LIMITS.usageWindows}`);
|
|
1252
|
-
else for (const [i, entry] of raw.windows.entries()) {
|
|
1253
|
-
if (!isObject(entry)) {
|
|
1254
|
-
errors.push(`windows[${i}] is an object`);
|
|
1255
|
-
continue;
|
|
1256
|
-
}
|
|
1257
|
-
errors.push(line(`windows[${i}].window`, entry.window, STATUS_CARD_LIMITS.usageWindow, true));
|
|
1258
|
-
errors.push(entry.left === void 0 ? `windows[${i}].left is a number from 0 to 100` : share(`windows[${i}].left`, entry.left), reset(`windows[${i}].resetsAt`, entry.resetsAt));
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
} else if (raw.kind === "app") {
|
|
1262
|
-
if (raw.links !== void 0) errors.push("an app card takes no links");
|
|
1263
|
-
if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
|
|
1264
|
-
else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
|
|
1265
|
-
if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
|
|
1266
|
-
if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
|
|
1267
|
-
errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
|
|
1268
|
-
if (raw.url !== void 0) errors.push(url("url", raw.url));
|
|
1269
|
-
if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
|
|
1270
|
-
} else if (raw.kind === "buttons") {
|
|
1271
|
-
if (raw.links !== void 0) errors.push("buttons take no links");
|
|
1272
|
-
if (!Array.isArray(raw.buttons) || !raw.buttons.length || raw.buttons.length > STATUS_CARD_LIMITS.buttons) errors.push(`buttons is a list of 1 to ${STATUS_CARD_LIMITS.buttons}`);
|
|
1273
|
-
else {
|
|
1274
|
-
const ids = /* @__PURE__ */ new Set();
|
|
1275
|
-
for (const [i, button] of raw.buttons.entries()) {
|
|
1276
|
-
if (!isObject(button)) {
|
|
1277
|
-
errors.push(`buttons[${i}] is an object`);
|
|
1278
|
-
continue;
|
|
1279
|
-
}
|
|
1280
|
-
if (typeof button.id !== "string" || !BUTTON_ID.test(button.id)) errors.push(`buttons[${i}].id is 1 to ${STATUS_CARD_LIMITS.buttonId} of A-Z a-z 0-9 _ . : - and does not start with -`);
|
|
1281
|
-
else if (ids.has(button.id)) errors.push(`buttons[${i}].id "${button.id}" is used twice`);
|
|
1282
|
-
else ids.add(button.id);
|
|
1283
|
-
errors.push(line(`buttons[${i}].label`, button.label, STATUS_CARD_LIMITS.buttonLabel, true));
|
|
1284
|
-
if (button.style !== void 0 && !oneOf(BUTTON_STYLES, button.style)) errors.push(`buttons[${i}].style is one of ${BUTTON_STYLES.join(", ")}`);
|
|
1285
|
-
if (button.once !== void 0 && button.once !== true) errors.push(`buttons[${i}].once is true or left out`);
|
|
1286
|
-
}
|
|
1287
|
-
const clash = buttonLabelClash(raw.buttons);
|
|
1288
|
-
if (clash) errors.push(`buttons[${clash.at}].label ${JSON.stringify(cardLine(raw.buttons[clash.at].label, Infinity))} ${clash.kind === "label" ? `repeats buttons[${clash.with}].label` : `is buttons[${clash.with}].id`} (ignoring case and spaces at the ends)`);
|
|
1289
|
-
if (raw.chosen !== void 0 && !(typeof raw.chosen === "string" && ids.has(raw.chosen))) errors.push("chosen names one of the buttons");
|
|
1290
|
-
}
|
|
1291
|
-
if (raw.closed !== void 0 && raw.closed !== true) errors.push("closed is true or left out");
|
|
1292
|
-
} else if (raw.kind === "task") {
|
|
1293
|
-
errors.push(line("title", raw.title, STATUS_CARD_LIMITS.title, true));
|
|
1294
|
-
if (!oneOf(TASK_STATUSES, raw.status)) errors.push(`status is one of ${TASK_STATUSES.join(", ")}`);
|
|
1295
|
-
if (raw.progress !== void 0 && !(typeof raw.progress === "number" && Number.isFinite(raw.progress) && raw.progress >= 0 && raw.progress <= 100)) errors.push("progress is a number from 0 to 100");
|
|
1296
|
-
if (raw.done === void 0 !== (raw.total === void 0)) errors.push("done and total go together");
|
|
1297
|
-
else if (raw.done !== void 0) {
|
|
1298
|
-
errors.push(whole("done", raw.done), whole("total", raw.total, 1));
|
|
1299
|
-
if (typeof raw.done === "number" && typeof raw.total === "number" && raw.done > raw.total) errors.push("done is at most total");
|
|
1300
|
-
}
|
|
1301
|
-
errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
|
|
1302
|
-
if (raw.pr !== void 0) {
|
|
1303
|
-
if (!isObject(raw.pr)) errors.push("pr is an object");
|
|
1304
|
-
else {
|
|
1305
|
-
errors.push(url("pr.url", raw.pr.url), whole("pr.number", raw.pr.number, 1), whole("pr.additions", raw.pr.additions), whole("pr.deletions", raw.pr.deletions), whole("pr.files", raw.pr.files));
|
|
1306
|
-
if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
|
|
1307
|
-
if (raw.pr.checks !== void 0 && !oneOf(PR_CHECKS, raw.pr.checks)) errors.push(`pr.checks is one of ${PR_CHECKS.join(", ")}`);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
if (raw.tags !== void 0) {
|
|
1311
|
-
if (!Array.isArray(raw.tags) || raw.tags.length > STATUS_CARD_LIMITS.tags) errors.push(`tags is a list of at most ${STATUS_CARD_LIMITS.tags}`);
|
|
1312
|
-
else {
|
|
1313
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1314
|
-
for (const [i, tag] of raw.tags.entries()) {
|
|
1315
|
-
const problem = line(`tags[${i}]`, tag, STATUS_CARD_LIMITS.tag, true);
|
|
1316
|
-
errors.push(problem);
|
|
1317
|
-
if (problem) continue;
|
|
1318
|
-
const clean = cardLine(tag, Infinity);
|
|
1319
|
-
if (seen.has(clean)) errors.push(`tags[${i}] ${JSON.stringify(clean)} is there twice`);
|
|
1320
|
-
seen.add(clean);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
if (raw.parent !== void 0) {
|
|
1325
|
-
if (typeof raw.parent !== "string" || !ID.test(raw.parent)) errors.push(`parent is another task's id: 1 to ${STATUS_CARD_LIMITS.id} of A-Z a-z 0-9 _ . : - and does not start with -`);
|
|
1326
|
-
else if (raw.parent === raw.id) errors.push("parent is another task's id, not its own");
|
|
1327
|
-
}
|
|
1328
|
-
for (const field of ["startedAt", "updatedAt"]) {
|
|
1329
|
-
const value = raw[field];
|
|
1330
|
-
if (value !== void 0 && !(typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + 3e5)) errors.push(`${field} is a time in milliseconds, not in the future`);
|
|
1331
|
-
}
|
|
1332
|
-
if (raw.items !== void 0) {
|
|
1333
|
-
if (!Array.isArray(raw.items) || raw.items.length > STATUS_CARD_LIMITS.items) errors.push(`items is a list of at most ${STATUS_CARD_LIMITS.items}`);
|
|
1334
|
-
else for (const [i, item] of raw.items.entries()) {
|
|
1335
|
-
if (!isObject(item)) {
|
|
1336
|
-
errors.push(`items[${i}] is an object`);
|
|
1337
|
-
continue;
|
|
1338
|
-
}
|
|
1339
|
-
errors.push(line(`items[${i}].text`, item.text, STATUS_CARD_LIMITS.line, true));
|
|
1340
|
-
if (item.state !== void 0 && !oneOf(ITEM_STATES, item.state)) errors.push(`items[${i}].state is one of ${ITEM_STATES.join(", ")}`);
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
} else {
|
|
1344
|
-
errors.push(line("name", raw.name, STATUS_CARD_LIMITS.title, true), line("schedule", raw.schedule, STATUS_CARD_LIMITS.schedule, true));
|
|
1345
|
-
if (!oneOf(ROUTINE_STATES, raw.state)) errors.push(`state is one of ${ROUTINE_STATES.join(", ")}`);
|
|
1346
|
-
if (raw.cron !== void 0 && !(typeof raw.cron === "string" && CRON.test(raw.cron.trim()))) errors.push(`cron is a cron line of at most ${STATUS_CARD_LIMITS.cron} characters`);
|
|
1347
|
-
if (raw.nextRunAt !== void 0 && !(typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs)) errors.push("nextRunAt is a time in milliseconds, within a year");
|
|
1348
|
-
const run = (field, value) => {
|
|
1349
|
-
if (!isObject(value)) return errors.push(`${field} is an object`);
|
|
1350
|
-
if (!(typeof value.at === "number" && Number.isSafeInteger(value.at) && value.at > 0 && value.at <= now + 3e5)) errors.push(`${field}.at is a time in milliseconds, not in the future`);
|
|
1351
|
-
if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
|
|
1352
|
-
errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
|
|
1353
|
-
};
|
|
1354
|
-
if (raw.lastRun !== void 0) run("lastRun", raw.lastRun);
|
|
1355
|
-
if (raw.runs !== void 0) {
|
|
1356
|
-
if (!Array.isArray(raw.runs) || raw.runs.length > STATUS_CARD_LIMITS.runs) errors.push(`runs is a list of at most ${STATUS_CARD_LIMITS.runs}`);
|
|
1357
|
-
else raw.runs.forEach((entry, i) => run(`runs[${i}]`, entry));
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
const error = errors.find((e) => typeof e === "string");
|
|
1361
|
-
if (error) return { error };
|
|
1362
|
-
const card = readStatusCard(raw, now);
|
|
1363
|
-
return card ? { card } : { error: "The card does not hold" };
|
|
1364
|
-
}
|
|
1365
|
-
/** The progress a task shows, 0 to 100: its own, else its steps done of total; undefined when it says neither. */
|
|
1366
|
-
function taskProgress(card) {
|
|
1367
|
-
if (card.progress !== void 0) return card.progress;
|
|
1368
|
-
return card.done !== void 0 && card.total ? Math.round(card.done / card.total * 100) : void 0;
|
|
1369
|
-
}
|
|
1370
|
-
var TASK_MARK = {
|
|
1371
|
-
queued: "⏳",
|
|
1372
|
-
running: "🔄",
|
|
1373
|
-
blocked: "⛔",
|
|
1374
|
-
done: "✅",
|
|
1375
|
-
failed: "❌",
|
|
1376
|
-
cancelled: "🚫"
|
|
1377
|
-
};
|
|
1378
|
-
var STATUS_WORD = {
|
|
1379
|
-
queued: "Queued",
|
|
1380
|
-
running: "Running",
|
|
1381
|
-
blocked: "Blocked",
|
|
1382
|
-
done: "Done",
|
|
1383
|
-
failed: "Failed",
|
|
1384
|
-
cancelled: "Cancelled"
|
|
1385
|
-
};
|
|
1386
|
-
var RESULT_WORD = {
|
|
1387
|
-
ok: "ok",
|
|
1388
|
-
failed: "failed",
|
|
1389
|
-
skipped: "skipped"
|
|
1390
|
-
};
|
|
1391
|
-
/** A time as the fallback text says it: in UTC, to the minute ("2026-09-30 01:00 UTC"). */
|
|
1392
|
-
function cardTimeUtc(ms) {
|
|
1393
|
-
return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
1394
|
-
}
|
|
1395
|
-
/**
|
|
1396
|
-
* The text a card's message carries: what an app without cards shows, the DHT floor carries, the chat list previews
|
|
1397
|
-
* and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
|
|
1398
|
-
*/
|
|
1399
|
-
function statusCardText(card) {
|
|
1400
|
-
if (card.kind === "app") {
|
|
1401
|
-
const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
|
|
1402
|
-
return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
|
|
1403
|
-
}
|
|
1404
|
-
if (card.kind === "usage") {
|
|
1405
|
-
const left = `${usageLeft(card)}% left${card.window ? ` (${card.window})` : ""}`;
|
|
1406
|
-
return [
|
|
1407
|
-
`📊 ${card.label ?? "Usage"}`,
|
|
1408
|
-
...card.account ? [card.account] : [],
|
|
1409
|
-
left,
|
|
1410
|
-
...card.used !== void 0 && card.limit !== void 0 ? [`${card.used} of ${card.limit} used`] : [],
|
|
1411
|
-
...card.resetsAt ? [`resets ${cardTimeUtc(card.resetsAt)}`] : [],
|
|
1412
|
-
...(card.windows ?? []).map((w) => `${w.window} ${w.left}%`)
|
|
1413
|
-
].join(" · ");
|
|
1414
|
-
}
|
|
1415
|
-
if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
|
|
1416
|
-
if (card.kind === "task") {
|
|
1417
|
-
const progress = taskProgress(card);
|
|
1418
|
-
const state = [
|
|
1419
|
-
STATUS_WORD[card.status],
|
|
1420
|
-
...progress !== void 0 ? [`${progress}%`] : [],
|
|
1421
|
-
...card.done !== void 0 && card.total ? [`${card.done} of ${card.total} steps`] : []
|
|
1422
|
-
].join(" · ");
|
|
1423
|
-
const standing = card.pr && [...card.pr.state ? [card.pr.state] : [], ...card.pr.checks ? [`checks ${card.pr.checks}`] : []].join(", ");
|
|
1424
|
-
const pr = card.pr && [
|
|
1425
|
-
`PR${card.pr.number !== void 0 ? ` #${card.pr.number}` : ""}`,
|
|
1426
|
-
...card.pr.additions !== void 0 || card.pr.deletions !== void 0 ? [`+${card.pr.additions ?? 0} -${card.pr.deletions ?? 0}`] : [],
|
|
1427
|
-
...standing ? [`(${standing})`] : []
|
|
1428
|
-
].join(" ") + `: ${card.pr.url}`;
|
|
1429
|
-
const now = card.step && ACTIVE_TASK_STATUSES.includes(card.status) ? [`Now: ${card.step}`] : [];
|
|
1430
|
-
return [
|
|
1431
|
-
`${TASK_MARK[card.status]} ${card.title}`,
|
|
1432
|
-
state,
|
|
1433
|
-
...now,
|
|
1434
|
-
...pr ? [pr] : [],
|
|
1435
|
-
...card.tags?.length ? [`Tags: ${card.tags.join(", ")}`] : [],
|
|
1436
|
-
...card.parent ? [`Part of: ${card.parent}`] : []
|
|
1437
|
-
].join("\n");
|
|
1438
|
-
}
|
|
1439
|
-
const last = card.lastRun && `Last run: ${RESULT_WORD[card.lastRun.result]}, ${cardTimeUtc(card.lastRun.at)}${card.lastRun.summary ? ` · ${card.lastRun.summary}` : ""}`;
|
|
1440
|
-
return [
|
|
1441
|
-
`🔁 ${card.name}`,
|
|
1442
|
-
`${card.schedule} · ${card.state === "active" ? "active" : "paused"}`,
|
|
1443
|
-
...last ? [last] : [],
|
|
1444
|
-
...card.nextRunAt && card.state === "active" ? [`Next run: ${cardTimeUtc(card.nextRunAt)}`] : []
|
|
1445
|
-
].join("\n");
|
|
1446
|
-
}
|
|
1447
|
-
/** Whether a message's edit number holds: a text's bound, or a card message's when the edit carries a card. */
|
|
1448
|
-
function cardEditNumber(e) {
|
|
1449
|
-
return Number.isSafeInteger(e) && e >= 1 && e <= STATUS_CARD_LIMITS.edits;
|
|
1450
|
-
}
|
|
1451
|
-
//#endregion
|
|
1452
|
-
//#region ../core/src/video.ts
|
|
1453
|
-
var VIDEO_LIMITS = {
|
|
1454
|
-
/** The poster's bytes: with the rest of an offer, a frame stays far below the 60 KiB a session carries. */
|
|
1455
|
-
maxPosterBytes: 12288,
|
|
1456
|
-
/** The poster's longest side, in pixels, as the sender draws it. */
|
|
1457
|
-
posterEdge: 320,
|
|
1458
|
-
/** Ten hours: longer is a mistake, not a video. */
|
|
1459
|
-
maxDurationMs: 36e6,
|
|
1460
|
-
/** Pixels on either side (8K and a bit). */
|
|
1461
|
-
maxEdge: 16384
|
|
1462
|
-
};
|
|
1463
|
-
/**
|
|
1464
|
-
* Video a player may be handed as it is. None of these runs anything in the page's origin, so received bytes
|
|
1465
|
-
* of these types are served with their type (see `safeBlobType`): WebKit will not play what is typed as bytes.
|
|
1466
|
-
*/
|
|
1467
|
-
var PLAYABLE_VIDEO = /^video\/(mp4|webm|quicktime|ogg|x-m4v)$/;
|
|
1468
|
-
/** Whether a file is a video, by its type: shown in a video bubble whether or not it came with a description. */
|
|
1469
|
-
function isVideoMime(mime) {
|
|
1470
|
-
return mime.split(";")[0].trim().toLowerCase().startsWith("video/");
|
|
1471
|
-
}
|
|
1472
|
-
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
1473
|
-
var JPEG_START = [
|
|
1474
|
-
255,
|
|
1475
|
-
216,
|
|
1476
|
-
255
|
|
1477
|
-
];
|
|
1478
|
-
/**
|
|
1479
|
-
* A peer's `video` description, or undefined when it is not one. It never refuses the file. A poster that is
|
|
1480
|
-
* too large or not a JPEG is dropped on its own; the length and size are kept.
|
|
1481
|
-
*/
|
|
1482
|
-
function parseVideoMeta(value, mime) {
|
|
1483
|
-
if (mime !== void 0 && !isVideoMime(mime)) return void 0;
|
|
1484
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1485
|
-
const { duration, width, height, poster } = value;
|
|
1486
|
-
if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VIDEO_LIMITS.maxDurationMs) return void 0;
|
|
1487
|
-
const edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= VIDEO_LIMITS.maxEdge;
|
|
1488
|
-
if (!edge(width) || !edge(height)) return void 0;
|
|
1489
|
-
const meta = {
|
|
1490
|
-
duration,
|
|
1491
|
-
width,
|
|
1492
|
-
height
|
|
1493
|
-
};
|
|
1494
|
-
if (typeof poster === "string" && poster.length <= Math.ceil(VIDEO_LIMITS.maxPosterBytes * 4 / 3) && BASE64URL.test(poster)) try {
|
|
1495
|
-
const bytes = fromBase64Url(poster);
|
|
1496
|
-
if (bytes.length <= VIDEO_LIMITS.maxPosterBytes && JPEG_START.every((b, i) => bytes[i] === b)) meta.poster = poster;
|
|
1497
|
-
} catch {}
|
|
1498
|
-
return meta;
|
|
1499
|
-
}
|
|
1500
|
-
/** `0:07`, `12:45`, `1:02:03`: as a player shows a video's length. */
|
|
1501
|
-
function formatVideoDuration(ms) {
|
|
1502
|
-
const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
|
|
1503
|
-
const hours = Math.floor(total / 3600), minutes = Math.floor(total % 3600 / 60), seconds = String(total % 60).padStart(2, "0");
|
|
1504
|
-
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
|
1505
|
-
}
|
|
1506
|
-
//#endregion
|
|
1507
|
-
//#region ../core/src/voice.ts
|
|
1508
|
-
var VOICE_LIMITS = {
|
|
1509
|
-
/** Fifteen minutes: at the bitrate recorded (32 kbit/s) about 3.6 MB, well inside a held item's 8 MiB. */
|
|
1510
|
-
maxDurationMs: 9e5,
|
|
1511
|
-
/** Bars drawn for a recording. */
|
|
1512
|
-
bars: 64,
|
|
1513
|
-
/** Bars accepted from a peer. */
|
|
1514
|
-
maxBars: 128,
|
|
1515
|
-
/** Asked of the recorder; opus speech is clear well below it. */
|
|
1516
|
-
audioBitsPerSecond: 32e3
|
|
1517
|
-
};
|
|
1518
|
-
/**
|
|
1519
|
-
* Audio a player may be handed as it is. None of these runs anything in the page's origin,
|
|
1520
|
-
* so received bytes of these types are served with their type (see `safeBlobType`).
|
|
1521
|
-
*/
|
|
1522
|
-
var PLAYABLE_AUDIO = /^audio\/(webm|ogg|opus|mp4|mpeg|mp3|aac|x-m4a|wav|x-wav|wave|flac|x-flac)$/;
|
|
1523
|
-
/** The part of a type before its parameters, in lower case: `audio/webm;codecs=opus` → `audio/webm`. */
|
|
1524
|
-
function baseMime(mime) {
|
|
1525
|
-
return mime.split(";")[0].trim().toLowerCase();
|
|
1526
|
-
}
|
|
1527
|
-
/**
|
|
1528
|
-
* A peer's `voice` description, or undefined when it is not one. It never refuses the file:
|
|
1529
|
-
* a bad description only means the file is shown as a file.
|
|
1530
|
-
*/
|
|
1531
|
-
function parseVoiceMeta(value, mime) {
|
|
1532
|
-
if (mime !== void 0 && !PLAYABLE_AUDIO.test(baseMime(mime))) return void 0;
|
|
1533
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1534
|
-
const { duration, peaks } = value;
|
|
1535
|
-
if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VOICE_LIMITS.maxDurationMs + 5e3) return void 0;
|
|
1536
|
-
if (!Array.isArray(peaks) || peaks.length === 0 || peaks.length > VOICE_LIMITS.maxBars) return void 0;
|
|
1537
|
-
if (!peaks.every((peak) => typeof peak === "number" && Number.isInteger(peak) && peak >= 0 && peak <= 255)) return void 0;
|
|
1538
|
-
return {
|
|
1539
|
-
duration,
|
|
1540
|
-
peaks: [...peaks]
|
|
1541
|
-
};
|
|
1542
|
-
}
|
|
1543
|
-
/**
|
|
1544
|
-
* How the level is metered while recording (apps/ui/src/lib/voiceRecorder.ts): every `intervalMs`, the RMS of the last
|
|
1545
|
-
* `window` samples an AnalyserNode holds (at a 48 kHz context, about 21 ms of sound).
|
|
1546
|
-
*/
|
|
1547
|
-
var VOICE_METER = {
|
|
1548
|
-
intervalMs: 50,
|
|
1549
|
-
window: 1024,
|
|
1550
|
-
windowRate: 48e3
|
|
1551
|
-
};
|
|
1552
|
-
/** One reading of the meter, 0-1: the RMS of the samples, lifted because speech sits low on a linear scale. */
|
|
1553
|
-
function voiceLevel(samples) {
|
|
1554
|
-
if (samples.length === 0) return 0;
|
|
1555
|
-
let sum = 0;
|
|
1556
|
-
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
|
|
1557
|
-
return Math.min(1, Math.sqrt(sum / samples.length) * 4);
|
|
1558
|
-
}
|
|
1559
|
-
/**
|
|
1560
|
-
* The readings the recorder would have taken of this sound (mono, -1 to 1), for a recording made elsewhere: one
|
|
1561
|
-
* every `intervalMs` of it, each over the window that ends there, as long in time as the analyser's.
|
|
1562
|
-
*/
|
|
1563
|
-
function voiceLevelsOf(mono, sampleRate) {
|
|
1564
|
-
if (!(sampleRate > 0) || mono.length === 0) return [];
|
|
1565
|
-
const window = Math.max(1, Math.round(VOICE_METER.window * sampleRate / VOICE_METER.windowRate));
|
|
1566
|
-
const levels = [];
|
|
1567
|
-
for (let step = 1;; step++) {
|
|
1568
|
-
const end = Math.round(step * VOICE_METER.intervalMs * sampleRate / 1e3);
|
|
1569
|
-
if (end > mono.length) break;
|
|
1570
|
-
levels.push(voiceLevel(mono.subarray(Math.max(0, end - window), end)));
|
|
1571
|
-
}
|
|
1572
|
-
return levels;
|
|
1573
|
-
}
|
|
1574
|
-
/** The waveform sent with a recording made elsewhere, drawn as the recorder draws its own. */
|
|
1575
|
-
function voicePeaksOf(mono, sampleRate, bars = VOICE_LIMITS.bars) {
|
|
1576
|
-
return downsamplePeaks(voiceLevelsOf(mono, sampleRate), bars);
|
|
1577
|
-
}
|
|
1578
|
-
/** Below this a recording is silence, and is drawn flat rather than stretched to fill the bars. */
|
|
1579
|
-
var QUIET = .02;
|
|
1580
|
-
/**
|
|
1581
|
-
* Bars from the loudness measured while recording (RMS, 0-1, oldest first): the loudest
|
|
1582
|
-
* sample of each stretch, scaled so the loudest bar is full height.
|
|
1583
|
-
*/
|
|
1584
|
-
function downsamplePeaks(levels, bars = VOICE_LIMITS.bars) {
|
|
1585
|
-
const count = Math.max(1, Math.min(VOICE_LIMITS.maxBars, Math.floor(bars)));
|
|
1586
|
-
const clean = Array.from(levels, (level) => Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0);
|
|
1587
|
-
if (clean.length === 0) return new Array(count).fill(0);
|
|
1588
|
-
const loudest = Math.max(QUIET, ...clean);
|
|
1589
|
-
const out = [];
|
|
1590
|
-
for (let bar = 0; bar < count; bar++) {
|
|
1591
|
-
const from = Math.floor(bar * clean.length / count);
|
|
1592
|
-
const to = Math.max(from + 1, Math.floor((bar + 1) * clean.length / count));
|
|
1593
|
-
let peak = 0;
|
|
1594
|
-
for (let i = from; i < to && i < clean.length; i++) peak = Math.max(peak, clean[i]);
|
|
1595
|
-
out.push(Math.round(peak / loudest * 255));
|
|
1596
|
-
}
|
|
1597
|
-
return out;
|
|
1598
|
-
}
|
|
1599
|
-
/** `0:07`, `12:45`: minutes and seconds, as a player shows them. */
|
|
1600
|
-
function formatVoiceDuration(ms) {
|
|
1601
|
-
const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
|
|
1602
|
-
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
|
1603
|
-
}
|
|
1604
|
-
/** The line a file message shows in the chat list and in notifications. */
|
|
1605
|
-
function fileMessageText(file) {
|
|
1606
|
-
if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)})`;
|
|
1607
|
-
if (file.video) return `🎬 Video (${formatVideoDuration(file.video.duration)})`;
|
|
1608
|
-
return `📎 ${file.name}`;
|
|
1609
|
-
}
|
|
1610
|
-
//#endregion
|
|
1611
|
-
//#region ../core/src/files.ts
|
|
1612
|
-
var FILE_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
1613
|
-
var MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/i;
|
|
1614
|
-
var MAX_NAME_LENGTH = 200;
|
|
1615
|
-
/**
|
|
1616
|
-
* Invisible and direction-changing characters: bidi overrides turn
|
|
1617
|
-
* "invoice\u202Efdp.exe" into what reads as "invoiceexe.pdf", zero-width ones hide
|
|
1618
|
-
* text. Cf covers both; line and paragraph separators break the layout.
|
|
1619
|
-
*/
|
|
1620
|
-
var INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
1621
|
-
/**
|
|
1622
|
-
* Names Windows keeps for devices, whatever the case, with any extension, and with spaces before the dot:
|
|
1623
|
-
* "CON", "nul.txt", "COM1 .log" open a device there, not a file.
|
|
1624
|
-
*/
|
|
1625
|
-
var WINDOWS_DEVICE = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])\s*(\.|$)/i;
|
|
1626
|
-
/**
|
|
1627
|
-
* At most `MAX_NAME_LENGTH` characters. A longer name loses the end of its stem, not its extension (up to 15
|
|
1628
|
-
* characters after the last dot): "…report.pdf" stays a PDF to the system it is saved on.
|
|
1629
|
-
*/
|
|
1630
|
-
function shorten(name) {
|
|
1631
|
-
const chars = [...name];
|
|
1632
|
-
if (chars.length <= MAX_NAME_LENGTH) return name;
|
|
1633
|
-
const extension = [.../\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? ""];
|
|
1634
|
-
return chars.slice(0, MAX_NAME_LENGTH - extension.length).join("").trimEnd() + extension.join("");
|
|
1635
|
-
}
|
|
1636
|
-
/** A file name is display text and a download suggestion, never a path. */
|
|
1637
|
-
function sanitizeFileName(name) {
|
|
1638
|
-
const clean = shorten(name.replace(INVISIBLE, "").replace(/[/\\:]/g, "").replace(/^[\s.]+/, "").trim());
|
|
1639
|
-
if (WINDOWS_DEVICE.test(clean)) return shorten("_" + clean);
|
|
1640
|
-
return clean || "file";
|
|
1641
|
-
}
|
|
1642
|
-
function sanitizeMime(mime) {
|
|
1643
|
-
return MIME.test(mime) ? mime.toLowerCase() : "application/octet-stream";
|
|
1644
|
-
}
|
|
1645
|
-
/** A size for people: B, KB, MB, GB, TB (powers of 1024), one decimal from KB up. */
|
|
1646
|
-
function formatFileSize(bytes) {
|
|
1647
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
1648
|
-
const units = [
|
|
1649
|
-
"KB",
|
|
1650
|
-
"MB",
|
|
1651
|
-
"GB",
|
|
1652
|
-
"TB"
|
|
1653
|
-
];
|
|
1654
|
-
let value = bytes / 1024, unit = 0;
|
|
1655
|
-
while (value >= 1024 && unit < units.length - 1) {
|
|
1656
|
-
value /= 1024;
|
|
1657
|
-
unit++;
|
|
1658
|
-
}
|
|
1659
|
-
return `${value.toFixed(1)} ${units[unit]}`;
|
|
1660
|
-
}
|
|
1661
|
-
/** Raster images a UI may show inline. SVG is left out on purpose: it can carry scripts. */
|
|
1662
|
-
var PREVIEWABLE_IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
|
|
1663
|
-
/**
|
|
1664
|
-
* The type received bytes are served with. The peer picks the announced type,
|
|
1665
|
-
* and a blob: URL typed text/html or image/svg+xml would run in the app's
|
|
1666
|
-
* origin, so anything but a previewable image or playable audio or video is opaque bytes.
|
|
1667
|
-
* Audio and video keep their type because WebKit will not play what is typed as bytes.
|
|
1668
|
-
*/
|
|
1669
|
-
function safeBlobType(mime) {
|
|
1670
|
-
const clean = sanitizeMime(mime);
|
|
1671
|
-
return PREVIEWABLE_IMAGE.test(clean) || PLAYABLE_AUDIO.test(clean) || PLAYABLE_VIDEO.test(clean) ? clean : "application/octet-stream";
|
|
1672
|
-
}
|
|
1673
|
-
var FileTransfers = class {
|
|
1674
|
-
channel;
|
|
1675
|
-
events;
|
|
1676
|
-
nextStreamId = 1;
|
|
1677
|
-
incoming = /* @__PURE__ */ new Map();
|
|
1678
|
-
outgoing = /* @__PURE__ */ new Map();
|
|
1679
|
-
constructor(channel, events) {
|
|
1680
|
-
this.channel = channel;
|
|
1681
|
-
this.events = events;
|
|
1682
|
-
}
|
|
1683
|
-
/** Streams a file to the peer. Resolves once every byte is handed to the channel. */
|
|
1684
|
-
async send(file, source) {
|
|
1685
|
-
if (file.size > LIMITS.maxFileBytes) throw new Error("File is too large");
|
|
1686
|
-
const id = this.nextStreamId++;
|
|
1687
|
-
const state = {
|
|
1688
|
-
fileId: file.id,
|
|
1689
|
-
cancelled: false
|
|
1690
|
-
};
|
|
1691
|
-
this.outgoing.set(id, state);
|
|
1692
|
-
try {
|
|
1693
|
-
this.channel.send(encodeControl({
|
|
1694
|
-
t: "file",
|
|
1695
|
-
id,
|
|
1696
|
-
f: file.id,
|
|
1697
|
-
ts: file.timestamp,
|
|
1698
|
-
n: sanitizeFileName(file.name),
|
|
1699
|
-
s: file.size,
|
|
1700
|
-
m: sanitizeMime(file.mime)
|
|
1701
|
-
}));
|
|
1702
|
-
let sent = 0;
|
|
1703
|
-
const counted = async function* () {
|
|
1704
|
-
for await (const part of source) {
|
|
1705
|
-
sent += part.length;
|
|
1706
|
-
yield part;
|
|
1707
|
-
this.events.onProgress?.(file.id, sent, "out");
|
|
1708
|
-
}
|
|
1709
|
-
}.call(this);
|
|
1710
|
-
await sendBody(this.channel, CHUNK_KIND.fileBody, id, counted, () => state.cancelled);
|
|
1711
|
-
if (state.cancelled) throw new Error("The peer refused the file");
|
|
1712
|
-
if (sent !== file.size) throw new Error("File changed while it was being sent");
|
|
1713
|
-
this.events.onComplete?.(file.id, "out");
|
|
1714
|
-
} catch (error) {
|
|
1715
|
-
if (!state.cancelled) try {
|
|
1716
|
-
this.channel.send(encodeControl({
|
|
1717
|
-
t: "rst",
|
|
1718
|
-
id,
|
|
1719
|
-
d: "f",
|
|
1720
|
-
e: "aborted"
|
|
1721
|
-
}));
|
|
1722
|
-
} catch {}
|
|
1723
|
-
this.events.onFailed?.(file.id, error instanceof Error ? error.message : String(error), "out");
|
|
1724
|
-
throw error;
|
|
1725
|
-
} finally {
|
|
1726
|
-
this.outgoing.delete(id);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
handleFile(frame) {
|
|
1730
|
-
if (this.incoming.has(frame.id)) return;
|
|
1731
|
-
const refuse = (reason) => {
|
|
1732
|
-
try {
|
|
1733
|
-
this.channel.send(encodeControl({
|
|
1734
|
-
t: "rst",
|
|
1735
|
-
id: frame.id,
|
|
1736
|
-
d: "f",
|
|
1737
|
-
e: reason
|
|
1738
|
-
}));
|
|
1739
|
-
} catch {}
|
|
1740
|
-
};
|
|
1741
|
-
if (!FILE_ID.test(frame.f)) return refuse("invalid file id");
|
|
1742
|
-
if (frame.s > LIMITS.maxFileBytes) return refuse("file too large");
|
|
1743
|
-
if (this.incoming.size >= LIMITS.maxIncomingFilesPerPeer) return refuse("too many transfers");
|
|
1744
|
-
const file = {
|
|
1745
|
-
id: frame.f,
|
|
1746
|
-
name: sanitizeFileName(frame.n),
|
|
1747
|
-
size: frame.s,
|
|
1748
|
-
mime: sanitizeMime(frame.m),
|
|
1749
|
-
timestamp: frame.ts
|
|
1750
|
-
};
|
|
1751
|
-
const sink = this.events.onIncoming(file);
|
|
1752
|
-
if (!sink || typeof sink === "string") return refuse(sink || "refused");
|
|
1753
|
-
const entry = {
|
|
1754
|
-
file,
|
|
1755
|
-
sink,
|
|
1756
|
-
received: 0,
|
|
1757
|
-
queue: Promise.resolve()
|
|
1758
|
-
};
|
|
1759
|
-
this.incoming.set(frame.id, entry);
|
|
1760
|
-
this.armIdleTimer(frame.id, entry);
|
|
1761
|
-
}
|
|
1762
|
-
handleChunk(chunk) {
|
|
1763
|
-
const entry = this.incoming.get(chunk.id);
|
|
1764
|
-
if (!entry) return;
|
|
1765
|
-
entry.received += chunk.payload.length;
|
|
1766
|
-
if (entry.received > entry.file.size) return this.failIncoming(chunk.id, "more data than announced", true);
|
|
1767
|
-
if (chunk.payload.length > 0) {
|
|
1768
|
-
const payload = chunk.payload.slice();
|
|
1769
|
-
entry.queue = entry.queue.then(() => entry.sink.write(payload));
|
|
1770
|
-
this.events.onProgress?.(entry.file.id, entry.received, "in");
|
|
1771
|
-
}
|
|
1772
|
-
if (!chunk.end) return this.armIdleTimer(chunk.id, entry);
|
|
1773
|
-
if (entry.received !== entry.file.size) return this.failIncoming(chunk.id, "incomplete file", false);
|
|
1774
|
-
clearTimeout(entry.idleTimer);
|
|
1775
|
-
this.incoming.delete(chunk.id);
|
|
1776
|
-
entry.queue.then(() => entry.sink.close()).then(() => this.events.onComplete?.(entry.file.id, "in"), () => {
|
|
1777
|
-
entry.sink.abort();
|
|
1778
|
-
this.events.onFailed?.(entry.file.id, "could not store the file", "in");
|
|
1779
|
-
});
|
|
1780
|
-
}
|
|
1781
|
-
handleReset(frame) {
|
|
1782
|
-
if (this.incoming.has(frame.id)) this.failIncoming(frame.id, frame.e || "cancelled by the sender", false);
|
|
1783
|
-
const sending = this.outgoing.get(frame.id);
|
|
1784
|
-
if (sending) sending.cancelled = true;
|
|
1785
|
-
}
|
|
1786
|
-
/** The link went away: nothing in flight can finish. */
|
|
1787
|
-
closeAll() {
|
|
1788
|
-
for (const id of [...this.incoming.keys()]) this.failIncoming(id, "connection lost", false);
|
|
1789
|
-
for (const sending of this.outgoing.values()) sending.cancelled = true;
|
|
1790
|
-
}
|
|
1791
|
-
armIdleTimer(id, entry) {
|
|
1792
|
-
clearTimeout(entry.idleTimer);
|
|
1793
|
-
entry.idleTimer = setTimeout(() => this.failIncoming(id, "transfer stalled", true), LIMITS.bodyIdleTimeoutMs);
|
|
1794
|
-
}
|
|
1795
|
-
failIncoming(id, reason, notifyPeer) {
|
|
1796
|
-
const entry = this.incoming.get(id);
|
|
1797
|
-
if (!entry) return;
|
|
1798
|
-
clearTimeout(entry.idleTimer);
|
|
1799
|
-
this.incoming.delete(id);
|
|
1800
|
-
entry.sink.abort();
|
|
1801
|
-
if (notifyPeer) try {
|
|
1802
|
-
this.channel.send(encodeControl({
|
|
1803
|
-
t: "rst",
|
|
1804
|
-
id,
|
|
1805
|
-
d: "f",
|
|
1806
|
-
e: reason
|
|
1807
|
-
}));
|
|
1808
|
-
} catch {}
|
|
1809
|
-
this.events.onFailed?.(entry.file.id, reason, "in");
|
|
1810
|
-
}
|
|
1811
|
-
};
|
|
1812
|
-
//#endregion
|
|
1813
|
-
//#region ../core/src/image.ts
|
|
1814
|
-
var IMAGE_LIMITS = {
|
|
1815
|
-
/** Pixels on either side: the most a JPEG can say, well past what anyone sends as a picture. */
|
|
1816
|
-
maxEdge: 65535,
|
|
1817
|
-
/** How much of a file is read for its size: past a JPEG's EXIF, thumbnails and colour profile. */
|
|
1818
|
-
headBytes: 262144
|
|
1819
|
-
};
|
|
1820
|
-
/** Whether a file is a picture, by its type. */
|
|
1821
|
-
function isImageMime(mime) {
|
|
1822
|
-
return mime.split(";")[0].trim().toLowerCase().startsWith("image/");
|
|
1823
|
-
}
|
|
1824
|
-
var edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= IMAGE_LIMITS.maxEdge;
|
|
1825
|
-
/** A peer's `image` description, or undefined when it is not one. It never refuses the file. */
|
|
1826
|
-
function parseImageMeta(value, mime) {
|
|
1827
|
-
if (mime !== void 0 && !isImageMime(mime)) return void 0;
|
|
1828
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1829
|
-
const { width, height } = value;
|
|
1830
|
-
return edge(width) && edge(height) ? {
|
|
1831
|
-
width,
|
|
1832
|
-
height
|
|
1833
|
-
} : void 0;
|
|
1834
|
-
}
|
|
1835
|
-
var u16be = (b, at) => b[at] << 8 | b[at + 1];
|
|
1836
|
-
var u16le = (b, at) => b[at] | b[at + 1] << 8;
|
|
1837
|
-
var u24le = (b, at) => b[at] | b[at + 1] << 8 | b[at + 2] << 16;
|
|
1838
|
-
var u32be = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
|
|
1839
|
-
var ascii = (b, at, text) => [...text].every((c, i) => b[at + i] === c.charCodeAt(0));
|
|
1840
|
-
var sized = (width, height) => edge(width) && edge(height) ? {
|
|
1841
|
-
width,
|
|
1842
|
-
height
|
|
1843
|
-
} : void 0;
|
|
1844
|
-
/** SOFn markers: every frame type but DHT (C4), JPG (C8) and DAC (CC). */
|
|
1845
|
-
var JPEG_FRAME = /* @__PURE__ */ new Set([
|
|
1846
|
-
192,
|
|
1847
|
-
193,
|
|
1848
|
-
194,
|
|
1849
|
-
195,
|
|
1850
|
-
197,
|
|
1851
|
-
198,
|
|
1852
|
-
199,
|
|
1853
|
-
201,
|
|
1854
|
-
202,
|
|
1855
|
-
203,
|
|
1856
|
-
205,
|
|
1857
|
-
206,
|
|
1858
|
-
207
|
|
1859
|
-
]);
|
|
1860
|
-
/** The EXIF orientation in an APP1 segment's TIFF block (1 when it says none). */
|
|
1861
|
-
function exifOrientation(b, tiff, end) {
|
|
1862
|
-
if (tiff + 8 > end) return 1;
|
|
1863
|
-
const little = ascii(b, tiff, "II");
|
|
1864
|
-
if (!little && !ascii(b, tiff, "MM")) return 1;
|
|
1865
|
-
const u16 = (at) => little ? u16le(b, at) : u16be(b, at);
|
|
1866
|
-
const u32 = (at) => little ? u16le(b, at) + u16le(b, at + 2) * 65536 : u32be(b, at);
|
|
1867
|
-
if (u16(tiff + 2) !== 42) return 1;
|
|
1868
|
-
const ifd = tiff + u32(tiff + 4);
|
|
1869
|
-
if (ifd + 2 > end) return 1;
|
|
1870
|
-
const count = u16(ifd);
|
|
1871
|
-
for (let i = 0; i < count; i++) {
|
|
1872
|
-
const entry = ifd + 2 + i * 12;
|
|
1873
|
-
if (entry + 12 > end) return 1;
|
|
1874
|
-
if (u16(entry) === 274) {
|
|
1875
|
-
const value = u16(entry + 8);
|
|
1876
|
-
return value >= 1 && value <= 8 ? value : 1;
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
return 1;
|
|
1880
|
-
}
|
|
1881
|
-
function jpegSize(b) {
|
|
1882
|
-
let at = 2, orientation = 1;
|
|
1883
|
-
while (at + 4 <= b.length) {
|
|
1884
|
-
if (b[at] !== 255) return void 0;
|
|
1885
|
-
const marker = b[at + 1];
|
|
1886
|
-
if (marker === 255) {
|
|
1887
|
-
at++;
|
|
1888
|
-
continue;
|
|
1889
|
-
}
|
|
1890
|
-
if (marker === 1 || marker >= 208 && marker <= 215) {
|
|
1891
|
-
at += 2;
|
|
1892
|
-
continue;
|
|
1893
|
-
}
|
|
1894
|
-
if (marker === 217 || marker === 218) return void 0;
|
|
1895
|
-
const length = u16be(b, at + 2);
|
|
1896
|
-
if (length < 2) return void 0;
|
|
1897
|
-
const end = Math.min(b.length, at + 2 + length);
|
|
1898
|
-
if (marker === 225 && ascii(b, at + 4, "Exif\0\0")) orientation = exifOrientation(b, at + 10, end);
|
|
1899
|
-
if (JPEG_FRAME.has(marker)) {
|
|
1900
|
-
if (at + 9 > b.length) return void 0;
|
|
1901
|
-
const height = u16be(b, at + 5), width = u16be(b, at + 7);
|
|
1902
|
-
return orientation >= 5 ? sized(height, width) : sized(width, height);
|
|
1903
|
-
}
|
|
1904
|
-
at += 2 + length;
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
function webpSize(b) {
|
|
1908
|
-
if (b.length < 30) return void 0;
|
|
1909
|
-
if (ascii(b, 12, "VP8 ")) {
|
|
1910
|
-
if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42) return void 0;
|
|
1911
|
-
return sized(u16le(b, 26) & 16383, u16le(b, 28) & 16383);
|
|
1912
|
-
}
|
|
1913
|
-
if (ascii(b, 12, "VP8L")) {
|
|
1914
|
-
if (b[20] !== 47) return void 0;
|
|
1915
|
-
const bits = b[21] | b[22] << 8 | b[23] << 16 | b[24] << 24;
|
|
1916
|
-
return sized((bits & 16383) + 1, (bits >>> 14 & 16383) + 1);
|
|
1917
|
-
}
|
|
1918
|
-
if (ascii(b, 12, "VP8X")) return sized(u24le(b, 24) + 1, u24le(b, 27) + 1);
|
|
1919
|
-
}
|
|
1920
|
-
/**
|
|
1921
|
-
* A picture's size as it is shown, read from its first bytes (PNG, JPEG, GIF, WebP), or undefined when they do not
|
|
1922
|
-
* say. Only the header is read: a JPEG whose frame header sits past `IMAGE_LIMITS.headBytes` goes without.
|
|
1923
|
-
*/
|
|
1924
|
-
function imageSize(head) {
|
|
1925
|
-
const b = head;
|
|
1926
|
-
try {
|
|
1927
|
-
if (b.length >= 24 && b[0] === 137 && ascii(b, 1, "PNG\r\n\n") && ascii(b, 12, "IHDR")) return sized(u32be(b, 16), u32be(b, 20));
|
|
1928
|
-
if (b.length >= 10 && (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a"))) return sized(u16le(b, 6), u16le(b, 8));
|
|
1929
|
-
if (b.length >= 4 && b[0] === 255 && b[1] === 216 && b[2] === 255) return jpegSize(b);
|
|
1930
|
-
if (b.length >= 16 && ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return webpSize(b);
|
|
1931
|
-
} catch {}
|
|
1932
|
-
}
|
|
1933
|
-
/**
|
|
1934
|
-
* The description a picture is sent with, read from the start of the file, or undefined when it is not a picture
|
|
1935
|
-
* or its first bytes do not say (it then goes without one).
|
|
1936
|
-
*/
|
|
1937
|
-
async function readImageMeta(source, mime) {
|
|
1938
|
-
if (!isImageMime(mime)) return void 0;
|
|
1939
|
-
try {
|
|
1940
|
-
return imageSize(new Uint8Array(await source.slice(0, IMAGE_LIMITS.headBytes).arrayBuffer()));
|
|
1941
|
-
} catch {
|
|
1942
|
-
return;
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
//#endregion
|
|
1946
|
-
//#region ../core/src/groupFiles.ts
|
|
1947
|
-
/**
|
|
1948
|
-
* Files and voice messages in groups (WISP 503 · Group Files). A file is announced as a group message carrying its
|
|
1949
|
-
* description; its bytes go over `files/3` between two members, from a member that holds it to one that asks for it.
|
|
1950
|
-
* The limits below are the profile's first numbers, kept in one place so they are easy to change.
|
|
1951
|
-
*/
|
|
1952
|
-
var GROUP_FILE_LIMITS = {
|
|
1953
|
-
/** The largest file a group takes. */
|
|
1954
|
-
maxBytes: 104857600,
|
|
1955
|
-
/** A file this size or smaller is fetched without asking (voice messages always are, up to `VOICE_LIMITS`). */
|
|
1956
|
-
autoBytes: 8388608,
|
|
1957
|
-
/** What a device keeps of a group's files it fetched without asking; past it, every file is asked about. */
|
|
1958
|
-
autoBytesPerGroup: 268435456,
|
|
1959
|
-
/** Bytes of group files a device serves to others in a day, when serving is on. */
|
|
1960
|
-
serveBytesPerDay: 1073741824,
|
|
1961
|
-
/** Transfers a holder serves at once, all groups together. */
|
|
1962
|
-
serveAtOnce: 3,
|
|
1963
|
-
/** File announcements a member sends to one group in a minute. */
|
|
1964
|
-
announcePerMinute: 8,
|
|
1965
|
-
/** File announcements of one member of a group fetched by themselves in `announceWindowMs`; past it, kept for a Download. */
|
|
1966
|
-
announceTaken: 30,
|
|
1967
|
-
announceWindowMs: 6e5,
|
|
1968
|
-
/** Holders remembered per file. */
|
|
1969
|
-
holders: 8,
|
|
1970
|
-
/**
|
|
1971
|
-
* Message ids a `group-have` named before their message came (a member back from away hears who holds what as its
|
|
1972
|
-
* catch-up begins), remembered with their holders for when it does.
|
|
1973
|
-
*/
|
|
1974
|
-
heardAhead: 256,
|
|
1975
|
-
/** Message ids in one `group-have`. */
|
|
1976
|
-
haveIds: 32,
|
|
1977
|
-
/**
|
|
1978
|
-
* `group-have` frames a private group's edge takes in `haveWindowMs`: what is to say is gathered for a second, then
|
|
1979
|
-
* goes. A community's, from a member, goes every `communityHaveEveryMs`.
|
|
1980
|
-
*/
|
|
1981
|
-
haveFrames: 10,
|
|
1982
|
-
haveWindowMs: 1e4,
|
|
1983
|
-
communityHaveEveryMs: 6e4,
|
|
1984
|
-
/**
|
|
1985
|
-
* How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
|
|
1986
|
-
* left for the next holder too.
|
|
1987
|
-
*/
|
|
1988
|
-
wantWaitMs: 3e4,
|
|
1989
|
-
/** A file session closes this long after its last transfer ends. */
|
|
1990
|
-
sessionIdleMs: 6e4,
|
|
1991
|
-
/** File sessions opening at once, and open at once, all groups together. */
|
|
1992
|
-
sessionsOpening: 2,
|
|
1993
|
-
sessionsOpen: 4,
|
|
1994
|
-
/** The longest file name and type kept from a description. */
|
|
1995
|
-
nameChars: 255,
|
|
1996
|
-
mimeChars: 255
|
|
1997
|
-
};
|
|
1998
|
-
/**
|
|
1999
|
-
* The frames of WISP 503 § Getting the bytes, on an edge between two apps that announced `GROUP_VERSION_FILES`:
|
|
2000
|
-
* `group-want {g, id}` asks a holder for the file of message `id`; `group-want-no {g, id, why}` is a holder's no;
|
|
2001
|
-
* `group-have {g, ids}` says which files a member keeps (in a community, an application frame `{t, ids}` instead).
|
|
2002
|
-
*/
|
|
2003
|
-
var GROUP_WANT_FRAME = "group-want";
|
|
2004
|
-
var GROUP_WANT_NO_FRAME = "group-want-no";
|
|
2005
|
-
var GROUP_HAVE_FRAME = "group-have";
|
|
2006
|
-
var REFUSALS = [
|
|
2007
|
-
"gone",
|
|
2008
|
-
"busy",
|
|
2009
|
-
"refused"
|
|
2010
|
-
];
|
|
2011
|
-
/** A group message's id, as a private group (`<s>:<e>:<n>`) or a community (`<s>:<e>:<h>:<n>`) names it. */
|
|
2012
|
-
var MESSAGE_ID = /^[a-z0-9]{52}:\d{1,15}(:[A-Za-z0-9_-]{1,64})?:\d{1,15}$/;
|
|
2013
|
-
var isGroupMessageId = (value) => typeof value === "string" && MESSAGE_ID.test(value);
|
|
2014
|
-
function frameOf(value, t, groupId) {
|
|
2015
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2016
|
-
const f = value;
|
|
2017
|
-
return f.t === t && (groupId === void 0 || f.g === groupId) ? f : null;
|
|
2018
|
-
}
|
|
2019
|
-
/** A `group-want` of group `groupId`: the message whose file is asked for, or null. */
|
|
2020
|
-
function readGroupWant(value, groupId) {
|
|
2021
|
-
const f = frameOf(value, GROUP_WANT_FRAME, groupId);
|
|
2022
|
-
return f && isGroupMessageId(f.id) ? { id: f.id } : null;
|
|
2023
|
-
}
|
|
2024
|
-
/** A holder's `group-want-no` of group `groupId`, or null. A reason this app does not know reads as `busy`. */
|
|
2025
|
-
function readGroupWantNo(value, groupId) {
|
|
2026
|
-
const f = frameOf(value, GROUP_WANT_NO_FRAME, groupId);
|
|
2027
|
-
if (!f || !isGroupMessageId(f.id)) return null;
|
|
2028
|
-
return {
|
|
2029
|
-
id: f.id,
|
|
2030
|
-
why: typeof f.why === "string" && REFUSALS.includes(f.why) ? f.why : "busy"
|
|
2031
|
-
};
|
|
2032
|
-
}
|
|
2033
|
-
/**
|
|
2034
|
-
* The message ids of a `group-have`: of group `groupId` on an edge, or (no `groupId`) a community's application frame.
|
|
2035
|
-
* At most `haveIds` message ids; a frame with more, or anything else, is null.
|
|
2036
|
-
*/
|
|
2037
|
-
function readGroupHave(value, groupId) {
|
|
2038
|
-
const f = frameOf(value, GROUP_HAVE_FRAME, groupId);
|
|
2039
|
-
if (!f || !Array.isArray(f.ids) || !f.ids.length || f.ids.length > GROUP_FILE_LIMITS.haveIds || !f.ids.every(isGroupMessageId)) return null;
|
|
2040
|
-
return [...new Set(f.ids)];
|
|
2041
|
-
}
|
|
2042
|
-
var DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
2043
|
-
/**
|
|
2044
|
-
* A description read from the wire, cleaned: the name made a display name (never a path), the type untrusted, the
|
|
2045
|
-
* size within the group limit, the digest the right shape. A malformed voice, video or picture description is dropped
|
|
2046
|
-
* on its own and the file is still a file; anything else malformed is no file at all (null), and the text shows.
|
|
2047
|
-
*/
|
|
2048
|
-
function readGroupFileMeta(value) {
|
|
2049
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2050
|
-
const v = value;
|
|
2051
|
-
if (typeof v.name !== "string" || !v.name.trim() || [...v.name].length > GROUP_FILE_LIMITS.nameChars) return null;
|
|
2052
|
-
if (typeof v.mime !== "string" || v.mime.length > GROUP_FILE_LIMITS.mimeChars) return null;
|
|
2053
|
-
if (!Number.isSafeInteger(v.size) || v.size < 1 || v.size > GROUP_FILE_LIMITS.maxBytes) return null;
|
|
2054
|
-
if (typeof v.d !== "string" || !DIGEST.test(v.d)) return null;
|
|
2055
|
-
const mime = sanitizeMime(v.mime);
|
|
2056
|
-
const voice = v.voice === void 0 ? void 0 : parseVoiceMeta(v.voice, mime);
|
|
2057
|
-
const video = v.video === void 0 ? void 0 : parseVideoMeta(v.video, mime);
|
|
2058
|
-
const image = v.image === void 0 ? void 0 : parseImageMeta(v.image, mime);
|
|
2059
|
-
return {
|
|
2060
|
-
name: sanitizeFileName(v.name),
|
|
2061
|
-
mime,
|
|
2062
|
-
size: v.size,
|
|
2063
|
-
d: v.d,
|
|
2064
|
-
...voice ? { voice } : {},
|
|
2065
|
-
...video ? { video } : {},
|
|
2066
|
-
...image ? { image } : {}
|
|
2067
|
-
};
|
|
2068
|
-
}
|
|
2069
|
-
/**
|
|
2070
|
-
* The text an announcement carries when its author wrote no caption: what an app without group files shows instead
|
|
2071
|
-
* of the file. English, as wire text is; an app that knows the file shows the file, not this line.
|
|
2072
|
-
*/
|
|
2073
|
-
function groupFileFallback(file) {
|
|
2074
|
-
if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)}): update Ghostly to hear it`;
|
|
2075
|
-
return `${fileMessageText(file)} (${formatFileSize(file.size)}): update Ghostly to get files in groups`;
|
|
2076
|
-
}
|
|
2077
|
-
/**
|
|
2078
|
-
* Whether a device fetches a file by itself (WISP 503 § Automatic and asked downloads): a voice message always, any
|
|
2079
|
-
* other file up to `autoBytes` while what it took that way from the group stays within `autoBytesPerGroup`.
|
|
2080
|
-
*/
|
|
2081
|
-
function fetchesByItself(file, keptAutomatically, limits = GROUP_FILE_LIMITS) {
|
|
2082
|
-
if (file.voice) return true;
|
|
2083
|
-
return file.size <= limits.autoBytes && keptAutomatically + file.size <= limits.autoBytesPerGroup;
|
|
2084
|
-
}
|
|
2085
|
-
//#endregion
|
|
2086
|
-
export { appRef as $, PR_CHECKS as A, fromBase64 as At, readStatusCard as B, downsamplePeaks as C, hasVisible as Ct, parseVideoMeta as D, trimEndOf as Dt, voicePeaksOf as E, stripHidden as Et, buttonLabelClash as F, toBase64Url as Ft, claimedTime as G, statusCardBytes as H, cardEditNumber as I, toZ32 as It, shownTime as J, heardTime as K, cardLine as L, utf8Decode as Lt, RUN_RESULTS as M, fromZ32 as Mt, STATUS_CARD_LIMITS as N, randomBytes as Nt, BUTTON_ID as O, bytesEqual as Ot, appCardId as P, toBase64 as Pt, appFingerprint as Q, cardUrl as R, utf8Encode as Rt, baseMime as S, wrapDataChannel as St, parseVoiceMeta as T, sanitizeNick as Tt, statusCardText as U, sameButtonText as V, arrivalKey as W, APP_PREFIXES as X, APP_NAME as Y, APP_STATEMENT_LIMITS as Z, safeBlobType as _, LIMITS as _t, fetchesByItself as a, readAppRevocation as at, PLAYABLE_AUDIO as b, encodeControl as bt, readGroupFileMeta as c, readAppSignature as ct, readGroupWantNo as d, verifyAppRevocation as dt, appRefPublisher as et, imageSize as f, verifyAppSignature as ft, formatFileSize as g, CHUNK_KIND as gt, FileTransfers as h, readCanonicalJson as ht, GROUP_WANT_NO_FRAME as i, isAppRef as it, PR_STATES as j, fromBase64Url as jt, ITEM_STATES as k, concatBytes as kt, readGroupHave as l, signAppObject as lt, readImageMeta as m, canonicalJsonBytes as mt, GROUP_HAVE_FRAME as n, isAppHash as nt, groupFileFallback as o, readAppRevocationShape as ot, parseImageMeta as p, canonicalJson as pt, receivedTimestamp as q, GROUP_WANT_FRAME as r, isAppKey as rt, isGroupMessageId as s, readAppRevocations as st, GROUP_FILE_LIMITS as t, checkAppRevokeStatement as tt, readGroupWant as u, signAppRevocation as ut, sanitizeFileName as v, decodeChunk as vt, fileMessageText as w, sanitizeDisplayText as wt, VOICE_LIMITS as x, sendBody as xt, sanitizeMime as y, decodeControl as yt, checkStatusCard as z };
|
|
2087
|
-
|
|
2088
|
-
//# sourceMappingURL=groupFiles-DBbjOLuD.js.map
|