agent-pager 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,849 @@
1
+ import { canonicalRequest, nowIso, verifyText } from "./crypto.js";
2
+ import { HostedAgentPagerError, HostedAgentPagerService } from "./hosted-service.js";
3
+ import { createSupabaseServiceClient, createSupabaseUserClient, loadSupabaseRuntimeConfig } from "./supabase.js";
4
+ const SIGNATURE_MAX_AGE_MS = 5 * 60_000;
5
+ const DEFAULT_PUBLIC_URL = "http://127.0.0.1:8787";
6
+ const CORS_HEADERS = {
7
+ "access-control-allow-origin": "*",
8
+ "access-control-allow-methods": "GET,POST,OPTIONS",
9
+ "access-control-allow-headers": "authorization,content-type,x-ap-device-id,x-ap-signature,x-ap-timestamp"
10
+ };
11
+ const VERCEL_ROUTE_QUERY_KEYS = [
12
+ "action",
13
+ "code",
14
+ "deliveryId",
15
+ "deviceId",
16
+ "path",
17
+ "requestId",
18
+ "username"
19
+ ];
20
+ export class HostedHttpHandler {
21
+ options;
22
+ service;
23
+ publicUrl;
24
+ constructor(options) {
25
+ this.options = options;
26
+ this.service = new HostedAgentPagerService({
27
+ supabase: options.supabase,
28
+ serverPrivateKeyPem: options.serverPrivateKeyPem
29
+ });
30
+ this.publicUrl = (options.publicUrl || DEFAULT_PUBLIC_URL).replace(/\/+$/, "");
31
+ }
32
+ async handle(request) {
33
+ const url = new URL(request.url);
34
+ const bodyText = await request.text();
35
+ try {
36
+ if (request.method === "OPTIONS")
37
+ return empty(204);
38
+ if (request.method === "GET" && url.pathname === "/api/health") {
39
+ return json({ ok: true, service: "agent-pager-hosted", realtime: "supabase" });
40
+ }
41
+ if (request.method === "GET" && url.pathname === "/api/config") {
42
+ return json({
43
+ supabaseUrl: this.options.supabaseUrl,
44
+ supabaseAnonKey: this.options.supabaseAnonKey,
45
+ publicUrl: this.publicUrl
46
+ });
47
+ }
48
+ const publicProfile = url.pathname.match(/^\/api\/public\/profiles\/([^/]+)$/);
49
+ if (request.method === "GET" && publicProfile) {
50
+ const profile = await this.publicProfileByUsername(decodeURIComponent(publicProfile[1]));
51
+ if (!profile)
52
+ return json({ error: "profile not found" }, 404);
53
+ return json({ profile });
54
+ }
55
+ if (request.method === "POST" && url.pathname === "/api/profiles") {
56
+ const actor = await this.authenticateBearer(request);
57
+ const body = parseJsonObject(bodyText);
58
+ const profile = await this.service.createProfile({
59
+ userId: actor.userId,
60
+ username: stringBody(body.username),
61
+ displayName: optionalStringBody(body.displayName)
62
+ });
63
+ return json({ profile });
64
+ }
65
+ if (request.method === "POST" && url.pathname === "/api/devices") {
66
+ const actor = await this.authenticateBearer(request);
67
+ const body = parseJsonObject(bodyText);
68
+ const device = await this.service.registerDevice({
69
+ userId: actor.userId,
70
+ name: stringBody(body.name || body.deviceName || "local terminal"),
71
+ publicKeyPem: stringBody(body.publicKeyPem),
72
+ platform: optionalStringBody(body.platform),
73
+ appVersion: optionalStringBody(body.appVersion)
74
+ });
75
+ return json({ device, serverPublicKeyPem: this.options.serverPublicKeyPem });
76
+ }
77
+ if (request.method === "POST" && url.pathname === "/api/device-pairings") {
78
+ const body = parseJsonObject(bodyText);
79
+ const result = await this.service.createDevicePairing({
80
+ name: stringBody(body.name || body.deviceName || "local terminal"),
81
+ publicKeyPem: stringBody(body.publicKeyPem),
82
+ platform: optionalStringBody(body.platform),
83
+ appVersion: optionalStringBody(body.appVersion),
84
+ ttlMinutes: optionalNumberBody(body.ttlMinutes)
85
+ });
86
+ return json({
87
+ code: result.pairing.code,
88
+ pairingSecret: result.pairingSecret,
89
+ verificationUrl: `${this.publicUrl}/app/devices/pair?code=${encodeURIComponent(result.pairing.code)}`,
90
+ expiresAt: result.pairing.expires_at
91
+ });
92
+ }
93
+ const pairingApprove = url.pathname.match(/^\/api\/device-pairings\/([^/]+)\/approve$/);
94
+ if (request.method === "POST" && pairingApprove) {
95
+ const actor = await this.authenticateBearer(request);
96
+ const result = await this.service.approveDevicePairing({
97
+ userId: actor.userId,
98
+ code: pairingApprove[1]
99
+ });
100
+ return json({ pairing: publicPairing(result.pairing), device: result.device });
101
+ }
102
+ const pairingClaim = url.pathname.match(/^\/api\/device-pairings\/([^/]+)\/claim$/);
103
+ if (request.method === "POST" && pairingClaim) {
104
+ const body = parseJsonObject(bodyText);
105
+ const result = await this.service.claimDevicePairing({
106
+ code: pairingClaim[1],
107
+ pairingSecret: stringBody(body.pairingSecret)
108
+ });
109
+ return json({
110
+ profile: result.profile,
111
+ device: result.device,
112
+ serverPublicKeyPem: this.options.serverPublicKeyPem,
113
+ realtime: await this.createRealtimeSession(result.profile.id)
114
+ });
115
+ }
116
+ const actor = await this.authenticate(request, url, bodyText);
117
+ if (request.method === "GET" && url.pathname === "/api/devices") {
118
+ return json({ devices: await this.listDevices(actor.userId) });
119
+ }
120
+ const deviceRevoke = url.pathname.match(/^\/api\/devices\/([^/]+)\/revoke$/);
121
+ if (request.method === "POST" && deviceRevoke) {
122
+ const device = await this.service.revokeDevice({
123
+ userId: actor.userId,
124
+ deviceId: decodeURIComponent(deviceRevoke[1]),
125
+ actorDeviceId: actor.deviceId
126
+ });
127
+ return json({ device: publicDevice(device) });
128
+ }
129
+ if (request.method === "POST" && url.pathname === "/api/realtime-token") {
130
+ if (!actor.deviceId)
131
+ throw new HostedAgentPagerError("device authentication is required for realtime tokens", 401);
132
+ return json({ realtime: await this.createRealtimeSession(actor.userId) });
133
+ }
134
+ if (request.method === "GET" && url.pathname === "/api/me") {
135
+ const [profile, friends, pages, deliveries] = await Promise.all([
136
+ this.profileFor(actor.userId),
137
+ this.listFriends(actor.userId),
138
+ this.listPages(actor.userId, 25),
139
+ this.listDeliveries(actor, "pending")
140
+ ]);
141
+ return json({ profile, friends, pages, pendingDeliveries: deliveries.map(publicDelivery) });
142
+ }
143
+ if (request.method === "GET" && url.pathname === "/api/settings") {
144
+ return json({ settings: publicSettings(await this.service.getSettings(actor.userId)) });
145
+ }
146
+ if (request.method === "POST" && url.pathname === "/api/settings") {
147
+ const body = parseJsonObject(bodyText);
148
+ const settings = await this.service.updateSettings({
149
+ userId: actor.userId,
150
+ presenceVisibility: optionalStringBody(body.presenceVisibility ?? body.presence_visibility),
151
+ defaultDeliveryMode: optionalStringBody(body.defaultDeliveryMode ?? body.default_delivery_mode),
152
+ quietHours: body.quietHours === undefined ? body.quiet_hours : body.quietHours,
153
+ maxPagesPerFriendPerMinute: optionalNumberBody(body.maxPagesPerFriendPerMinute ?? body.max_pages_per_friend_per_minute)
154
+ });
155
+ return json({ settings: publicSettings(settings) });
156
+ }
157
+ if (request.method === "POST" && url.pathname === "/api/presence") {
158
+ if (!actor.deviceId)
159
+ throw new HostedAgentPagerError("device authentication is required for presence", 401);
160
+ const body = parseJsonObject(bodyText);
161
+ await this.service.setPresence({
162
+ userId: actor.userId,
163
+ deviceId: actor.deviceId,
164
+ status: normalizeStatus(body.status),
165
+ reachable: body.reachable === undefined ? true : Boolean(body.reachable),
166
+ activeAdapter: optionalStringBody(body.activeAdapter)
167
+ });
168
+ return json({ ok: true });
169
+ }
170
+ if (request.method === "GET" && url.pathname === "/api/invites") {
171
+ return json({ invites: await this.listInvites(actor.userId) });
172
+ }
173
+ if (request.method === "POST" && url.pathname === "/api/invites") {
174
+ const body = parseJsonObject(bodyText);
175
+ const invite = await this.service.createInvite({
176
+ userId: actor.userId,
177
+ ttlHours: optionalNumberBody(body.ttlHours)
178
+ });
179
+ return json({ invite: publicInvite(invite, this.publicUrl), url: `${this.publicUrl}/invite/${invite.code}` });
180
+ }
181
+ const inviteAccept = url.pathname.match(/^\/api\/invites\/([^/]+)\/accept$/);
182
+ if (request.method === "POST" && inviteAccept) {
183
+ const body = parseJsonObject(bodyText);
184
+ const friendship = await this.service.acceptInvite({
185
+ userId: actor.userId,
186
+ code: inviteAccept[1],
187
+ note: optionalStringBody(body.note)
188
+ });
189
+ const friends = await this.listFriends(actor.userId);
190
+ return json({ friendship, friends });
191
+ }
192
+ const inviteRevoke = url.pathname.match(/^\/api\/invites\/([^/]+)\/revoke$/);
193
+ if (request.method === "POST" && inviteRevoke) {
194
+ const invite = await this.service.revokeInvite({
195
+ userId: actor.userId,
196
+ code: decodeURIComponent(inviteRevoke[1])
197
+ });
198
+ return json({ invite: publicInvite(invite, this.publicUrl) });
199
+ }
200
+ if (request.method === "GET" && url.pathname === "/api/friends") {
201
+ return json({ friends: await this.listFriends(actor.userId) });
202
+ }
203
+ const friendRemove = url.pathname.match(/^\/api\/friends\/([^/]+)\/remove$/);
204
+ if (request.method === "POST" && friendRemove) {
205
+ const result = await this.service.removeFriend({
206
+ ownerUserId: actor.userId,
207
+ friendUsername: decodeURIComponent(friendRemove[1])
208
+ });
209
+ return json({ removedFriend: profileSummary(result.removedFriend) });
210
+ }
211
+ if (request.method === "GET" && url.pathname === "/api/mutes") {
212
+ return json({ mutes: await this.listMutes(actor.userId) });
213
+ }
214
+ if (request.method === "GET" && url.pathname === "/api/blocks") {
215
+ return json({ blocks: await this.listBlocks(actor.userId) });
216
+ }
217
+ if (request.method === "POST" && url.pathname === "/api/blocks") {
218
+ const body = parseJsonObject(bodyText);
219
+ const result = await this.service.blockUser({
220
+ ownerUserId: actor.userId,
221
+ blockedUsername: stringBody(body.username || body.friend)
222
+ });
223
+ return json({ blockedUser: profileSummary(result.blockedUser) });
224
+ }
225
+ const blockUnblock = url.pathname.match(/^\/api\/blocks\/([^/]+)\/unblock$/);
226
+ if (request.method === "POST" && blockUnblock) {
227
+ const result = await this.service.unblockUser({
228
+ ownerUserId: actor.userId,
229
+ blockedUsername: decodeURIComponent(blockUnblock[1])
230
+ });
231
+ return json({ unblockedUser: profileSummary(result.unblockedUser) });
232
+ }
233
+ if (request.method === "POST" && url.pathname === "/api/mutes") {
234
+ const body = parseJsonObject(bodyText);
235
+ const result = await this.service.muteFriend({
236
+ ownerUserId: actor.userId,
237
+ mutedUsername: stringBody(body.friend || body.username),
238
+ durationMs: parseDurationMs(optionalStringBody(body.duration) || optionalStringBody(body.for)),
239
+ reason: optionalStringBody(body.reason)
240
+ });
241
+ return json({
242
+ mutedUntil: result.mutedUntil,
243
+ mutedUser: {
244
+ id: result.mutedUser.id,
245
+ username: result.mutedUser.username,
246
+ displayName: result.mutedUser.display_name
247
+ }
248
+ });
249
+ }
250
+ const muteUnmute = url.pathname.match(/^\/api\/mutes\/([^/]+)\/unmute$/);
251
+ if (request.method === "POST" && muteUnmute) {
252
+ const result = await this.service.unmuteFriend({
253
+ ownerUserId: actor.userId,
254
+ mutedUsername: decodeURIComponent(muteUnmute[1])
255
+ });
256
+ return json({ unmutedUser: profileSummary(result.unmutedUser) });
257
+ }
258
+ if (request.method === "GET" && url.pathname === "/api/friend-requests") {
259
+ return json({ friendRequests: await this.listFriendRequests(actor.userId) });
260
+ }
261
+ if (request.method === "POST" && url.pathname === "/api/friend-requests") {
262
+ const body = parseJsonObject(bodyText);
263
+ const request = await this.service.createFriendRequest({
264
+ fromUserId: actor.userId,
265
+ toUsername: stringBody(body.toUsername || body.to),
266
+ note: optionalStringBody(body.note)
267
+ });
268
+ return json({ friendRequest: request });
269
+ }
270
+ const friendRequestResolve = url.pathname.match(/^\/api\/friend-requests\/([^/]+)\/(approve|deny|cancel)$/);
271
+ if (request.method === "POST" && friendRequestResolve) {
272
+ const decision = friendRequestResolve[2] === "approve"
273
+ ? "approved"
274
+ : friendRequestResolve[2] === "deny"
275
+ ? "denied"
276
+ : "canceled";
277
+ const result = await this.service.resolveFriendRequest({
278
+ userId: actor.userId,
279
+ requestId: friendRequestResolve[1],
280
+ decision
281
+ });
282
+ return json(result);
283
+ }
284
+ if (request.method === "POST" && url.pathname === "/api/pages") {
285
+ const body = parseJsonObject(bodyText);
286
+ const result = await this.service.sendPage({
287
+ fromUserId: actor.userId,
288
+ toUsername: stringBody(body.toUsername || body.to),
289
+ message: stringBody(body.message),
290
+ urgency: normalizeUrgency(body.urgency),
291
+ replyToPageId: optionalStringBody(body.replyToPageId)
292
+ });
293
+ return json({
294
+ page: result.envelope.page,
295
+ deliveries: result.deliveries.map(publicDeliverySummary)
296
+ });
297
+ }
298
+ if (request.method === "GET" && url.pathname === "/api/pages") {
299
+ return json({ pages: await this.listPages(actor.userId, 100) });
300
+ }
301
+ if (request.method === "GET" && url.pathname === "/api/deliveries") {
302
+ const status = optionalStringBody(url.searchParams.get("status"));
303
+ return json({ deliveries: (await this.listDeliveries(actor, status)).map(publicDelivery) });
304
+ }
305
+ if (request.method === "GET" && url.pathname === "/api/audit-events") {
306
+ return json({ auditEvents: await this.listAuditEvents(actor.userId) });
307
+ }
308
+ if (request.method === "GET" && url.pathname === "/api/abuse-reports") {
309
+ return json({ abuseReports: await this.listAbuseReports(actor.userId) });
310
+ }
311
+ if (request.method === "POST" && url.pathname === "/api/abuse-reports") {
312
+ const body = parseJsonObject(bodyText);
313
+ const report = await this.service.createAbuseReport({
314
+ reporterUserId: actor.userId,
315
+ reportedUsername: optionalStringBody(body.username || body.reportedUsername || body.friend),
316
+ pageId: optionalStringBody(body.pageId || body.page_id),
317
+ reason: stringBody(body.reason),
318
+ details: optionalStringBody(body.details)
319
+ });
320
+ return json({ abuseReport: publicAbuseReport(report) });
321
+ }
322
+ const deliveryAck = url.pathname.match(/^\/api\/deliveries\/([^/]+)\/ack$/);
323
+ if (request.method === "POST" && deliveryAck) {
324
+ await this.service.ackDelivery({
325
+ deliveryId: deliveryAck[1],
326
+ userId: actor.userId,
327
+ deviceId: actor.deviceId
328
+ });
329
+ return json({ ok: true });
330
+ }
331
+ return json({ error: "not found" }, 404);
332
+ }
333
+ catch (error) {
334
+ if (error instanceof HostedAgentPagerError) {
335
+ return json({ error: error.message }, error.status);
336
+ }
337
+ return json({ error: error instanceof Error ? error.message : "request failed" }, 500);
338
+ }
339
+ }
340
+ async authenticate(request, url, bodyText) {
341
+ const deviceId = request.headers.get("x-ap-device-id");
342
+ if (deviceId)
343
+ return this.authenticateDevice(request, url, bodyText, deviceId);
344
+ return this.authenticateBearer(request);
345
+ }
346
+ async authenticateBearer(request) {
347
+ const authorization = request.headers.get("authorization") || "";
348
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
349
+ if (!match)
350
+ throw new HostedAgentPagerError("bearer token is required", 401);
351
+ const { data, error } = await this.options.authSupabase.auth.getUser(match[1]);
352
+ if (error || !data.user)
353
+ throw new HostedAgentPagerError("invalid bearer token", 401);
354
+ return { kind: "bearer", userId: data.user.id };
355
+ }
356
+ async authenticateDevice(request, url, bodyText, deviceId) {
357
+ const timestamp = request.headers.get("x-ap-timestamp") || "";
358
+ const signature = request.headers.get("x-ap-signature") || "";
359
+ const timestampMs = Date.parse(timestamp);
360
+ if (!signature || !Number.isFinite(timestampMs)) {
361
+ throw new HostedAgentPagerError("device signature is required", 401);
362
+ }
363
+ if (Math.abs(Date.now() - timestampMs) > SIGNATURE_MAX_AGE_MS) {
364
+ throw new HostedAgentPagerError("device signature timestamp is stale", 401);
365
+ }
366
+ const { data: device, error } = await this.options.supabase
367
+ .from("devices")
368
+ .select()
369
+ .eq("id", deviceId)
370
+ .is("revoked_at", null)
371
+ .maybeSingle();
372
+ if (error)
373
+ throw new HostedAgentPagerError(error.message, 500);
374
+ if (!device)
375
+ throw new HostedAgentPagerError("device not found", 401);
376
+ const pathAndQuery = signedPathAndQuery(url);
377
+ const canonical = canonicalRequest(request.method, pathAndQuery, timestamp, bodyText);
378
+ if (!verifyText(device.public_key_pem, canonical, signature)) {
379
+ throw new HostedAgentPagerError("invalid device signature", 401);
380
+ }
381
+ await this.options.supabase
382
+ .from("devices")
383
+ .update({ last_seen_at: nowIso() })
384
+ .eq("id", device.id);
385
+ return { kind: "device", userId: device.user_id, deviceId: device.id, device };
386
+ }
387
+ async profileFor(userId) {
388
+ const { data, error } = await this.options.supabase
389
+ .from("profiles")
390
+ .select("id,username,display_name,avatar_url,public_profile_enabled,default_status,created_at,updated_at")
391
+ .eq("id", userId)
392
+ .maybeSingle();
393
+ if (error)
394
+ throw new HostedAgentPagerError(error.message, 500);
395
+ return data;
396
+ }
397
+ async publicProfileByUsername(username) {
398
+ const { data, error } = await this.options.supabase
399
+ .from("profiles")
400
+ .select("id,username,display_name,avatar_url,public_profile_enabled,created_at")
401
+ .eq("username", cleanUsername(username))
402
+ .eq("public_profile_enabled", true)
403
+ .maybeSingle();
404
+ if (error)
405
+ throw new HostedAgentPagerError(error.message, 500);
406
+ if (!data)
407
+ return null;
408
+ const { count, error: friendCountError } = await this.options.supabase
409
+ .from("friendships")
410
+ .select("id", { count: "exact", head: true })
411
+ .or(`user_a_id.eq.${data.id},user_b_id.eq.${data.id}`)
412
+ .is("revoked_at", null);
413
+ if (friendCountError)
414
+ throw new HostedAgentPagerError(friendCountError.message, 500);
415
+ return {
416
+ username: data.username,
417
+ displayName: data.display_name,
418
+ avatarUrl: data.avatar_url,
419
+ friendCount: count || 0,
420
+ profileUrl: `${this.publicUrl}/@${data.username}`
421
+ };
422
+ }
423
+ async listDevices(userId) {
424
+ const { data, error } = await this.options.supabase
425
+ .from("devices")
426
+ .select("id,name,public_key_fingerprint,platform,app_version,created_at,last_seen_at,revoked_at")
427
+ .eq("user_id", userId)
428
+ .order("created_at", { ascending: false });
429
+ if (error)
430
+ throw new HostedAgentPagerError(error.message, 500);
431
+ return data.map(publicDevice);
432
+ }
433
+ async listInvites(userId) {
434
+ const { data, error } = await this.options.supabase
435
+ .from("friend_invites")
436
+ .select()
437
+ .eq("created_by_user_id", userId)
438
+ .order("created_at", { ascending: false })
439
+ .limit(100);
440
+ if (error)
441
+ throw new HostedAgentPagerError(error.message, 500);
442
+ return data.map((invite) => publicInvite(invite, this.publicUrl));
443
+ }
444
+ async listFriends(userId) {
445
+ const { data: friendships, error } = await this.options.supabase
446
+ .from("friendships")
447
+ .select("id,user_a_id,user_b_id,created_at")
448
+ .or(`user_a_id.eq.${userId},user_b_id.eq.${userId}`)
449
+ .is("revoked_at", null)
450
+ .order("created_at", { ascending: false });
451
+ if (error)
452
+ throw new HostedAgentPagerError(error.message, 500);
453
+ const friendIds = friendships.map((friendship) => friendship.user_a_id === userId ? friendship.user_b_id : friendship.user_a_id);
454
+ if (!friendIds.length)
455
+ return [];
456
+ const [profiles, presence] = await Promise.all([
457
+ this.profilesByIds(friendIds),
458
+ this.presenceByUserIds(friendIds)
459
+ ]);
460
+ return friendIds.map((friendId) => {
461
+ const profile = profiles.get(friendId);
462
+ const visiblePresence = presence.get(friendId);
463
+ return {
464
+ id: friendId,
465
+ username: profile?.username || "unknown",
466
+ displayName: profile?.display_name || profile?.username || "Unknown",
467
+ status: visiblePresence?.status || "offline",
468
+ reachable: visiblePresence?.reachable || false,
469
+ lastSeenAt: visiblePresence?.last_seen_at || null
470
+ };
471
+ });
472
+ }
473
+ async listFriendRequests(userId) {
474
+ const { data, error } = await this.options.supabase
475
+ .from("friend_requests")
476
+ .select("id,from_user_id,to_user_id,note,status,created_at,resolved_at")
477
+ .or(`from_user_id.eq.${userId},to_user_id.eq.${userId}`)
478
+ .order("created_at", { ascending: false })
479
+ .limit(100);
480
+ if (error)
481
+ throw new HostedAgentPagerError(error.message, 500);
482
+ if (!data.length)
483
+ return [];
484
+ const userIds = [...new Set(data.flatMap((request) => [request.from_user_id, request.to_user_id]))];
485
+ const profiles = await this.profilesByIds(userIds);
486
+ return data.map((request) => ({
487
+ id: request.id,
488
+ direction: request.to_user_id === userId ? "incoming" : "outgoing",
489
+ status: request.status,
490
+ note: request.note,
491
+ createdAt: request.created_at,
492
+ resolvedAt: request.resolved_at,
493
+ from: profileSummary(profiles.get(request.from_user_id)),
494
+ to: profileSummary(profiles.get(request.to_user_id))
495
+ }));
496
+ }
497
+ async listMutes(userId) {
498
+ const { data, error } = await this.options.supabase
499
+ .from("mutes")
500
+ .select("muted_user_id,muted_until,reason,created_at")
501
+ .eq("owner_user_id", userId)
502
+ .order("created_at", { ascending: false });
503
+ if (error)
504
+ throw new HostedAgentPagerError(error.message, 500);
505
+ if (!data.length)
506
+ return [];
507
+ const profiles = await this.profilesByIds(data.map((mute) => mute.muted_user_id));
508
+ return data.map((mute) => ({
509
+ mutedUntil: mute.muted_until,
510
+ reason: mute.reason,
511
+ createdAt: mute.created_at,
512
+ user: profileSummary(profiles.get(mute.muted_user_id))
513
+ }));
514
+ }
515
+ async listBlocks(userId) {
516
+ const { data, error } = await this.options.supabase
517
+ .from("blocks")
518
+ .select("blocked_user_id,created_at")
519
+ .eq("owner_user_id", userId)
520
+ .order("created_at", { ascending: false });
521
+ if (error)
522
+ throw new HostedAgentPagerError(error.message, 500);
523
+ if (!data.length)
524
+ return [];
525
+ const profiles = await this.profilesByIds(data.map((block) => block.blocked_user_id));
526
+ return data.map((block) => ({
527
+ createdAt: block.created_at,
528
+ user: profileSummary(profiles.get(block.blocked_user_id))
529
+ }));
530
+ }
531
+ async listPages(userId, limit) {
532
+ const { data: pages, error } = await this.options.supabase
533
+ .from("pages")
534
+ .select()
535
+ .or(`from_user_id.eq.${userId},to_user_id.eq.${userId}`)
536
+ .order("created_at", { ascending: false })
537
+ .limit(limit);
538
+ if (error)
539
+ throw new HostedAgentPagerError(error.message, 500);
540
+ if (!pages.length)
541
+ return [];
542
+ const userIds = [...new Set(pages.flatMap((page) => [page.from_user_id, page.to_user_id]))];
543
+ const profiles = await this.profilesByIds(userIds);
544
+ return pages.map((page) => ({
545
+ ...page,
546
+ fromUsername: profiles.get(page.from_user_id)?.username || "unknown",
547
+ toUsername: profiles.get(page.to_user_id)?.username || "unknown"
548
+ }));
549
+ }
550
+ async listDeliveries(actor, status) {
551
+ let query = this.options.supabase
552
+ .from("page_deliveries")
553
+ .select()
554
+ .eq("recipient_user_id", actor.userId)
555
+ .order("created_at", { ascending: false })
556
+ .limit(100);
557
+ if (actor.deviceId) {
558
+ query = query.or(`device_id.eq.${actor.deviceId},device_id.is.null`);
559
+ }
560
+ if (status) {
561
+ query = query.eq("status", status);
562
+ }
563
+ const { data, error } = await query;
564
+ if (error)
565
+ throw new HostedAgentPagerError(error.message, 500);
566
+ return data;
567
+ }
568
+ async listAuditEvents(userId) {
569
+ const { data, error } = await this.options.supabase
570
+ .from("audit_events")
571
+ .select("id,action,target_type,target_id,metadata,created_at,actor_user_id,target_user_id")
572
+ .or(`actor_user_id.eq.${userId},target_user_id.eq.${userId}`)
573
+ .order("created_at", { ascending: false })
574
+ .limit(100);
575
+ if (error)
576
+ throw new HostedAgentPagerError(error.message, 500);
577
+ return data.map((event) => ({
578
+ id: event.id,
579
+ action: event.action,
580
+ targetType: event.target_type,
581
+ targetId: event.target_id,
582
+ metadata: event.metadata,
583
+ createdAt: event.created_at
584
+ }));
585
+ }
586
+ async listAbuseReports(userId) {
587
+ const { data, error } = await this.options.supabase
588
+ .from("abuse_reports")
589
+ .select()
590
+ .eq("reporter_user_id", userId)
591
+ .order("created_at", { ascending: false })
592
+ .limit(100);
593
+ if (error)
594
+ throw new HostedAgentPagerError(error.message, 500);
595
+ return data.map(publicAbuseReport);
596
+ }
597
+ async profilesByIds(userIds) {
598
+ if (!userIds.length)
599
+ return new Map();
600
+ const { data, error } = await this.options.supabase
601
+ .from("profiles")
602
+ .select("id,username,display_name")
603
+ .in("id", userIds);
604
+ if (error)
605
+ throw new HostedAgentPagerError(error.message, 500);
606
+ return new Map(data.map((profile) => [profile.id, profile]));
607
+ }
608
+ async presenceByUserIds(userIds) {
609
+ if (!userIds.length)
610
+ return new Map();
611
+ const { data, error } = await this.options.supabase
612
+ .from("presence")
613
+ .select("user_id,status,reachable,last_seen_at")
614
+ .in("user_id", userIds)
615
+ .order("last_seen_at", { ascending: false });
616
+ if (error)
617
+ throw new HostedAgentPagerError(error.message, 500);
618
+ const byUser = new Map();
619
+ for (const row of data) {
620
+ const existing = byUser.get(row.user_id);
621
+ if (!existing || (!existing.reachable && row.reachable)) {
622
+ byUser.set(row.user_id, {
623
+ status: row.status,
624
+ reachable: row.reachable,
625
+ last_seen_at: row.last_seen_at
626
+ });
627
+ }
628
+ }
629
+ return byUser;
630
+ }
631
+ async createRealtimeSession(userId) {
632
+ const { data: userData, error: userError } = await this.options.supabase.auth.admin.getUserById(userId);
633
+ const email = userData.user?.email;
634
+ if (userError || !email) {
635
+ throw new HostedAgentPagerError(userError?.message || "paired user email is required for realtime auth", 500);
636
+ }
637
+ const { data: linkData, error: linkError } = await this.options.supabase.auth.admin.generateLink({
638
+ type: "magiclink",
639
+ email
640
+ });
641
+ const tokenHash = linkData.properties?.hashed_token;
642
+ if (linkError || !tokenHash) {
643
+ throw new HostedAgentPagerError(linkError?.message || "failed to create realtime auth link", 500);
644
+ }
645
+ const { data: sessionData, error: sessionError } = await this.options.authSupabase.auth.verifyOtp({
646
+ token_hash: tokenHash,
647
+ type: "email"
648
+ });
649
+ const session = sessionData.session;
650
+ if (sessionError || !session?.access_token || session.user?.id !== userId) {
651
+ throw new HostedAgentPagerError(sessionError?.message || "failed to create realtime auth session", 500);
652
+ }
653
+ return {
654
+ supabaseUrl: this.options.supabaseUrl,
655
+ supabaseAnonKey: this.options.supabaseAnonKey,
656
+ accessToken: session.access_token,
657
+ expiresAt: session.expires_at
658
+ ? new Date(session.expires_at * 1000).toISOString()
659
+ : new Date(Date.now() + Math.max(60, session.expires_in || 3600) * 1000).toISOString()
660
+ };
661
+ }
662
+ }
663
+ export function createHostedHttpHandler(options) {
664
+ return new HostedHttpHandler(options);
665
+ }
666
+ export function createHostedHttpHandlerFromEnv(env = process.env) {
667
+ const config = loadSupabaseRuntimeConfig(env);
668
+ const serverPrivateKeyPem = pemFromEnv(env.AGENT_PAGER_SERVER_PRIVATE_KEY_PEM);
669
+ const serverPublicKeyPem = pemFromEnv(env.AGENT_PAGER_SERVER_PUBLIC_KEY_PEM);
670
+ if (!serverPrivateKeyPem || !serverPublicKeyPem) {
671
+ throw new Error("Missing AGENT_PAGER_SERVER_PRIVATE_KEY_PEM and AGENT_PAGER_SERVER_PUBLIC_KEY_PEM.");
672
+ }
673
+ return createHostedHttpHandler({
674
+ supabase: createSupabaseServiceClient(config),
675
+ authSupabase: createSupabaseUserClient(config),
676
+ serverPrivateKeyPem,
677
+ serverPublicKeyPem,
678
+ supabaseUrl: config.url,
679
+ supabaseAnonKey: config.anonKey,
680
+ publicUrl: env.NEXT_PUBLIC_AGENT_PAGER_URL || env.AGENT_PAGER_PUBLIC_URL
681
+ });
682
+ }
683
+ function parseJsonObject(bodyText) {
684
+ if (!bodyText.trim())
685
+ return {};
686
+ const parsed = JSON.parse(bodyText);
687
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
688
+ throw new HostedAgentPagerError("JSON object body is required", 400);
689
+ }
690
+ return parsed;
691
+ }
692
+ function publicDelivery(delivery) {
693
+ return {
694
+ id: delivery.id,
695
+ pageId: delivery.page_id,
696
+ deviceId: delivery.device_id,
697
+ status: delivery.status,
698
+ channel: delivery.delivery_channel,
699
+ envelope: delivery.envelope,
700
+ serverSignature: delivery.server_signature,
701
+ createdAt: delivery.created_at,
702
+ deliveredAt: delivery.delivered_at,
703
+ ackedAt: delivery.acked_at
704
+ };
705
+ }
706
+ function publicDeliverySummary(delivery) {
707
+ return {
708
+ id: delivery.id,
709
+ deviceId: delivery.device_id,
710
+ status: delivery.status,
711
+ createdAt: delivery.created_at
712
+ };
713
+ }
714
+ function publicPairing(pairing) {
715
+ return {
716
+ code: pairing.code,
717
+ expiresAt: pairing.expires_at,
718
+ approvedAt: pairing.approved_at,
719
+ deviceId: pairing.device_id,
720
+ consumedAt: pairing.consumed_at
721
+ };
722
+ }
723
+ function publicInvite(invite, publicUrl) {
724
+ return {
725
+ code: invite.code,
726
+ url: `${publicUrl}/invite/${invite.code}`,
727
+ createdAt: invite.created_at,
728
+ expiresAt: invite.expires_at,
729
+ acceptedAt: invite.accepted_at,
730
+ acceptedByUserId: invite.accepted_by_user_id,
731
+ revokedAt: invite.revoked_at,
732
+ useCount: invite.use_count,
733
+ maxUses: invite.max_uses,
734
+ status: invite.revoked_at
735
+ ? "revoked"
736
+ : invite.accepted_by_user_id || invite.use_count >= invite.max_uses
737
+ ? "used"
738
+ : Date.parse(invite.expires_at) < Date.now()
739
+ ? "expired"
740
+ : "active"
741
+ };
742
+ }
743
+ function publicSettings(settings) {
744
+ return {
745
+ presenceVisibility: settings.presence_visibility,
746
+ defaultDeliveryMode: settings.default_delivery_mode,
747
+ quietHours: settings.quiet_hours,
748
+ maxPagesPerFriendPerMinute: settings.max_pages_per_friend_per_minute,
749
+ createdAt: settings.created_at,
750
+ updatedAt: settings.updated_at
751
+ };
752
+ }
753
+ function publicAbuseReport(report) {
754
+ return {
755
+ id: report.id,
756
+ reportedUserId: report.reported_user_id,
757
+ pageId: report.page_id,
758
+ reason: report.reason,
759
+ details: report.details,
760
+ status: report.status,
761
+ createdAt: report.created_at,
762
+ reviewedAt: report.reviewed_at
763
+ };
764
+ }
765
+ function signedPathAndQuery(url) {
766
+ const params = new URLSearchParams(url.searchParams);
767
+ for (const key of VERCEL_ROUTE_QUERY_KEYS)
768
+ params.delete(key);
769
+ const query = params.toString();
770
+ return `${url.pathname}${query ? `?${query}` : ""}`;
771
+ }
772
+ function publicDevice(device) {
773
+ return {
774
+ id: device.id,
775
+ name: device.name,
776
+ fingerprint: device.public_key_fingerprint,
777
+ platform: device.platform,
778
+ appVersion: device.app_version,
779
+ createdAt: device.created_at,
780
+ lastSeenAt: device.last_seen_at,
781
+ revokedAt: device.revoked_at
782
+ };
783
+ }
784
+ function json(body, status = 200) {
785
+ return new Response(JSON.stringify(body), {
786
+ status,
787
+ headers: {
788
+ ...CORS_HEADERS,
789
+ "content-type": "application/json; charset=utf-8"
790
+ }
791
+ });
792
+ }
793
+ function empty(status) {
794
+ return new Response(null, { status, headers: CORS_HEADERS });
795
+ }
796
+ function stringBody(value) {
797
+ return String(value || "");
798
+ }
799
+ function optionalStringBody(value) {
800
+ if (value === undefined || value === null || value === "")
801
+ return undefined;
802
+ return String(value);
803
+ }
804
+ function optionalNumberBody(value) {
805
+ if (value === undefined || value === null || value === "")
806
+ return undefined;
807
+ const number = Number(value);
808
+ return Number.isFinite(number) ? number : undefined;
809
+ }
810
+ function parseDurationMs(value) {
811
+ if (!value || value === "forever" || value === "indefinite")
812
+ return null;
813
+ const match = value.trim().toLowerCase().match(/^(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)$/);
814
+ if (!match)
815
+ throw new HostedAgentPagerError("duration must look like 15m, 2h, 1d, or forever", 400);
816
+ const amount = Number(match[1]);
817
+ const unit = match[2][0];
818
+ if (unit === "m")
819
+ return amount * 60_000;
820
+ if (unit === "h")
821
+ return amount * 60 * 60_000;
822
+ return amount * 24 * 60 * 60_000;
823
+ }
824
+ function cleanUsername(value) {
825
+ return String(value || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 31);
826
+ }
827
+ function profileSummary(profile) {
828
+ if (!profile) {
829
+ return { id: "", username: "unknown", displayName: "Unknown" };
830
+ }
831
+ return {
832
+ id: profile.id,
833
+ username: profile.username,
834
+ displayName: profile.display_name
835
+ };
836
+ }
837
+ function normalizeUrgency(value) {
838
+ return ["gentle", "normal", "firm", "professionally-dramatic"].includes(String(value))
839
+ ? String(value)
840
+ : "normal";
841
+ }
842
+ function normalizeStatus(value) {
843
+ return ["online", "busy", "muted", "offline"].includes(String(value))
844
+ ? String(value)
845
+ : "online";
846
+ }
847
+ function pemFromEnv(value) {
848
+ return (value || "").replace(/\\n/g, "\n");
849
+ }