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.
- package/README.md +169 -0
- package/dist/src/cli.js +1110 -0
- package/dist/src/crypto.js +45 -0
- package/dist/src/delivery.js +51 -0
- package/dist/src/hosted-http.js +849 -0
- package/dist/src/hosted-service.js +1038 -0
- package/dist/src/hosted-vercel.js +51 -0
- package/dist/src/http-client.js +28 -0
- package/dist/src/local-config.js +29 -0
- package/dist/src/mcp.js +341 -0
- package/dist/src/render.js +34 -0
- package/dist/src/server.js +441 -0
- package/dist/src/session-adapters.js +23 -0
- package/dist/src/setup.js +90 -0
- package/dist/src/store.js +52 -0
- package/dist/src/supabase.js +32 -0
- package/dist/src/supabase.types.js +13 -0
- package/dist/src/types.js +1 -0
- package/package.json +82 -0
- package/web/app.js +676 -0
- package/web/index.html +199 -0
- package/web/pager-terminal.png +0 -0
- package/web/styles.css +421 -0
|
@@ -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
|
+
}
|