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,1038 @@
|
|
|
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
|
+
}
|