pronto-imessage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ import type { ResilientRpcClient } from "./rpc.js";
2
+ import type { AttachmentReference, ConversationFacts, ConversationReference, MaterializedAttachment, MessagesEvent, MessagesHistoryBudget, MessagesHistoryPage, MessagesScopeLimits } from "../types.js";
3
+ export declare class ConversationReferenceExpiredError extends Error {
4
+ constructor();
5
+ }
6
+ interface CapabilityScope {
7
+ readonly capabilityId: string;
8
+ readonly chatId: number;
9
+ readonly expiresAt: number;
10
+ readonly generation: string;
11
+ readonly v: 1;
12
+ }
13
+ interface ConversationPayload extends CapabilityScope {
14
+ readonly facts: ConversationFacts;
15
+ readonly kind: "conversation";
16
+ readonly limits: DeferredLimits;
17
+ }
18
+ interface DeferredLimits {
19
+ readonly maxAttachmentBytes: number;
20
+ readonly maxAttachmentCount: number;
21
+ readonly maxHistoryBytes: number;
22
+ readonly maxHistoryMessages: number;
23
+ readonly maxHistoryRows: number;
24
+ readonly maxHistoryRpcCalls: number;
25
+ readonly ttlMs: number;
26
+ }
27
+ export declare class ScopedMessagesAccess {
28
+ #private;
29
+ constructor(input: {
30
+ readonly attachmentsRoot?: string;
31
+ readonly generation: () => Promise<string>;
32
+ readonly limits?: MessagesScopeLimits;
33
+ readonly now?: () => number;
34
+ readonly referenceKey?: string;
35
+ readonly rpc: ResilientRpcClient;
36
+ readonly scratchRoot?: string;
37
+ });
38
+ issueConversation(chatId: number, generation: string, facts: ConversationFacts): ConversationReference;
39
+ decorateEvent(event: MessagesEvent, rawMessage: Record<string, unknown>, generation: string, existingConversation?: ConversationReference): Promise<MessagesEvent>;
40
+ conversation(reference: ConversationReference, restoreForReply?: boolean): Promise<ConversationPayload>;
41
+ history(input: {
42
+ readonly budget: MessagesHistoryBudget;
43
+ readonly continuation?: string;
44
+ readonly conversation: ConversationReference;
45
+ readonly includeReactions?: boolean;
46
+ readonly mode?: "forward" | "recent";
47
+ }): Promise<MessagesHistoryPage>;
48
+ materializeAttachment(input: {
49
+ readonly attachment: AttachmentReference;
50
+ readonly conversation: ConversationReference;
51
+ readonly maxBytes: number;
52
+ }): Promise<MaterializedAttachment>;
53
+ }
54
+ export {};
55
+ //# sourceMappingURL=scoped.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scoped.d.ts","sourceRoot":"","sources":["../../src/internal/scoped.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AACnD,OAAO,KAAK,EACV,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,sBAAsB,EAEtB,aAAa,EACb,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAYrB,qBAAa,iCAAkC,SAAQ,KAAK;;CAI3D;AAED,UAAU,eAAe;IACvB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC;CACf;AAED,UAAU,mBAAoB,SAAQ,eAAe;IACnD,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;CACjC;AAgDD,UAAU,cAAc;IACtB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AA8JD,qBAAa,oBAAoB;;gBAWnB,KAAK,EAAE;QACjB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;QAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;QAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,mBAAmB,CAAC;QACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;QAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAC/B,QAAQ,CAAC,GAAG,EAAE,kBAAkB,CAAC;QACjC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;KAC/B;IAqBD,iBAAiB,CACf,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,iBAAiB,GACvB,qBAAqB;IAkClB,aAAa,CACjB,KAAK,EAAE,aAAa,EACpB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,UAAU,EAAE,MAAM,EAClB,oBAAoB,CAAC,EAAE,qBAAqB,GAC3C,OAAO,CAAC,aAAa,CAAC;IAqBnB,YAAY,CAChB,SAAS,EAAE,qBAAqB,EAChC,eAAe,UAAQ,GACtB,OAAO,CAAC,mBAAmB,CAAC;IAkBzB,OAAO,CAAC,KAAK,EAAE;QACnB,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;QACvC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAC/B,QAAQ,CAAC,YAAY,EAAE,qBAAqB,CAAC;QAC7C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;QACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;KACtC,GAAG,OAAO,CAAC,mBAAmB,CAAC;IA6G1B,qBAAqB,CAAC,KAAK,EAAE;QACjC,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC;QACzC,QAAQ,CAAC,YAAY,EAAE,qBAAqB,CAAC;QAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;KAC3B,GAAG,OAAO,CAAC,sBAAsB,CAAC;CA2PpC"}
@@ -0,0 +1,579 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, mkdtemp, open, realpath, rm, } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, } from "node:crypto";
6
+ import { normalizeEvent, record } from "./normalize.js";
7
+ const DEFAULT_LIMITS = {
8
+ maxAttachmentBytes: 20 * 1024 * 1024,
9
+ maxAttachmentCount: 32,
10
+ maxHistoryBytes: 8 * 1024 * 1024,
11
+ maxHistoryMessages: 2_000,
12
+ maxHistoryRows: 20_000,
13
+ maxHistoryRpcCalls: 64,
14
+ ttlMs: 15 * 60_000,
15
+ };
16
+ export class ConversationReferenceExpiredError extends Error {
17
+ constructor() {
18
+ super("messages_conversation_reference_invalid");
19
+ }
20
+ }
21
+ function positiveInteger(value) {
22
+ return Number.isSafeInteger(value) && Number(value) > 0;
23
+ }
24
+ function nonNegativeInteger(value) {
25
+ return Number.isSafeInteger(value) && Number(value) >= 0;
26
+ }
27
+ function safeString(value, maximum) {
28
+ return typeof value === "string" && value.length > 0 && value.length <= maximum &&
29
+ !/[\u0000-\u001f]/u.test(value)
30
+ ? value
31
+ : undefined;
32
+ }
33
+ function safeName(value) {
34
+ const candidate = safeString(value, 1_024);
35
+ if (candidate === undefined)
36
+ return undefined;
37
+ const name = basename(candidate);
38
+ return name !== "." && name !== ".." ? name : undefined;
39
+ }
40
+ function safeMime(value) {
41
+ return typeof value === "string" && value.length <= 128 &&
42
+ /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(value)
43
+ ? value.toLowerCase()
44
+ : undefined;
45
+ }
46
+ function scopeLimits(input) {
47
+ const value = { ...DEFAULT_LIMITS, ...input };
48
+ if (!Object.values(value).every(positiveInteger)) {
49
+ throw new Error("messages_scope_limits_invalid");
50
+ }
51
+ return value;
52
+ }
53
+ function validBudget(value) {
54
+ return Object.values(value).every(positiveInteger);
55
+ }
56
+ function inside(root, candidate) {
57
+ const child = relative(root, candidate);
58
+ return child !== "" && child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child);
59
+ }
60
+ function stableRegularFile(value) {
61
+ return value.isFile() && !value.isSymbolicLink() && value.nlink === 1 &&
62
+ (value.size === 0 || value.blocks * 512 >= value.size);
63
+ }
64
+ function ownedByCurrentUser(uid) {
65
+ return typeof process.getuid !== "function" || process.getuid() === uid;
66
+ }
67
+ function parseAttachment(value) {
68
+ const raw = record(value);
69
+ const path = safeString(raw.original_path ?? raw.path, 32_000);
70
+ const name = safeName(raw.transfer_name ?? raw.name ?? raw.filename);
71
+ const mimeType = safeMime(raw.mime_type ?? raw.mimeType) ?? "application/octet-stream";
72
+ const size = raw.total_bytes ?? raw.size;
73
+ if (path === undefined || name === undefined || !nonNegativeInteger(size))
74
+ return undefined;
75
+ return {
76
+ mimeType,
77
+ missing: raw.missing === true,
78
+ name,
79
+ path,
80
+ providerAttachmentId: safeString(raw.attachment_id ?? raw.guid, 1_024) ?? null,
81
+ size,
82
+ };
83
+ }
84
+ function detectedMime(data) {
85
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
86
+ return "image/png";
87
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
88
+ return "image/jpeg";
89
+ }
90
+ if (data.length >= 6) {
91
+ const header = data.subarray(0, 6).toString("ascii");
92
+ if (header === "GIF87a" || header === "GIF89a")
93
+ return "image/gif";
94
+ }
95
+ if (data.length >= 5 && data.subarray(0, 5).toString("ascii") === "%PDF-") {
96
+ return "application/pdf";
97
+ }
98
+ if (data.length >= 4 && data.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) {
99
+ return "application/zip";
100
+ }
101
+ try {
102
+ if (data.length > 0 && !data.includes(0)) {
103
+ new TextDecoder("utf-8", { fatal: true }).decode(data);
104
+ return "text/plain";
105
+ }
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ return undefined;
111
+ }
112
+ function mimeCompatible(advertised, detected) {
113
+ if (detected === undefined)
114
+ return false;
115
+ if (advertised === "application/octet-stream" || advertised === detected)
116
+ return true;
117
+ return detected === "text/plain" && advertised.startsWith("text/");
118
+ }
119
+ function validCapabilityScope(value) {
120
+ const item = record(value);
121
+ return item.v === 1 && safeString(item.capabilityId, 256) !== undefined &&
122
+ positiveInteger(item.chatId) && positiveInteger(item.expiresAt) &&
123
+ safeString(item.generation, 256) !== undefined;
124
+ }
125
+ function validConversationPayload(value) {
126
+ if (!validCapabilityScope(value))
127
+ return false;
128
+ const item = record(value);
129
+ const facts = record(item.facts);
130
+ const limits = record(item.limits);
131
+ return item.kind === "conversation" &&
132
+ typeof facts.ownerParticipated === "boolean" &&
133
+ (facts.service === null || safeString(facts.service, 256) !== undefined) &&
134
+ Object.keys(DEFAULT_LIMITS).every((key) => positiveInteger(limits[key]));
135
+ }
136
+ function validContinuationPayload(value) {
137
+ if (!validCapabilityScope(value))
138
+ return false;
139
+ const item = record(value);
140
+ return item.kind === "history" && nonNegativeInteger(item.cursor) &&
141
+ typeof item.includeReactions === "boolean" &&
142
+ (item.mode === "forward" || item.mode === "recent");
143
+ }
144
+ function validFileEvidence(value) {
145
+ const item = record(value);
146
+ return nonNegativeInteger(item.dev) && nonNegativeInteger(item.ino) &&
147
+ typeof item.mtimeMs === "number" && Number.isFinite(item.mtimeMs) &&
148
+ nonNegativeInteger(item.size);
149
+ }
150
+ function validAttachmentPayload(value) {
151
+ if (!validCapabilityScope(value))
152
+ return false;
153
+ const item = record(value);
154
+ return item.kind === "attachment" && nonNegativeInteger(item.attachmentIndex) &&
155
+ positiveInteger(item.messageRowId) &&
156
+ safeString(item.messageGuid, 1_024) !== undefined && safeName(item.name) !== undefined &&
157
+ safeMime(item.mimeType) !== undefined && typeof item.path === "string" && isAbsolute(item.path) &&
158
+ (item.providerAttachmentId === null || safeString(item.providerAttachmentId, 1_024) !== undefined) &&
159
+ nonNegativeInteger(item.size) && validFileEvidence(item.evidence);
160
+ }
161
+ export class ScopedMessagesAccess {
162
+ #rpc;
163
+ #generation;
164
+ #key;
165
+ #limits;
166
+ #now;
167
+ #restorableReferences;
168
+ #attachmentsRoot;
169
+ #scratchRoot;
170
+ #usage = new Map();
171
+ constructor(input) {
172
+ this.#rpc = input.rpc;
173
+ this.#generation = input.generation;
174
+ if (input.referenceKey !== undefined && Buffer.byteLength(input.referenceKey) < 32) {
175
+ throw new Error("messages_reference_key_invalid");
176
+ }
177
+ this.#key = createHash("sha256")
178
+ .update("pronto/messages/reference-key/v1\0", "utf8")
179
+ .update(input.referenceKey ?? randomBytes(32))
180
+ .digest();
181
+ this.#restorableReferences = input.referenceKey !== undefined;
182
+ this.#limits = scopeLimits(input.limits);
183
+ this.#now = input.now ?? Date.now;
184
+ this.#attachmentsRoot = resolve(input.attachmentsRoot ?? join(homedir(), "Library", "Messages", "Attachments"));
185
+ this.#scratchRoot = resolve(input.scratchRoot ?? join(tmpdir(), `pronto-imessage-${process.getuid?.() ?? "owner"}`));
186
+ }
187
+ issueConversation(chatId, generation, facts) {
188
+ const payload = {
189
+ capabilityId: randomBytes(24).toString("base64url"),
190
+ chatId,
191
+ expiresAt: this.#now() + this.#limits.ttlMs,
192
+ facts,
193
+ generation,
194
+ kind: "conversation",
195
+ limits: this.#limits,
196
+ v: 1,
197
+ };
198
+ this.#usage.set(payload.capabilityId, {
199
+ attachments: 0,
200
+ attachmentBytes: 0,
201
+ expiresAt: payload.expiresAt,
202
+ historyBytes: 0,
203
+ historyMessages: 0,
204
+ historyRows: 0,
205
+ historyRpcCalls: 0,
206
+ });
207
+ while (this.#usage.size > 1_024) {
208
+ const oldest = this.#usage.keys().next().value;
209
+ if (oldest === undefined)
210
+ break;
211
+ this.#usage.delete(oldest);
212
+ }
213
+ return {
214
+ chatId,
215
+ expiresAt: new Date(payload.expiresAt).toISOString(),
216
+ provider: "apple-messages",
217
+ token: this.#seal(payload, "conversation"),
218
+ version: 1,
219
+ };
220
+ }
221
+ async decorateEvent(event, rawMessage, generation, existingConversation) {
222
+ const conversation = existingConversation ??
223
+ this.issueConversation(event.conversation.chatId, generation, event.conversationFacts);
224
+ const rawAttachments = Array.isArray(rawMessage.attachments) ? rawMessage.attachments : [];
225
+ const attachments = await Promise.all(event.message.attachments.map(async (attachment, index) => await this.#describeAttachment(attachment, rawAttachments[index], event, conversation, generation, index)));
226
+ return {
227
+ ...event,
228
+ conversation,
229
+ message: { ...event.message, attachments },
230
+ };
231
+ }
232
+ async conversation(reference, restoreForReply = false) {
233
+ const payload = this.#open(reference.token, "conversation", validConversationPayload);
234
+ if (reference.version !== 1 || reference.provider !== "apple-messages" ||
235
+ reference.chatId !== payload.chatId ||
236
+ reference.expiresAt !== new Date(payload.expiresAt).toISOString())
237
+ throw new Error("messages_conversation_reference_invalid");
238
+ if (payload.expiresAt <= this.#now())
239
+ throw new ConversationReferenceExpiredError();
240
+ if (await this.#generation() !== payload.generation) {
241
+ throw new Error("messages_conversation_scope_changed");
242
+ }
243
+ if (!this.#usage.has(payload.capabilityId) &&
244
+ !(restoreForReply && this.#restorableReferences)) {
245
+ throw new Error("messages_conversation_reference_invalid");
246
+ }
247
+ return payload;
248
+ }
249
+ async history(input) {
250
+ if (!validBudget(input.budget))
251
+ throw new Error("messages_history_budget_invalid");
252
+ const scope = await this.conversation(input.conversation);
253
+ const usage = this.#capabilityUsage(scope);
254
+ const continuation = input.continuation === undefined
255
+ ? undefined
256
+ : this.#open(input.continuation, "history", validContinuationPayload);
257
+ const mode = continuation?.mode ?? input.mode ?? "recent";
258
+ const includeReactions = continuation?.includeReactions ?? input.includeReactions ?? false;
259
+ if (continuation !== undefined &&
260
+ (continuation.capabilityId !== scope.capabilityId ||
261
+ continuation.chatId !== scope.chatId || continuation.generation !== scope.generation ||
262
+ continuation.expiresAt <= this.#now() || input.mode !== undefined ||
263
+ input.includeReactions !== undefined))
264
+ throw new Error("messages_history_continuation_invalid");
265
+ const remaining = {
266
+ maxBytes: Math.min(input.budget.maxBytes, scope.limits.maxHistoryBytes - usage.historyBytes),
267
+ maxMessages: Math.min(input.budget.maxMessages, scope.limits.maxHistoryMessages - usage.historyMessages),
268
+ maxRows: Math.min(input.budget.maxRows, scope.limits.maxHistoryRows - usage.historyRows),
269
+ maxRpcCalls: Math.min(input.budget.maxRpcCalls, scope.limits.maxHistoryRpcCalls - usage.historyRpcCalls),
270
+ };
271
+ if (Object.values(remaining).some((value) => value <= 0)) {
272
+ throw new Error("messages_history_budget_exhausted");
273
+ }
274
+ usage.historyBytes += remaining.maxBytes;
275
+ usage.historyMessages += remaining.maxMessages;
276
+ usage.historyRows += remaining.maxRows;
277
+ usage.historyRpcCalls += 1;
278
+ const cursor = continuation?.cursor ?? 0;
279
+ const method = continuation === undefined && mode === "recent"
280
+ ? "messages.history"
281
+ : "messages.after";
282
+ const result = record(await this.#rpc.request(method, method === "messages.history" ? {
283
+ attachments: true,
284
+ chat_id: scope.chatId,
285
+ limit: Math.min(500, remaining.maxMessages, remaining.maxRows),
286
+ } : {
287
+ attachments: true,
288
+ chat_id: scope.chatId,
289
+ convert_attachments: false,
290
+ include_reactions: includeReactions,
291
+ limit: Math.min(500, remaining.maxMessages, remaining.maxRows),
292
+ since_rowid: cursor,
293
+ }));
294
+ const rawMessages = result.messages;
295
+ if (!Array.isArray(rawMessages))
296
+ throw new Error("messages_history_response_invalid");
297
+ const bytes = Buffer.byteLength(JSON.stringify(rawMessages), "utf8");
298
+ if (rawMessages.length > remaining.maxMessages || rawMessages.length > remaining.maxRows ||
299
+ bytes > remaining.maxBytes)
300
+ throw new Error("messages_history_budget_exceeded");
301
+ const events = [];
302
+ for (const rawValue of rawMessages) {
303
+ const raw = record(rawValue);
304
+ if (raw.chat_id !== scope.chatId)
305
+ throw new Error("messages_history_scope_changed");
306
+ const normalized = normalizeEvent(raw, scope.facts);
307
+ if (normalized === null)
308
+ throw new Error("messages_history_response_invalid");
309
+ events.push(await this.decorateEvent(normalized, raw, scope.generation, input.conversation));
310
+ }
311
+ events.sort((left, right) => left.message.rowId - right.message.rowId);
312
+ const nextRowId = result.next_rowid;
313
+ const hasMore = method === "messages.history" && result.has_more === undefined
314
+ ? false
315
+ : result.has_more;
316
+ if (typeof hasMore !== "boolean") {
317
+ throw new Error("messages_history_response_invalid");
318
+ }
319
+ if (hasMore && (!nonNegativeInteger(nextRowId) || nextRowId <= cursor))
320
+ throw new Error("messages_history_cursor_invalid");
321
+ if (scope.expiresAt <= this.#now() || await this.#generation() !== scope.generation) {
322
+ throw new Error("messages_history_scope_changed");
323
+ }
324
+ usage.historyBytes -= remaining.maxBytes - bytes;
325
+ usage.historyMessages -= remaining.maxMessages - events.length;
326
+ usage.historyRows -= remaining.maxRows - rawMessages.length;
327
+ return {
328
+ hasMore,
329
+ messages: events,
330
+ scannedBytes: bytes,
331
+ scannedRows: rawMessages.length,
332
+ ...(hasMore ? {
333
+ continuation: this.#seal({
334
+ capabilityId: scope.capabilityId,
335
+ chatId: scope.chatId,
336
+ cursor: Number(nextRowId),
337
+ expiresAt: scope.expiresAt,
338
+ generation: scope.generation,
339
+ includeReactions,
340
+ kind: "history",
341
+ mode,
342
+ v: 1,
343
+ }, "history"),
344
+ } : {}),
345
+ };
346
+ }
347
+ async materializeAttachment(input) {
348
+ if (!positiveInteger(input.maxBytes))
349
+ throw new Error("messages_attachment_budget_invalid");
350
+ const scope = await this.conversation(input.conversation);
351
+ const payload = this.#open(input.attachment.token, "attachment", validAttachmentPayload);
352
+ if (input.attachment.version !== 1 || input.attachment.provider !== "apple-messages" ||
353
+ input.attachment.expiresAt !== new Date(payload.expiresAt).toISOString() ||
354
+ payload.expiresAt <= this.#now() || payload.capabilityId !== scope.capabilityId ||
355
+ payload.chatId !== scope.chatId || payload.generation !== scope.generation)
356
+ throw new Error("messages_attachment_reference_invalid");
357
+ const usage = this.#capabilityUsage(scope);
358
+ if (payload.size > input.maxBytes || payload.size > scope.limits.maxAttachmentBytes ||
359
+ usage.attachments >= scope.limits.maxAttachmentCount ||
360
+ usage.attachmentBytes + payload.size > scope.limits.maxAttachmentBytes)
361
+ throw new Error("messages_attachment_budget_exhausted");
362
+ usage.attachments += 1;
363
+ usage.attachmentBytes += payload.size;
364
+ await this.#revalidateAttachment(payload);
365
+ const materialized = await this.#copyAttachment(payload);
366
+ if (payload.expiresAt <= this.#now() || await this.#generation() !== payload.generation) {
367
+ await materialized.dispose().catch(() => undefined);
368
+ throw new Error("messages_attachment_scope_changed");
369
+ }
370
+ return materialized;
371
+ }
372
+ async #describeAttachment(fallback, rawValue, event, conversation, generation, attachmentIndex) {
373
+ const parsed = parseAttachment(rawValue);
374
+ if (parsed === undefined || parsed.missing || parsed.size > this.#limits.maxAttachmentBytes) {
375
+ return { ...fallback, available: false };
376
+ }
377
+ const evidence = await this.#fileEvidence(parsed.path).catch(() => undefined);
378
+ if (evidence === undefined || evidence.size !== parsed.size) {
379
+ return { ...fallback, available: false };
380
+ }
381
+ const scope = this.#open(conversation.token, "conversation", validConversationPayload);
382
+ const payload = {
383
+ attachmentIndex,
384
+ capabilityId: scope.capabilityId,
385
+ chatId: scope.chatId,
386
+ evidence,
387
+ expiresAt: scope.expiresAt,
388
+ generation,
389
+ kind: "attachment",
390
+ messageGuid: event.message.providerMessageId,
391
+ messageRowId: event.message.rowId,
392
+ mimeType: parsed.mimeType,
393
+ name: parsed.name,
394
+ path: resolve(parsed.path),
395
+ providerAttachmentId: parsed.providerAttachmentId,
396
+ size: parsed.size,
397
+ v: 1,
398
+ };
399
+ return {
400
+ available: true,
401
+ mimeType: parsed.mimeType,
402
+ name: parsed.name,
403
+ providerAttachmentId: parsed.providerAttachmentId,
404
+ reference: {
405
+ expiresAt: new Date(payload.expiresAt).toISOString(),
406
+ provider: "apple-messages",
407
+ token: this.#seal(payload, "attachment"),
408
+ version: 1,
409
+ },
410
+ sizeBytes: parsed.size,
411
+ };
412
+ }
413
+ async #revalidateAttachment(payload) {
414
+ if (await this.#generation() !== payload.generation) {
415
+ throw new Error("messages_attachment_scope_changed");
416
+ }
417
+ const result = record(await this.#rpc.request("messages.after", {
418
+ attachments: true,
419
+ chat_id: payload.chatId,
420
+ convert_attachments: false,
421
+ include_reactions: false,
422
+ limit: 1,
423
+ since_rowid: payload.messageRowId - 1,
424
+ }));
425
+ const messages = result.messages;
426
+ if (!Array.isArray(messages) || messages.length !== 1) {
427
+ throw new Error("messages_attachment_message_changed");
428
+ }
429
+ const message = record(messages[0]);
430
+ const attachments = message.attachments;
431
+ if (message.id !== payload.messageRowId || message.guid !== payload.messageGuid ||
432
+ message.chat_id !== payload.chatId || !Array.isArray(attachments))
433
+ throw new Error("messages_attachment_message_changed");
434
+ const current = parseAttachment(attachments[payload.attachmentIndex]);
435
+ if (current === undefined || current.missing || current.path !== payload.path ||
436
+ current.name !== payload.name || current.mimeType !== payload.mimeType ||
437
+ current.providerAttachmentId !== payload.providerAttachmentId || current.size !== payload.size)
438
+ throw new Error("messages_attachment_metadata_changed");
439
+ }
440
+ async #fileEvidence(path) {
441
+ const lexical = resolve(path);
442
+ const rootMetadata = await lstat(this.#attachmentsRoot);
443
+ if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink() ||
444
+ !ownedByCurrentUser(rootMetadata.uid))
445
+ throw new Error("messages_attachment_unsafe");
446
+ const [rootReal, pathReal, metadata] = await Promise.all([
447
+ realpath(this.#attachmentsRoot),
448
+ realpath(lexical),
449
+ lstat(lexical),
450
+ ]);
451
+ if (!inside(rootReal, pathReal) || !stableRegularFile(metadata)) {
452
+ throw new Error("messages_attachment_unsafe");
453
+ }
454
+ return {
455
+ dev: metadata.dev,
456
+ ino: metadata.ino,
457
+ mtimeMs: metadata.mtimeMs,
458
+ size: metadata.size,
459
+ };
460
+ }
461
+ async #copyAttachment(payload) {
462
+ const before = await this.#fileEvidence(payload.path);
463
+ if (before.dev !== payload.evidence.dev || before.ino !== payload.evidence.ino ||
464
+ before.mtimeMs !== payload.evidence.mtimeMs || before.size !== payload.evidence.size)
465
+ throw new Error("messages_attachment_file_changed");
466
+ const scratchRoot = await this.#qualifiedScratchRoot();
467
+ const directory = await mkdtemp(join(scratchRoot, "attachment-"));
468
+ const targetPath = join(directory, payload.name);
469
+ let source;
470
+ let target;
471
+ try {
472
+ source = await open(payload.path, constants.O_RDONLY | constants.O_NOFOLLOW);
473
+ const opened = await source.stat();
474
+ if (!stableRegularFile(opened) || opened.dev !== before.dev || opened.ino !== before.ino ||
475
+ opened.mtimeMs !== before.mtimeMs || opened.size !== before.size)
476
+ throw new Error("messages_attachment_file_changed");
477
+ target = await open(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
478
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, payload.size)));
479
+ const digest = createHash("sha256");
480
+ const sniff = [];
481
+ let sniffBytes = 0;
482
+ let offset = 0;
483
+ while (offset < payload.size) {
484
+ const read = await source.read(chunk, 0, Math.min(chunk.length, payload.size - offset), offset);
485
+ if (read.bytesRead <= 0)
486
+ throw new Error("messages_attachment_size_changed");
487
+ const bytes = chunk.subarray(0, read.bytesRead);
488
+ await target.write(bytes, 0, bytes.length, offset);
489
+ digest.update(bytes);
490
+ if (sniffBytes < 64 * 1024) {
491
+ const part = Buffer.from(bytes.subarray(0, 64 * 1024 - sniffBytes));
492
+ sniff.push(part);
493
+ sniffBytes += part.length;
494
+ }
495
+ offset += read.bytesRead;
496
+ }
497
+ if ((await source.read(Buffer.allocUnsafe(1), 0, 1, payload.size)).bytesRead !== 0) {
498
+ throw new Error("messages_attachment_size_changed");
499
+ }
500
+ const after = await this.#fileEvidence(payload.path);
501
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.mtimeMs !== opened.mtimeMs ||
502
+ after.size !== opened.size)
503
+ throw new Error("messages_attachment_file_changed");
504
+ const mimeType = detectedMime(Buffer.concat(sniff));
505
+ if (!mimeCompatible(payload.mimeType, mimeType)) {
506
+ throw new Error("messages_attachment_mime_mismatch");
507
+ }
508
+ await target.close();
509
+ target = undefined;
510
+ await source.close();
511
+ source = undefined;
512
+ return {
513
+ dispose: async () => await rm(directory, { force: true, recursive: true }),
514
+ mimeType: mimeType ?? payload.mimeType,
515
+ name: payload.name,
516
+ path: targetPath,
517
+ sha256: digest.digest("hex"),
518
+ sizeBytes: payload.size,
519
+ };
520
+ }
521
+ catch (error) {
522
+ await target?.close().catch(() => undefined);
523
+ await source?.close().catch(() => undefined);
524
+ await rm(directory, { force: true, recursive: true }).catch(() => undefined);
525
+ throw error;
526
+ }
527
+ }
528
+ async #qualifiedScratchRoot() {
529
+ await mkdir(this.#scratchRoot, { mode: 0o700, recursive: true });
530
+ const metadata = await lstat(this.#scratchRoot);
531
+ if (!metadata.isDirectory() || metadata.isSymbolicLink() ||
532
+ !ownedByCurrentUser(metadata.uid) || (metadata.mode & 0o077) !== 0)
533
+ throw new Error("messages_attachment_scratch_unsafe");
534
+ return await realpath(this.#scratchRoot);
535
+ }
536
+ #capabilityUsage(scope) {
537
+ this.#pruneUsage();
538
+ const usage = this.#usage.get(scope.capabilityId);
539
+ if (usage === undefined || usage.expiresAt !== scope.expiresAt) {
540
+ throw new Error("messages_conversation_reference_invalid");
541
+ }
542
+ return usage;
543
+ }
544
+ #pruneUsage() {
545
+ const now = this.#now();
546
+ for (const [id, usage] of this.#usage) {
547
+ if (usage.expiresAt <= now)
548
+ this.#usage.delete(id);
549
+ }
550
+ }
551
+ #seal(payload, kind) {
552
+ const nonce = randomBytes(12);
553
+ const cipher = createCipheriv("aes-256-gcm", this.#key, nonce);
554
+ cipher.setAAD(Buffer.from(`pronto/messages/${kind}/v1`, "utf8"));
555
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
556
+ return Buffer.concat([nonce, cipher.getAuthTag(), encrypted]).toString("base64url");
557
+ }
558
+ #open(token, kind, validate) {
559
+ try {
560
+ const sealed = Buffer.from(token, "base64url");
561
+ if (sealed.length < 29 || sealed.toString("base64url") !== token)
562
+ throw new Error();
563
+ const decipher = createDecipheriv("aes-256-gcm", this.#key, sealed.subarray(0, 12));
564
+ decipher.setAAD(Buffer.from(`pronto/messages/${kind}/v1`, "utf8"));
565
+ decipher.setAuthTag(sealed.subarray(12, 28));
566
+ const value = JSON.parse(Buffer.concat([
567
+ decipher.update(sealed.subarray(28)),
568
+ decipher.final(),
569
+ ]).toString("utf8"));
570
+ if (!validate(value))
571
+ throw new Error();
572
+ return value;
573
+ }
574
+ catch {
575
+ throw new Error(`messages_${kind}_reference_invalid`);
576
+ }
577
+ }
578
+ }
579
+ //# sourceMappingURL=scoped.js.map