agent-pager 0.1.0 → 0.1.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.
@@ -1,1038 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { nowIso, sha256, signText, stableJson } from "./crypto.js";
3
- const MESSAGE_MAX = 600;
4
- const DEFAULT_INVITE_TTL_HOURS = 24;
5
- const MAX_INVITE_TTL_HOURS = 168;
6
- const DEFAULT_PAIRING_TTL_MINUTES = 10;
7
- const MAX_PAIRING_TTL_MINUTES = 60;
8
- const RATE_LIMIT_WINDOW_MS = 60_000;
9
- const DEFAULT_RATE_LIMIT_MAX = 8;
10
- export class HostedAgentPagerService {
11
- options;
12
- constructor(options) {
13
- this.options = options;
14
- }
15
- async createProfile(input) {
16
- const username = cleanUsername(input.username);
17
- const displayName = cleanText(input.displayName || username, 80);
18
- if (!username)
19
- throw new HostedAgentPagerError("username is required", 400);
20
- const { data: profile, error } = await this.options.supabase
21
- .from("profiles")
22
- .upsert({
23
- id: input.userId,
24
- username,
25
- display_name: displayName,
26
- public_profile_enabled: true,
27
- default_status: "offline"
28
- }, { onConflict: "id" })
29
- .select()
30
- .single();
31
- if (error)
32
- throw fromSupabaseError(error);
33
- const { error: settingsError } = await this.options.supabase
34
- .from("user_settings")
35
- .upsert({ user_id: input.userId }, { onConflict: "user_id" });
36
- if (settingsError)
37
- throw fromSupabaseError(settingsError);
38
- await this.audit("profile.upsert", { actorUserId: profile.id, targetType: "profile", targetId: profile.id });
39
- return profile;
40
- }
41
- async registerDevice(input) {
42
- const name = cleanText(input.name, 100);
43
- if (!name)
44
- throw new HostedAgentPagerError("device name is required", 400);
45
- if (!input.publicKeyPem.includes("PUBLIC KEY")) {
46
- throw new HostedAgentPagerError("publicKeyPem is required", 400);
47
- }
48
- const { data: device, error } = await this.options.supabase
49
- .from("devices")
50
- .insert({
51
- user_id: input.userId,
52
- name,
53
- public_key_pem: input.publicKeyPem,
54
- public_key_fingerprint: sha256(input.publicKeyPem),
55
- platform: input.platform || null,
56
- app_version: input.appVersion || null,
57
- last_seen_at: nowIso()
58
- })
59
- .select()
60
- .single();
61
- if (error)
62
- throw fromSupabaseError(error);
63
- await this.setPresence({
64
- userId: input.userId,
65
- deviceId: device.id,
66
- status: "online",
67
- reachable: true,
68
- activeAdapter: "setup"
69
- });
70
- await this.audit("device.register", { actorUserId: input.userId, targetType: "device", targetId: device.id });
71
- return device;
72
- }
73
- async revokeDevice(input) {
74
- const { data: existing, error: existingError } = await this.options.supabase
75
- .from("devices")
76
- .select()
77
- .eq("id", input.deviceId)
78
- .eq("user_id", input.userId)
79
- .maybeSingle();
80
- if (existingError)
81
- throw fromSupabaseError(existingError);
82
- if (!existing)
83
- throw new HostedAgentPagerError("device not found", 404);
84
- if (existing.revoked_at)
85
- return existing;
86
- const revokedAt = nowIso();
87
- const { data: device, error } = await this.options.supabase
88
- .from("devices")
89
- .update({ revoked_at: revokedAt })
90
- .eq("id", input.deviceId)
91
- .eq("user_id", input.userId)
92
- .select()
93
- .single();
94
- if (error)
95
- throw fromSupabaseError(error);
96
- const { error: presenceError } = await this.options.supabase
97
- .from("presence")
98
- .update({
99
- status: "offline",
100
- reachable: false,
101
- last_seen_at: revokedAt
102
- })
103
- .eq("device_id", input.deviceId)
104
- .eq("user_id", input.userId);
105
- if (presenceError)
106
- throw fromSupabaseError(presenceError);
107
- await this.audit("device.revoke", {
108
- actorUserId: input.userId,
109
- targetType: "device",
110
- targetId: input.deviceId,
111
- metadata: { self: input.actorDeviceId === input.deviceId }
112
- });
113
- return device;
114
- }
115
- async createDevicePairing(input) {
116
- const name = cleanText(input.name, 100);
117
- if (!name)
118
- throw new HostedAgentPagerError("device name is required", 400);
119
- if (!input.publicKeyPem.includes("PUBLIC KEY")) {
120
- throw new HostedAgentPagerError("publicKeyPem is required", 400);
121
- }
122
- const ttlMinutes = Math.min(Math.max(input.ttlMinutes || DEFAULT_PAIRING_TTL_MINUTES, 1), MAX_PAIRING_TTL_MINUTES);
123
- const pairingSecret = randomCode(32);
124
- const { data: pairing, error } = await this.options.supabase
125
- .from("device_pairing_codes")
126
- .insert({
127
- code: randomPairingCode(),
128
- pairing_secret_hash: sha256(pairingSecret),
129
- public_key_pem: input.publicKeyPem,
130
- public_key_fingerprint: sha256(input.publicKeyPem),
131
- device_name: name,
132
- platform: input.platform || null,
133
- app_version: input.appVersion || null,
134
- expires_at: new Date(Date.now() + ttlMinutes * 60_000).toISOString()
135
- })
136
- .select()
137
- .single();
138
- if (error)
139
- throw fromSupabaseError(error);
140
- await this.audit("device_pairing.create", { targetType: "device_pairing", targetId: pairing.code });
141
- return { pairing, pairingSecret };
142
- }
143
- async approveDevicePairing(input) {
144
- const pairing = await this.getValidPairing(input.code);
145
- if (pairing.approved_by_user_id || pairing.approved_at || pairing.device_id) {
146
- throw new HostedAgentPagerError("device pairing has already been approved", 400);
147
- }
148
- const device = await this.registerDevice({
149
- userId: input.userId,
150
- name: pairing.device_name,
151
- publicKeyPem: pairing.public_key_pem,
152
- platform: pairing.platform || undefined,
153
- appVersion: pairing.app_version || undefined
154
- });
155
- const { data: approved, error } = await this.options.supabase
156
- .from("device_pairing_codes")
157
- .update({
158
- approved_by_user_id: input.userId,
159
- approved_at: nowIso(),
160
- device_id: device.id
161
- })
162
- .eq("code", pairing.code)
163
- .select()
164
- .single();
165
- if (error)
166
- throw fromSupabaseError(error);
167
- await this.audit("device_pairing.approve", {
168
- actorUserId: input.userId,
169
- targetType: "device_pairing",
170
- targetId: pairing.code
171
- });
172
- return { pairing: approved, device };
173
- }
174
- async claimDevicePairing(input) {
175
- const pairing = await this.getValidPairing(input.code);
176
- if (pairing.pairing_secret_hash !== sha256(input.pairingSecret)) {
177
- throw new HostedAgentPagerError("device pairing secret is invalid", 401);
178
- }
179
- if (!pairing.approved_by_user_id || !pairing.device_id) {
180
- throw new HostedAgentPagerError("device pairing is not approved yet", 202);
181
- }
182
- const [profile, device] = await Promise.all([
183
- this.profileById(pairing.approved_by_user_id),
184
- this.deviceById(pairing.device_id)
185
- ]);
186
- if (!profile || !device)
187
- throw new HostedAgentPagerError("approved device pairing is incomplete", 500);
188
- if (!pairing.consumed_at) {
189
- await this.options.supabase
190
- .from("device_pairing_codes")
191
- .update({ consumed_at: nowIso() })
192
- .eq("code", pairing.code);
193
- }
194
- await this.audit("device_pairing.claim", {
195
- actorUserId: profile.id,
196
- targetType: "device",
197
- targetId: device.id
198
- });
199
- return { pairing, profile, device };
200
- }
201
- async setPresence(input) {
202
- const { error } = await this.options.supabase
203
- .from("presence")
204
- .upsert({
205
- user_id: input.userId,
206
- device_id: input.deviceId,
207
- status: input.status,
208
- reachable: input.reachable,
209
- active_adapter: input.activeAdapter || null,
210
- last_seen_at: nowIso()
211
- }, { onConflict: "user_id,device_id" });
212
- if (error)
213
- throw fromSupabaseError(error);
214
- }
215
- async getSettings(userId) {
216
- const { data: existing, error } = await this.options.supabase
217
- .from("user_settings")
218
- .select()
219
- .eq("user_id", userId)
220
- .maybeSingle();
221
- if (error)
222
- throw fromSupabaseError(error);
223
- if (existing)
224
- return existing;
225
- const { data, error: insertError } = await this.options.supabase
226
- .from("user_settings")
227
- .insert({ user_id: userId })
228
- .select()
229
- .single();
230
- if (insertError)
231
- throw fromSupabaseError(insertError);
232
- return data;
233
- }
234
- async updateSettings(input) {
235
- const patch = {};
236
- if (input.presenceVisibility !== undefined) {
237
- patch.presence_visibility = normalizeOneOf(input.presenceVisibility, ["private", "friends", "public"], "presenceVisibility");
238
- }
239
- if (input.defaultDeliveryMode !== undefined) {
240
- patch.default_delivery_mode = normalizeOneOf(input.defaultDeliveryMode, ["active-or-recent", "pinned-device", "notify-only"], "defaultDeliveryMode");
241
- }
242
- if (input.quietHours !== undefined) {
243
- patch.quiet_hours = normalizeQuietHours(input.quietHours);
244
- }
245
- if (input.maxPagesPerFriendPerMinute !== undefined) {
246
- const max = Math.trunc(Number(input.maxPagesPerFriendPerMinute));
247
- if (!Number.isFinite(max) || max < 1 || max > 30) {
248
- throw new HostedAgentPagerError("maxPagesPerFriendPerMinute must be between 1 and 30", 400);
249
- }
250
- patch.max_pages_per_friend_per_minute = max;
251
- }
252
- const { data, error } = await this.options.supabase
253
- .from("user_settings")
254
- .upsert({ user_id: input.userId, ...patch }, { onConflict: "user_id" })
255
- .select()
256
- .single();
257
- if (error)
258
- throw fromSupabaseError(error);
259
- await this.audit("settings.update", {
260
- actorUserId: input.userId,
261
- targetType: "user_settings",
262
- targetId: input.userId,
263
- metadata: Object.keys(patch).reduce((out, key) => {
264
- out[key] = true;
265
- return out;
266
- }, {})
267
- });
268
- return data;
269
- }
270
- async createAbuseReport(input) {
271
- const reason = cleanText(input.reason, 120);
272
- const details = cleanText(input.details || "", 1000);
273
- if (!reason)
274
- throw new HostedAgentPagerError("reason is required", 400);
275
- const [reporter, reportedUser, page] = await Promise.all([
276
- this.profileById(input.reporterUserId),
277
- input.reportedUsername ? this.profileByUsername(input.reportedUsername) : Promise.resolve(null),
278
- input.pageId ? this.pageForReport(input.reporterUserId, input.pageId) : Promise.resolve(null)
279
- ]);
280
- if (!reporter)
281
- throw new HostedAgentPagerError("reporter not found", 404);
282
- if (input.reportedUsername && !reportedUser)
283
- throw new HostedAgentPagerError("reported user not found", 404);
284
- if (input.pageId && !page)
285
- throw new HostedAgentPagerError("page not found", 404);
286
- const reportedUserId = reportedUser?.id || (page
287
- ? page.from_user_id === input.reporterUserId ? page.to_user_id : page.from_user_id
288
- : null);
289
- if (!reportedUserId && !page)
290
- throw new HostedAgentPagerError("reported user or page id is required", 400);
291
- if (reportedUserId === input.reporterUserId && !page) {
292
- throw new HostedAgentPagerError("cannot report yourself", 400);
293
- }
294
- const { data, error } = await this.options.supabase
295
- .from("abuse_reports")
296
- .insert({
297
- reporter_user_id: input.reporterUserId,
298
- reported_user_id: reportedUserId,
299
- page_id: page?.id || null,
300
- reason,
301
- details
302
- })
303
- .select()
304
- .single();
305
- if (error)
306
- throw fromSupabaseError(error);
307
- await this.audit("abuse.report", {
308
- actorUserId: input.reporterUserId,
309
- targetUserId: reportedUserId || undefined,
310
- targetType: "abuse_report",
311
- targetId: data.id,
312
- metadata: { reason, pageId: page?.id || null }
313
- });
314
- return data;
315
- }
316
- async createInvite(input) {
317
- const ttlHours = Math.min(Math.max(input.ttlHours || DEFAULT_INVITE_TTL_HOURS, 1), MAX_INVITE_TTL_HOURS);
318
- const invite = {
319
- code: randomCode(),
320
- created_by_user_id: input.userId,
321
- expires_at: new Date(Date.now() + ttlHours * 3600_000).toISOString()
322
- };
323
- const { data, error } = await this.options.supabase
324
- .from("friend_invites")
325
- .insert(invite)
326
- .select()
327
- .single();
328
- if (error)
329
- throw fromSupabaseError(error);
330
- await this.audit("invite.create", { actorUserId: input.userId, targetType: "friend_invite", targetId: data.code });
331
- return data;
332
- }
333
- async revokeInvite(input) {
334
- const { data: existing, error: existingError } = await this.options.supabase
335
- .from("friend_invites")
336
- .select()
337
- .eq("code", cleanInviteCode(input.code))
338
- .eq("created_by_user_id", input.userId)
339
- .maybeSingle();
340
- if (existingError)
341
- throw fromSupabaseError(existingError);
342
- if (!existing)
343
- throw new HostedAgentPagerError("invite not found", 404);
344
- if (existing.revoked_at || existing.accepted_by_user_id || existing.use_count >= existing.max_uses)
345
- return existing;
346
- const { data: invite, error } = await this.options.supabase
347
- .from("friend_invites")
348
- .update({ revoked_at: nowIso() })
349
- .eq("code", existing.code)
350
- .eq("created_by_user_id", input.userId)
351
- .select()
352
- .single();
353
- if (error)
354
- throw fromSupabaseError(error);
355
- await this.audit("invite.revoke", {
356
- actorUserId: input.userId,
357
- targetType: "friend_invite",
358
- targetId: invite.code
359
- });
360
- return invite;
361
- }
362
- async acceptInvite(input) {
363
- const { data: invite, error } = await this.options.supabase
364
- .from("friend_invites")
365
- .select()
366
- .eq("code", cleanInviteCode(input.code))
367
- .maybeSingle();
368
- if (error)
369
- throw fromSupabaseError(error);
370
- if (!invite ||
371
- invite.revoked_at ||
372
- invite.accepted_by_user_id ||
373
- invite.use_count >= invite.max_uses ||
374
- Date.parse(invite.expires_at) < Date.now()) {
375
- throw new HostedAgentPagerError("invite is invalid or expired", 400);
376
- }
377
- if (invite.created_by_user_id === input.userId) {
378
- throw new HostedAgentPagerError("cannot accept your own invite", 400);
379
- }
380
- await this.assertNotBlocked(input.userId, invite.created_by_user_id);
381
- const { data: request, error: requestError } = await this.options.supabase
382
- .from("friend_requests")
383
- .insert({
384
- from_user_id: input.userId,
385
- to_user_id: invite.created_by_user_id,
386
- invite_code: invite.code,
387
- note: cleanText(input.note || "", 280),
388
- status: "approved",
389
- resolved_at: nowIso()
390
- })
391
- .select()
392
- .single();
393
- if (requestError)
394
- throw fromSupabaseError(requestError);
395
- const [userA, userB] = orderedUsers(input.userId, invite.created_by_user_id);
396
- const { data: friendship, error: friendshipError } = await this.options.supabase
397
- .from("friendships")
398
- .upsert({
399
- user_a_id: userA,
400
- user_b_id: userB,
401
- created_by_request_id: request.id,
402
- revoked_at: null
403
- }, { onConflict: "user_a_id,user_b_id" })
404
- .select()
405
- .single();
406
- if (friendshipError)
407
- throw fromSupabaseError(friendshipError);
408
- const { error: inviteError } = await this.options.supabase
409
- .from("friend_invites")
410
- .update({
411
- accepted_by_user_id: input.userId,
412
- accepted_at: nowIso(),
413
- use_count: invite.use_count + 1
414
- })
415
- .eq("code", invite.code);
416
- if (inviteError)
417
- throw fromSupabaseError(inviteError);
418
- await this.audit("invite.accept", {
419
- actorUserId: input.userId,
420
- targetUserId: invite.created_by_user_id,
421
- targetType: "friend_invite",
422
- targetId: invite.code
423
- });
424
- return friendship;
425
- }
426
- async createFriendRequest(input) {
427
- const [sender, recipient] = await Promise.all([
428
- this.profileById(input.fromUserId),
429
- this.profileByUsername(input.toUsername)
430
- ]);
431
- if (!sender)
432
- throw new HostedAgentPagerError("sender not found", 404);
433
- if (!recipient || !recipient.public_profile_enabled)
434
- throw new HostedAgentPagerError("profile not found", 404);
435
- if (sender.id === recipient.id)
436
- throw new HostedAgentPagerError("cannot request yourself", 400);
437
- await this.assertNotBlocked(sender.id, recipient.id);
438
- const { data: friends, error: friendsError } = await this.options.supabase.rpc("are_friends", {
439
- left_user_id: sender.id,
440
- right_user_id: recipient.id
441
- });
442
- if (friendsError)
443
- throw fromSupabaseError(friendsError);
444
- if (friends)
445
- throw new HostedAgentPagerError("already friends", 409);
446
- const { data: request, error } = await this.options.supabase
447
- .from("friend_requests")
448
- .insert({
449
- from_user_id: sender.id,
450
- to_user_id: recipient.id,
451
- note: cleanText(input.note || "", 280),
452
- status: "pending"
453
- })
454
- .select()
455
- .single();
456
- if (error)
457
- throw fromSupabaseError(error);
458
- await this.audit("friend_request.create", {
459
- actorUserId: sender.id,
460
- targetUserId: recipient.id,
461
- targetType: "friend_request",
462
- targetId: request.id
463
- });
464
- return request;
465
- }
466
- async resolveFriendRequest(input) {
467
- const { data: existing, error } = await this.options.supabase
468
- .from("friend_requests")
469
- .select()
470
- .eq("id", input.requestId)
471
- .maybeSingle();
472
- if (error)
473
- throw fromSupabaseError(error);
474
- if (!existing)
475
- throw new HostedAgentPagerError("friend request not found", 404);
476
- if (existing.status !== "pending")
477
- throw new HostedAgentPagerError("friend request already resolved", 400);
478
- const canResolve = input.decision === "canceled"
479
- ? existing.from_user_id === input.userId
480
- : existing.to_user_id === input.userId;
481
- if (!canResolve)
482
- throw new HostedAgentPagerError("not allowed to resolve this friend request", 403);
483
- const { data: request, error: updateError } = await this.options.supabase
484
- .from("friend_requests")
485
- .update({
486
- status: input.decision,
487
- resolved_at: nowIso()
488
- })
489
- .eq("id", existing.id)
490
- .select()
491
- .single();
492
- if (updateError)
493
- throw fromSupabaseError(updateError);
494
- let friendship;
495
- if (input.decision === "approved") {
496
- await this.assertNotBlocked(existing.from_user_id, existing.to_user_id);
497
- const [userA, userB] = orderedUsers(existing.from_user_id, existing.to_user_id);
498
- const { data, error: friendshipError } = await this.options.supabase
499
- .from("friendships")
500
- .upsert({
501
- user_a_id: userA,
502
- user_b_id: userB,
503
- created_by_request_id: existing.id,
504
- revoked_at: null
505
- }, { onConflict: "user_a_id,user_b_id" })
506
- .select()
507
- .single();
508
- if (friendshipError)
509
- throw fromSupabaseError(friendshipError);
510
- friendship = data;
511
- }
512
- await this.audit(`friend_request.${input.decision}`, {
513
- actorUserId: input.userId,
514
- targetUserId: input.decision === "canceled" ? existing.to_user_id : existing.from_user_id,
515
- targetType: "friend_request",
516
- targetId: existing.id
517
- });
518
- return { request, friendship };
519
- }
520
- async sendPage(input) {
521
- const message = cleanText(input.message, MESSAGE_MAX);
522
- if (!message)
523
- throw new HostedAgentPagerError("message is required", 400);
524
- const [sender, recipient] = await Promise.all([
525
- this.profileById(input.fromUserId),
526
- this.profileByUsername(input.toUsername)
527
- ]);
528
- if (!sender)
529
- throw new HostedAgentPagerError("sender not found", 404);
530
- if (!recipient)
531
- throw new HostedAgentPagerError("recipient not found", 404);
532
- if (sender.id === recipient.id)
533
- throw new HostedAgentPagerError("cannot page yourself", 400);
534
- await this.assertCanPage(sender.id, recipient.id);
535
- await this.assertOutsideQuietHours(sender.id, recipient.id);
536
- await this.assertWithinRateLimit(sender.id, recipient.id);
537
- const { data: page, error: pageError } = await this.options.supabase
538
- .from("pages")
539
- .insert({
540
- from_user_id: sender.id,
541
- to_user_id: recipient.id,
542
- message,
543
- urgency: normalizeUrgency(input.urgency),
544
- reply_to_page_id: input.replyToPageId || null
545
- })
546
- .select()
547
- .single();
548
- if (pageError)
549
- throw fromSupabaseError(pageError);
550
- const envelope = {
551
- type: "agent-pager.page.v1",
552
- page: {
553
- ...page,
554
- fromUsername: sender.username,
555
- toUsername: recipient.username
556
- },
557
- serverTime: nowIso(),
558
- nonce: randomCode(24)
559
- };
560
- const signature = signText(this.options.serverPrivateKeyPem, stableJson(envelope));
561
- const devices = await this.activeDevicesForUser(recipient.id);
562
- const deliveryRows = (devices.length ? devices : [null]).map((device) => ({
563
- page_id: page.id,
564
- recipient_user_id: recipient.id,
565
- device_id: device?.id || null,
566
- envelope: envelope,
567
- server_signature: signature,
568
- status: "pending"
569
- }));
570
- const { data: deliveries, error: deliveryError } = await this.options.supabase
571
- .from("page_deliveries")
572
- .insert(deliveryRows)
573
- .select();
574
- if (deliveryError)
575
- throw fromSupabaseError(deliveryError);
576
- await this.options.supabase
577
- .from("rate_limit_events")
578
- .insert({
579
- actor_user_id: sender.id,
580
- target_user_id: recipient.id,
581
- action: "page.send"
582
- });
583
- await this.audit("page.send", {
584
- actorUserId: sender.id,
585
- targetUserId: recipient.id,
586
- targetType: "page",
587
- targetId: page.id,
588
- metadata: { urgency: page.urgency, deliveries: deliveries.length }
589
- });
590
- return { page, deliveries, envelope, signature };
591
- }
592
- async ackDelivery(input) {
593
- let query = this.options.supabase
594
- .from("page_deliveries")
595
- .update({ status: "acked", acked_at: nowIso() })
596
- .eq("id", input.deliveryId)
597
- .eq("recipient_user_id", input.userId);
598
- if (input.deviceId)
599
- query = query.eq("device_id", input.deviceId);
600
- const { error } = await query;
601
- if (error)
602
- throw fromSupabaseError(error);
603
- }
604
- async muteFriend(input) {
605
- const [owner, mutedUser] = await Promise.all([
606
- this.profileById(input.ownerUserId),
607
- this.profileByUsername(input.mutedUsername)
608
- ]);
609
- if (!owner)
610
- throw new HostedAgentPagerError("owner not found", 404);
611
- if (!mutedUser)
612
- throw new HostedAgentPagerError("friend not found", 404);
613
- if (owner.id === mutedUser.id)
614
- throw new HostedAgentPagerError("cannot mute yourself", 400);
615
- const { data: friends, error } = await this.options.supabase.rpc("are_friends", {
616
- left_user_id: owner.id,
617
- right_user_id: mutedUser.id
618
- });
619
- if (error)
620
- throw fromSupabaseError(error);
621
- if (!friends)
622
- throw new HostedAgentPagerError("can only mute approved friends", 403);
623
- const mutedUntil = input.durationMs === null || input.durationMs === undefined
624
- ? null
625
- : new Date(Date.now() + Math.max(input.durationMs, 60_000)).toISOString();
626
- const { error: muteError } = await this.options.supabase
627
- .from("mutes")
628
- .upsert({
629
- owner_user_id: owner.id,
630
- muted_user_id: mutedUser.id,
631
- muted_until: mutedUntil,
632
- reason: cleanText(input.reason || "", 160) || null
633
- }, { onConflict: "owner_user_id,muted_user_id" });
634
- if (muteError)
635
- throw fromSupabaseError(muteError);
636
- await this.audit("friend.mute", {
637
- actorUserId: owner.id,
638
- targetUserId: mutedUser.id,
639
- targetType: "mute",
640
- targetId: mutedUser.id,
641
- metadata: { mutedUntil }
642
- });
643
- return { mutedUntil, mutedUser };
644
- }
645
- async unmuteFriend(input) {
646
- const [owner, mutedUser] = await Promise.all([
647
- this.profileById(input.ownerUserId),
648
- this.profileByUsername(input.mutedUsername)
649
- ]);
650
- if (!owner)
651
- throw new HostedAgentPagerError("owner not found", 404);
652
- if (!mutedUser)
653
- throw new HostedAgentPagerError("friend not found", 404);
654
- if (owner.id === mutedUser.id)
655
- throw new HostedAgentPagerError("cannot unmute yourself", 400);
656
- const { error } = await this.options.supabase
657
- .from("mutes")
658
- .delete()
659
- .eq("owner_user_id", owner.id)
660
- .eq("muted_user_id", mutedUser.id);
661
- if (error)
662
- throw fromSupabaseError(error);
663
- await this.audit("friend.unmute", {
664
- actorUserId: owner.id,
665
- targetUserId: mutedUser.id,
666
- targetType: "mute",
667
- targetId: mutedUser.id
668
- });
669
- return { unmutedUser: mutedUser };
670
- }
671
- async removeFriend(input) {
672
- const [owner, friend] = await Promise.all([
673
- this.profileById(input.ownerUserId),
674
- this.profileByUsername(input.friendUsername)
675
- ]);
676
- if (!owner)
677
- throw new HostedAgentPagerError("owner not found", 404);
678
- if (!friend)
679
- throw new HostedAgentPagerError("friend not found", 404);
680
- if (owner.id === friend.id)
681
- throw new HostedAgentPagerError("cannot remove yourself", 400);
682
- const [userA, userB] = orderedUsers(owner.id, friend.id);
683
- const { data: existing, error: existingError } = await this.options.supabase
684
- .from("friendships")
685
- .select()
686
- .eq("user_a_id", userA)
687
- .eq("user_b_id", userB)
688
- .is("revoked_at", null)
689
- .maybeSingle();
690
- if (existingError)
691
- throw fromSupabaseError(existingError);
692
- if (!existing)
693
- throw new HostedAgentPagerError("friendship not found", 404);
694
- const revokedAt = nowIso();
695
- const { data: friendship, error } = await this.options.supabase
696
- .from("friendships")
697
- .update({ revoked_at: revokedAt })
698
- .eq("id", existing.id)
699
- .select()
700
- .single();
701
- if (error)
702
- throw fromSupabaseError(error);
703
- const { error: muteError } = await this.options.supabase
704
- .from("mutes")
705
- .delete()
706
- .or(`and(owner_user_id.eq.${owner.id},muted_user_id.eq.${friend.id}),and(owner_user_id.eq.${friend.id},muted_user_id.eq.${owner.id})`);
707
- if (muteError)
708
- throw fromSupabaseError(muteError);
709
- await this.audit("friend.remove", {
710
- actorUserId: owner.id,
711
- targetUserId: friend.id,
712
- targetType: "friendship",
713
- targetId: friendship.id,
714
- metadata: { revokedAt }
715
- });
716
- return { removedFriend: friend, friendship };
717
- }
718
- async blockUser(input) {
719
- const [owner, blockedUser] = await Promise.all([
720
- this.profileById(input.ownerUserId),
721
- this.profileByUsername(input.blockedUsername)
722
- ]);
723
- if (!owner)
724
- throw new HostedAgentPagerError("owner not found", 404);
725
- if (!blockedUser)
726
- throw new HostedAgentPagerError("profile not found", 404);
727
- if (owner.id === blockedUser.id)
728
- throw new HostedAgentPagerError("cannot block yourself", 400);
729
- const { error } = await this.options.supabase
730
- .from("blocks")
731
- .upsert({
732
- owner_user_id: owner.id,
733
- blocked_user_id: blockedUser.id
734
- }, { onConflict: "owner_user_id,blocked_user_id" });
735
- if (error)
736
- throw fromSupabaseError(error);
737
- const { error: requestError } = await this.options.supabase
738
- .from("friend_requests")
739
- .update({ status: "denied", resolved_at: nowIso() })
740
- .eq("status", "pending")
741
- .or(`and(from_user_id.eq.${owner.id},to_user_id.eq.${blockedUser.id}),and(from_user_id.eq.${blockedUser.id},to_user_id.eq.${owner.id})`);
742
- if (requestError)
743
- throw fromSupabaseError(requestError);
744
- await this.audit("friend.block", {
745
- actorUserId: owner.id,
746
- targetUserId: blockedUser.id,
747
- targetType: "block",
748
- targetId: blockedUser.id
749
- });
750
- return { blockedUser };
751
- }
752
- async unblockUser(input) {
753
- const [owner, blockedUser] = await Promise.all([
754
- this.profileById(input.ownerUserId),
755
- this.profileByUsername(input.blockedUsername)
756
- ]);
757
- if (!owner)
758
- throw new HostedAgentPagerError("owner not found", 404);
759
- if (!blockedUser)
760
- throw new HostedAgentPagerError("profile not found", 404);
761
- if (owner.id === blockedUser.id)
762
- throw new HostedAgentPagerError("cannot unblock yourself", 400);
763
- const { error } = await this.options.supabase
764
- .from("blocks")
765
- .delete()
766
- .eq("owner_user_id", owner.id)
767
- .eq("blocked_user_id", blockedUser.id);
768
- if (error)
769
- throw fromSupabaseError(error);
770
- await this.audit("friend.unblock", {
771
- actorUserId: owner.id,
772
- targetUserId: blockedUser.id,
773
- targetType: "block",
774
- targetId: blockedUser.id
775
- });
776
- return { unblockedUser: blockedUser };
777
- }
778
- async profileById(id) {
779
- const { data, error } = await this.options.supabase
780
- .from("profiles")
781
- .select()
782
- .eq("id", id)
783
- .maybeSingle();
784
- if (error)
785
- throw fromSupabaseError(error);
786
- return data;
787
- }
788
- async profileByUsername(username) {
789
- const { data, error } = await this.options.supabase
790
- .from("profiles")
791
- .select()
792
- .eq("username", cleanUsername(username))
793
- .maybeSingle();
794
- if (error)
795
- throw fromSupabaseError(error);
796
- return data;
797
- }
798
- async pageForReport(userId, pageId) {
799
- const { data, error } = await this.options.supabase
800
- .from("pages")
801
- .select()
802
- .eq("id", pageId)
803
- .or(`from_user_id.eq.${userId},to_user_id.eq.${userId}`)
804
- .maybeSingle();
805
- if (error)
806
- throw fromSupabaseError(error);
807
- return data;
808
- }
809
- async activeDevicesForUser(userId) {
810
- const { data, error } = await this.options.supabase
811
- .from("devices")
812
- .select()
813
- .eq("user_id", userId)
814
- .is("revoked_at", null);
815
- if (error)
816
- throw fromSupabaseError(error);
817
- return data;
818
- }
819
- async getValidPairing(code) {
820
- const { data: pairing, error } = await this.options.supabase
821
- .from("device_pairing_codes")
822
- .select()
823
- .eq("code", cleanPairingCode(code))
824
- .maybeSingle();
825
- if (error)
826
- throw fromSupabaseError(error);
827
- if (!pairing || pairing.revoked_at || Date.parse(pairing.expires_at) < Date.now()) {
828
- throw new HostedAgentPagerError("device pairing is invalid or expired", 400);
829
- }
830
- return pairing;
831
- }
832
- async deviceById(id) {
833
- const { data, error } = await this.options.supabase
834
- .from("devices")
835
- .select()
836
- .eq("id", id)
837
- .is("revoked_at", null)
838
- .maybeSingle();
839
- if (error)
840
- throw fromSupabaseError(error);
841
- return data;
842
- }
843
- async assertCanPage(senderId, recipientId) {
844
- const { data: friends, error } = await this.options.supabase.rpc("are_friends", {
845
- left_user_id: senderId,
846
- right_user_id: recipientId
847
- });
848
- if (error)
849
- throw fromSupabaseError(error);
850
- if (!friends)
851
- throw new HostedAgentPagerError("recipient is not an approved friend", 403);
852
- await this.assertNotBlocked(senderId, recipientId);
853
- const { data: mutes, error: muteError } = await this.options.supabase
854
- .from("mutes")
855
- .select("muted_until")
856
- .eq("owner_user_id", recipientId)
857
- .eq("muted_user_id", senderId);
858
- if (muteError)
859
- throw fromSupabaseError(muteError);
860
- const activeMute = mutes.some((mute) => !mute.muted_until || Date.parse(mute.muted_until) > Date.now());
861
- if (activeMute)
862
- throw new HostedAgentPagerError("recipient has muted this friend", 403);
863
- }
864
- async assertNotBlocked(leftUserId, rightUserId) {
865
- const { data: blocks, error: blockError } = await this.options.supabase
866
- .from("blocks")
867
- .select("owner_user_id")
868
- .or(`and(owner_user_id.eq.${leftUserId},blocked_user_id.eq.${rightUserId}),and(owner_user_id.eq.${rightUserId},blocked_user_id.eq.${leftUserId})`);
869
- if (blockError)
870
- throw fromSupabaseError(blockError);
871
- if (blocks.length)
872
- throw new HostedAgentPagerError("friendship is blocked", 403);
873
- }
874
- async assertWithinRateLimit(senderId, recipientId) {
875
- const since = new Date(Date.now() - RATE_LIMIT_WINDOW_MS).toISOString();
876
- const { data: settings, error: settingsError } = await this.options.supabase
877
- .from("user_settings")
878
- .select("max_pages_per_friend_per_minute")
879
- .eq("user_id", recipientId)
880
- .maybeSingle();
881
- if (settingsError)
882
- throw fromSupabaseError(settingsError);
883
- const max = settings?.max_pages_per_friend_per_minute || DEFAULT_RATE_LIMIT_MAX;
884
- const { count, error } = await this.options.supabase
885
- .from("rate_limit_events")
886
- .select("id", { count: "exact", head: true })
887
- .eq("actor_user_id", senderId)
888
- .eq("target_user_id", recipientId)
889
- .eq("action", "page.send")
890
- .gte("created_at", since);
891
- if (error)
892
- throw fromSupabaseError(error);
893
- if ((count || 0) >= max)
894
- throw new HostedAgentPagerError("rate limited for this friend", 429);
895
- }
896
- async assertOutsideQuietHours(senderId, recipientId) {
897
- const { data: settings, error } = await this.options.supabase
898
- .from("user_settings")
899
- .select("quiet_hours")
900
- .eq("user_id", recipientId)
901
- .maybeSingle();
902
- if (error)
903
- throw fromSupabaseError(error);
904
- if (!settings || !isQuietHoursActive(settings.quiet_hours))
905
- return;
906
- await this.audit("page.quiet_hours_block", {
907
- actorUserId: senderId,
908
- targetUserId: recipientId,
909
- targetType: "user_settings",
910
- targetId: recipientId
911
- });
912
- throw new HostedAgentPagerError("recipient is in quiet hours", 403);
913
- }
914
- async audit(action, input) {
915
- const { error } = await this.options.supabase.from("audit_events").insert({
916
- action,
917
- actor_user_id: input.actorUserId || null,
918
- target_user_id: input.targetUserId || null,
919
- target_type: input.targetType || null,
920
- target_id: input.targetId || null,
921
- metadata: (input.metadata || {})
922
- });
923
- if (error)
924
- throw fromSupabaseError(error);
925
- }
926
- }
927
- export class HostedAgentPagerError extends Error {
928
- status;
929
- constructor(message, status = 500) {
930
- super(message);
931
- this.status = status;
932
- }
933
- }
934
- function fromSupabaseError(error) {
935
- const status = error.code === "23505" ? 409 : error.code === "23514" ? 400 : 500;
936
- return new HostedAgentPagerError(error.message, status);
937
- }
938
- function cleanUsername(value) {
939
- return String(value || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 31);
940
- }
941
- function cleanText(value, max) {
942
- return String(value || "").trim().replace(/\s+/g, " ").slice(0, max);
943
- }
944
- function normalizeOneOf(value, allowed, label) {
945
- if (allowed.includes(value))
946
- return value;
947
- throw new HostedAgentPagerError(`${label} is invalid`, 400);
948
- }
949
- function normalizeQuietHours(value) {
950
- if (!value || typeof value !== "object" || Array.isArray(value)) {
951
- throw new HostedAgentPagerError("quietHours must be an object", 400);
952
- }
953
- const input = value;
954
- const enabled = Boolean(input.enabled);
955
- const start = typeof input.start === "string" && input.start ? input.start : "22:00";
956
- const end = typeof input.end === "string" && input.end ? input.end : "08:00";
957
- const timezone = typeof input.timezone === "string" && input.timezone ? cleanText(input.timezone, 80) : "UTC";
958
- assertClockTime(start, "quietHours.start");
959
- assertClockTime(end, "quietHours.end");
960
- assertTimeZone(timezone);
961
- return { enabled, start, end, timezone };
962
- }
963
- function quietHoursFromJson(value) {
964
- if (!value || typeof value !== "object" || Array.isArray(value))
965
- return null;
966
- const input = value;
967
- const enabled = input.enabled === true;
968
- const start = typeof input.start === "string" ? input.start : "";
969
- const end = typeof input.end === "string" ? input.end : "";
970
- const timezone = typeof input.timezone === "string" ? input.timezone : "UTC";
971
- if (!enabled || !isClockTime(start) || !isClockTime(end) || !isValidTimeZone(timezone))
972
- return null;
973
- return { enabled, start, end, timezone };
974
- }
975
- function isQuietHoursActive(value) {
976
- const quietHours = quietHoursFromJson(value);
977
- if (!quietHours)
978
- return false;
979
- const start = minutesFromClockTime(quietHours.start);
980
- const end = minutesFromClockTime(quietHours.end);
981
- if (start === end)
982
- return false;
983
- const now = localMinutesInTimeZone(quietHours.timezone);
984
- return start < end ? now >= start && now < end : now >= start || now < end;
985
- }
986
- function assertClockTime(value, label) {
987
- if (!isClockTime(value))
988
- throw new HostedAgentPagerError(`${label} must be HH:MM`, 400);
989
- }
990
- function isClockTime(value) {
991
- return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
992
- }
993
- function minutesFromClockTime(value) {
994
- const [hours, minutes] = value.split(":").map(Number);
995
- return hours * 60 + minutes;
996
- }
997
- function assertTimeZone(value) {
998
- if (!isValidTimeZone(value))
999
- throw new HostedAgentPagerError("quietHours.timezone is invalid", 400);
1000
- }
1001
- function isValidTimeZone(value) {
1002
- try {
1003
- new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
1004
- return true;
1005
- }
1006
- catch {
1007
- return false;
1008
- }
1009
- }
1010
- function localMinutesInTimeZone(timeZone) {
1011
- const parts = new Intl.DateTimeFormat("en-GB", {
1012
- timeZone,
1013
- hour: "2-digit",
1014
- minute: "2-digit",
1015
- hour12: false
1016
- }).formatToParts(new Date());
1017
- const hour = Number(parts.find((part) => part.type === "hour")?.value || 0) % 24;
1018
- const minute = Number(parts.find((part) => part.type === "minute")?.value || 0);
1019
- return hour * 60 + minute;
1020
- }
1021
- function randomCode(bytes = 18) {
1022
- return randomBytes(bytes).toString("base64url");
1023
- }
1024
- function randomPairingCode() {
1025
- return randomBytes(5).toString("hex").toUpperCase();
1026
- }
1027
- function cleanPairingCode(value) {
1028
- return String(value || "").trim().toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 16);
1029
- }
1030
- function cleanInviteCode(value) {
1031
- return String(value || "").trim().replace(/[^A-Za-z0-9_-]/g, "").slice(0, 96);
1032
- }
1033
- function orderedUsers(left, right) {
1034
- return left < right ? [left, right] : [right, left];
1035
- }
1036
- function normalizeUrgency(urgency) {
1037
- return urgency && ["gentle", "normal", "firm", "professionally-dramatic"].includes(urgency) ? urgency : "normal";
1038
- }