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
package/web/app.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.108.2";
|
|
2
|
+
|
|
3
|
+
const state = {
|
|
4
|
+
view: "console",
|
|
5
|
+
mode: "loading",
|
|
6
|
+
config: null,
|
|
7
|
+
supabase: null,
|
|
8
|
+
session: null,
|
|
9
|
+
me: null,
|
|
10
|
+
abuseReports: [],
|
|
11
|
+
blocks: [],
|
|
12
|
+
devices: [],
|
|
13
|
+
invites: [],
|
|
14
|
+
mutes: [],
|
|
15
|
+
pages: [],
|
|
16
|
+
settings: null,
|
|
17
|
+
friendRequests: [],
|
|
18
|
+
auditEvents: [],
|
|
19
|
+
routeUsername: "",
|
|
20
|
+
publicProfile: null,
|
|
21
|
+
prototype: null,
|
|
22
|
+
notice: ""
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const els = {};
|
|
26
|
+
for (const id of [
|
|
27
|
+
"abuse-reports-list",
|
|
28
|
+
"audit-list",
|
|
29
|
+
"auth-email",
|
|
30
|
+
"auth-password",
|
|
31
|
+
"auth-username",
|
|
32
|
+
"auth-name",
|
|
33
|
+
"auth-panel",
|
|
34
|
+
"blocks-list",
|
|
35
|
+
"console-note",
|
|
36
|
+
"device-code",
|
|
37
|
+
"devices-list",
|
|
38
|
+
"friend-code",
|
|
39
|
+
"friend-request-note",
|
|
40
|
+
"friend-requests-list",
|
|
41
|
+
"friends-list",
|
|
42
|
+
"invite-output",
|
|
43
|
+
"invites-list",
|
|
44
|
+
"mutes-list",
|
|
45
|
+
"page-friend",
|
|
46
|
+
"page-message",
|
|
47
|
+
"page-urgency",
|
|
48
|
+
"pages-list",
|
|
49
|
+
"profile-line",
|
|
50
|
+
"profile-request-card",
|
|
51
|
+
"profile-request-title",
|
|
52
|
+
"quiet-enabled",
|
|
53
|
+
"quiet-end",
|
|
54
|
+
"quiet-start",
|
|
55
|
+
"quiet-timezone",
|
|
56
|
+
"rate-limit",
|
|
57
|
+
"security-line",
|
|
58
|
+
"setup-command",
|
|
59
|
+
"status-cloud",
|
|
60
|
+
"status-friends",
|
|
61
|
+
"status-human",
|
|
62
|
+
"status-pending",
|
|
63
|
+
"user-count"
|
|
64
|
+
]) {
|
|
65
|
+
els[id] = document.querySelector(`#${id}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const tab of document.querySelectorAll(".tab")) {
|
|
69
|
+
tab.addEventListener("click", () => {
|
|
70
|
+
state.view = tab.dataset.view;
|
|
71
|
+
renderTabs();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
document.addEventListener("click", async (event) => {
|
|
76
|
+
const action = event.target?.dataset?.action;
|
|
77
|
+
if (!action) return;
|
|
78
|
+
try {
|
|
79
|
+
if (action === "sign-in") await signIn();
|
|
80
|
+
if (action === "sign-up") await signUp();
|
|
81
|
+
if (action === "sign-out") await signOut();
|
|
82
|
+
if (action === "save-profile") await saveProfile();
|
|
83
|
+
if (action === "save-settings") await saveSettings();
|
|
84
|
+
if (action === "create-invite") await createInvite();
|
|
85
|
+
if (action === "revoke-invite") await revokeInvite(event.target.dataset.code);
|
|
86
|
+
if (action === "accept-invite") await acceptInvite();
|
|
87
|
+
if (action === "request-profile") await requestProfileAccess();
|
|
88
|
+
if (action === "approve-friend-request") await resolveFriendRequest(event.target.dataset.requestId, "approve");
|
|
89
|
+
if (action === "deny-friend-request") await resolveFriendRequest(event.target.dataset.requestId, "deny");
|
|
90
|
+
if (action === "cancel-friend-request") await resolveFriendRequest(event.target.dataset.requestId, "cancel");
|
|
91
|
+
if (action === "send-page") await sendPage();
|
|
92
|
+
if (action === "mute-friend") await muteFriend(event.target.dataset.username);
|
|
93
|
+
if (action === "unmute-friend") await unmuteFriend(event.target.dataset.username);
|
|
94
|
+
if (action === "remove-friend") await removeFriend(event.target.dataset.username);
|
|
95
|
+
if (action === "block-friend") await blockFriend(event.target.dataset.username);
|
|
96
|
+
if (action === "report-user") await reportUser(event.target.dataset.username);
|
|
97
|
+
if (action === "report-page") await reportPage(event.target.dataset.pageId);
|
|
98
|
+
if (action === "unblock-user") await unblockUser(event.target.dataset.username);
|
|
99
|
+
if (action === "approve-device") await approveDevice();
|
|
100
|
+
if (action === "revoke-device") await revokeDevice(event.target.dataset.deviceId);
|
|
101
|
+
await refresh();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
setNotice(error instanceof Error ? error.message : "Action failed.");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await init();
|
|
108
|
+
|
|
109
|
+
async function init() {
|
|
110
|
+
fillRouteInputs();
|
|
111
|
+
try {
|
|
112
|
+
const config = await fetchJson("/api/config");
|
|
113
|
+
if (!config.supabaseUrl || !config.supabaseAnonKey) throw new Error("Hosted config unavailable.");
|
|
114
|
+
state.mode = "hosted";
|
|
115
|
+
state.config = config;
|
|
116
|
+
state.supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
|
|
117
|
+
auth: {
|
|
118
|
+
persistSession: true,
|
|
119
|
+
autoRefreshToken: true
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const { data } = await state.supabase.auth.getSession();
|
|
123
|
+
state.session = data.session;
|
|
124
|
+
state.supabase.auth.onAuthStateChange((_event, session) => {
|
|
125
|
+
state.session = session;
|
|
126
|
+
refresh();
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
state.mode = "prototype";
|
|
130
|
+
}
|
|
131
|
+
await refresh();
|
|
132
|
+
setInterval(refresh, 8000);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function refresh() {
|
|
136
|
+
if (state.mode === "prototype") {
|
|
137
|
+
await loadPrototype();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (state.routeUsername) {
|
|
141
|
+
state.publicProfile = await fetchJson(`/api/public/profiles/${encodeURIComponent(state.routeUsername)}`).catch(() => null);
|
|
142
|
+
}
|
|
143
|
+
if (!state.session) {
|
|
144
|
+
state.me = null;
|
|
145
|
+
state.abuseReports = [];
|
|
146
|
+
state.blocks = [];
|
|
147
|
+
state.devices = [];
|
|
148
|
+
state.invites = [];
|
|
149
|
+
state.mutes = [];
|
|
150
|
+
state.pages = [];
|
|
151
|
+
state.settings = null;
|
|
152
|
+
state.friendRequests = [];
|
|
153
|
+
state.auditEvents = [];
|
|
154
|
+
render();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const [me, pages, devices, audit, abuseReports, friendRequests, blocks, mutes, invites, settings] = await Promise.all([
|
|
158
|
+
hostedApi("/api/me"),
|
|
159
|
+
hostedApi("/api/pages"),
|
|
160
|
+
hostedApi("/api/devices"),
|
|
161
|
+
hostedApi("/api/audit-events"),
|
|
162
|
+
hostedApi("/api/abuse-reports"),
|
|
163
|
+
hostedApi("/api/friend-requests"),
|
|
164
|
+
hostedApi("/api/blocks"),
|
|
165
|
+
hostedApi("/api/mutes"),
|
|
166
|
+
hostedApi("/api/invites"),
|
|
167
|
+
hostedApi("/api/settings")
|
|
168
|
+
]);
|
|
169
|
+
state.me = me;
|
|
170
|
+
state.pages = pages.pages || [];
|
|
171
|
+
state.devices = devices.devices || [];
|
|
172
|
+
state.auditEvents = audit.auditEvents || [];
|
|
173
|
+
state.abuseReports = abuseReports.abuseReports || [];
|
|
174
|
+
state.friendRequests = friendRequests.friendRequests || [];
|
|
175
|
+
state.blocks = blocks.blocks || [];
|
|
176
|
+
state.mutes = mutes.mutes || [];
|
|
177
|
+
state.invites = invites.invites || [];
|
|
178
|
+
state.settings = settings.settings || null;
|
|
179
|
+
render();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function loadPrototype() {
|
|
183
|
+
const response = await fetch("/api/public/state");
|
|
184
|
+
if (!response.ok) throw new Error("Agent Pager service is not reachable.");
|
|
185
|
+
state.prototype = await response.json();
|
|
186
|
+
render();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function render() {
|
|
190
|
+
renderTabs();
|
|
191
|
+
if (state.mode === "prototype") {
|
|
192
|
+
renderPrototype();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const signedIn = Boolean(state.session);
|
|
197
|
+
const profile = state.me?.profile;
|
|
198
|
+
const friends = state.me?.friends || [];
|
|
199
|
+
const requests = state.friendRequests || [];
|
|
200
|
+
const pending = state.me?.pendingDeliveries || [];
|
|
201
|
+
const username = profile?.username || state.session?.user?.email || "not signed in";
|
|
202
|
+
|
|
203
|
+
els["auth-panel"].classList.toggle("is-signed-in", signedIn);
|
|
204
|
+
els["profile-line"].textContent = signedIn ? `Signed in as ${username}` : "Signed out";
|
|
205
|
+
els["status-human"].textContent = username;
|
|
206
|
+
els["status-cloud"].textContent = state.config?.publicUrl || "hosted API";
|
|
207
|
+
els["status-friends"].textContent = `${friends.length} approved`;
|
|
208
|
+
els["status-pending"].textContent = `${pending.length} pending`;
|
|
209
|
+
els["user-count"].textContent = signedIn ? "1 human signed in" : "No active human";
|
|
210
|
+
els["security-line"].textContent = state.notice || "Friend-gated, signed, and rate-limited.";
|
|
211
|
+
els["setup-command"].textContent = "npm install -g agent-pager\nagent-pager login\nagent-pager start";
|
|
212
|
+
renderPublicProfileCard();
|
|
213
|
+
renderSettings();
|
|
214
|
+
|
|
215
|
+
els["friends-list"].innerHTML = friends.map((friend) => `
|
|
216
|
+
<article class="item">
|
|
217
|
+
<span class="light ${friend.reachable ? "online" : ""}"></span>
|
|
218
|
+
<div>
|
|
219
|
+
<h3>@${escapeHtml(friend.username)} · ${escapeHtml(friend.status)}</h3>
|
|
220
|
+
<p>${friend.reachable ? "Reachable" : "Not reachable"}${friend.lastSeenAt ? ` · ${formatTime(friend.lastSeenAt)}` : ""}</p>
|
|
221
|
+
<div class="button-row">
|
|
222
|
+
<button data-action="mute-friend" data-username="${escapeHtml(friend.username)}">Mute 1h</button>
|
|
223
|
+
<button data-action="remove-friend" data-username="${escapeHtml(friend.username)}">Remove</button>
|
|
224
|
+
<button data-action="report-user" data-username="${escapeHtml(friend.username)}">Report</button>
|
|
225
|
+
<button data-action="block-friend" data-username="${escapeHtml(friend.username)}">Block</button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</article>
|
|
229
|
+
`).join("") || empty("No approved friends yet.", "Create or accept an invite.");
|
|
230
|
+
|
|
231
|
+
els["invites-list"].innerHTML = state.invites.map((invite) => {
|
|
232
|
+
const actions = invite.status === "active"
|
|
233
|
+
? `<div class="button-row">
|
|
234
|
+
<button data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke invite</button>
|
|
235
|
+
</div>`
|
|
236
|
+
: "";
|
|
237
|
+
return `
|
|
238
|
+
<article class="item">
|
|
239
|
+
<span class="light ${invite.status === "active" ? "online" : ""}"></span>
|
|
240
|
+
<div>
|
|
241
|
+
<h3>Invite · ${escapeHtml(invite.status)}</h3>
|
|
242
|
+
<p>${escapeHtml(invite.url)} · expires ${formatTime(invite.expiresAt)}</p>
|
|
243
|
+
${actions}
|
|
244
|
+
</div>
|
|
245
|
+
</article>
|
|
246
|
+
`;
|
|
247
|
+
}).join("");
|
|
248
|
+
|
|
249
|
+
els["blocks-list"].innerHTML = state.blocks.map((block) => `
|
|
250
|
+
<article class="item">
|
|
251
|
+
<span class="light"></span>
|
|
252
|
+
<div>
|
|
253
|
+
<h3>Blocked · @${escapeHtml(block.user.username)}</h3>
|
|
254
|
+
<p>${formatTime(block.createdAt)}</p>
|
|
255
|
+
<div class="button-row">
|
|
256
|
+
<button data-action="unblock-user" data-username="${escapeHtml(block.user.username)}">Unblock</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</article>
|
|
260
|
+
`).join("");
|
|
261
|
+
|
|
262
|
+
els["mutes-list"].innerHTML = state.mutes.map((mute) => `
|
|
263
|
+
<article class="item">
|
|
264
|
+
<span class="light"></span>
|
|
265
|
+
<div>
|
|
266
|
+
<h3>Muted · @${escapeHtml(mute.user.username)}</h3>
|
|
267
|
+
<p>${mute.mutedUntil ? `Until ${formatTime(mute.mutedUntil)}` : "Forever"}${mute.reason ? ` · ${escapeHtml(mute.reason)}` : ""}</p>
|
|
268
|
+
<div class="button-row">
|
|
269
|
+
<button data-action="unmute-friend" data-username="${escapeHtml(mute.user.username)}">Unmute</button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</article>
|
|
273
|
+
`).join("");
|
|
274
|
+
|
|
275
|
+
els["friend-requests-list"].innerHTML = requests.map((request) => {
|
|
276
|
+
const other = request.direction === "incoming" ? request.from : request.to;
|
|
277
|
+
const actions = request.status === "pending" && request.direction === "incoming"
|
|
278
|
+
? `<div class="button-row">
|
|
279
|
+
<button data-action="approve-friend-request" data-request-id="${escapeHtml(request.id)}">Approve</button>
|
|
280
|
+
<button data-action="deny-friend-request" data-request-id="${escapeHtml(request.id)}">Deny</button>
|
|
281
|
+
</div>`
|
|
282
|
+
: request.status === "pending" && request.direction === "outgoing"
|
|
283
|
+
? `<div class="button-row">
|
|
284
|
+
<button data-action="cancel-friend-request" data-request-id="${escapeHtml(request.id)}">Cancel</button>
|
|
285
|
+
</div>`
|
|
286
|
+
: "";
|
|
287
|
+
return `
|
|
288
|
+
<article class="item">
|
|
289
|
+
<span class="light ${request.status === "approved" ? "online" : ""}"></span>
|
|
290
|
+
<div>
|
|
291
|
+
<h3>${escapeHtml(request.direction)} · @${escapeHtml(other.username)} · ${escapeHtml(request.status)}</h3>
|
|
292
|
+
<p>${escapeHtml(request.note || "No note")} · ${formatTime(request.createdAt)}</p>
|
|
293
|
+
${actions}
|
|
294
|
+
</div>
|
|
295
|
+
</article>
|
|
296
|
+
`;
|
|
297
|
+
}).join("") || empty("No friend requests yet.", "Public profile requests appear here.");
|
|
298
|
+
|
|
299
|
+
els["pages-list"].innerHTML = state.pages.map((page) => `
|
|
300
|
+
<article class="item">
|
|
301
|
+
<span class="light ${page.to_user_id === profile?.id ? "online" : ""}"></span>
|
|
302
|
+
<div>
|
|
303
|
+
<h3>${escapeHtml(page.fromUsername)} → ${escapeHtml(page.toUsername)} · ${escapeHtml(page.urgency)}</h3>
|
|
304
|
+
<p>${escapeHtml(page.message)} · ${formatTime(page.created_at)}</p>
|
|
305
|
+
<div class="button-row">
|
|
306
|
+
<button data-action="report-page" data-page-id="${escapeHtml(page.id)}">Report</button>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</article>
|
|
310
|
+
`).join("") || empty("No page traffic yet.", "Approved friends can page each other.");
|
|
311
|
+
|
|
312
|
+
els["devices-list"].innerHTML = state.devices.map((device) => {
|
|
313
|
+
const status = device.revokedAt ? `Revoked ${formatTime(device.revokedAt)}` : "Active";
|
|
314
|
+
const actions = device.revokedAt
|
|
315
|
+
? ""
|
|
316
|
+
: `<div class="button-row">
|
|
317
|
+
<button data-action="revoke-device" data-device-id="${escapeHtml(device.id)}">Revoke</button>
|
|
318
|
+
</div>`;
|
|
319
|
+
return `
|
|
320
|
+
<article class="item">
|
|
321
|
+
<span class="light ${device.revokedAt ? "" : "online"}"></span>
|
|
322
|
+
<div>
|
|
323
|
+
<h3>${escapeHtml(device.name)} · ${status}</h3>
|
|
324
|
+
<p>${escapeHtml(device.platform || "unknown")} · ${escapeHtml(device.fingerprint.slice(0, 16))} · ${device.lastSeenAt ? formatTime(device.lastSeenAt) : "new"}</p>
|
|
325
|
+
${actions}
|
|
326
|
+
</div>
|
|
327
|
+
</article>
|
|
328
|
+
`;
|
|
329
|
+
}).join("") || empty("No paired devices yet.", "Run agent-pager login.");
|
|
330
|
+
|
|
331
|
+
els["audit-list"].innerHTML = state.auditEvents.map((event) => `
|
|
332
|
+
<article class="item">
|
|
333
|
+
<div>
|
|
334
|
+
<h3>${escapeHtml(event.action)}</h3>
|
|
335
|
+
<p>${formatTime(event.createdAt)}${event.targetId ? ` · ${escapeHtml(event.targetId)}` : ""}</p>
|
|
336
|
+
</div>
|
|
337
|
+
</article>
|
|
338
|
+
`).join("") || empty("No security events yet.", "Account activity appears here.");
|
|
339
|
+
|
|
340
|
+
els["abuse-reports-list"].innerHTML = state.abuseReports.map((report) => `
|
|
341
|
+
<article class="item">
|
|
342
|
+
<span class="light ${report.status === "open" ? "" : "online"}"></span>
|
|
343
|
+
<div>
|
|
344
|
+
<h3>Report · ${escapeHtml(report.status)} · ${escapeHtml(report.reason)}</h3>
|
|
345
|
+
<p>${formatTime(report.createdAt)}${report.pageId ? ` · page ${escapeHtml(report.pageId)}` : ""}</p>
|
|
346
|
+
</div>
|
|
347
|
+
</article>
|
|
348
|
+
`).join("") || empty("No abuse reports filed.", "Reports you file appear here.");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderSettings() {
|
|
352
|
+
const settings = state.settings || {};
|
|
353
|
+
const quietHours = quietHoursDefaults(settings.quietHours);
|
|
354
|
+
els["quiet-enabled"].checked = quietHours.enabled;
|
|
355
|
+
els["quiet-start"].value = quietHours.start;
|
|
356
|
+
els["quiet-end"].value = quietHours.end;
|
|
357
|
+
els["quiet-timezone"].value = quietHours.timezone;
|
|
358
|
+
els["rate-limit"].value = settings.maxPagesPerFriendPerMinute || 8;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function renderPrototype() {
|
|
362
|
+
const users = state.prototype?.users || [];
|
|
363
|
+
const pages = state.prototype?.pages || [];
|
|
364
|
+
els["auth-panel"].classList.remove("is-signed-in");
|
|
365
|
+
els["profile-line"].textContent = "Prototype mode";
|
|
366
|
+
els["status-human"].textContent = `${users.length} registered`;
|
|
367
|
+
els["status-cloud"].textContent = state.prototype?.publicUrl || "local service";
|
|
368
|
+
els["status-friends"].textContent = "prototype";
|
|
369
|
+
els["status-pending"].textContent = `${pages.filter((page) => !page.deliveredAt).length} pending`;
|
|
370
|
+
els["user-count"].textContent = `${users.length} human${users.length === 1 ? "" : "s"} registered`;
|
|
371
|
+
els["security-line"].textContent = "Local prototype mode.";
|
|
372
|
+
els["setup-command"].textContent = "npm install -g agent-pager\nagent-pager login --username bryson --local-dev\nagent-pager start";
|
|
373
|
+
renderSettings();
|
|
374
|
+
els["profile-request-card"].classList.add("is-hidden");
|
|
375
|
+
|
|
376
|
+
els["friends-list"].innerHTML = users.map((user) => `
|
|
377
|
+
<article class="item">
|
|
378
|
+
<span class="light ${user.reachable ? "online" : ""}"></span>
|
|
379
|
+
<div>
|
|
380
|
+
<h3>@${escapeHtml(user.username)} · ${escapeHtml(user.status)}</h3>
|
|
381
|
+
<p>${user.reachable ? "Reachable" : "Not reachable"} · ${user.deviceCount} terminal${user.deviceCount === 1 ? "" : "s"}</p>
|
|
382
|
+
</div>
|
|
383
|
+
</article>
|
|
384
|
+
`).join("") || empty("No humans registered yet.", "Start with the setup cards.");
|
|
385
|
+
els["invites-list"].innerHTML = "";
|
|
386
|
+
els["blocks-list"].innerHTML = "";
|
|
387
|
+
els["mutes-list"].innerHTML = "";
|
|
388
|
+
els["abuse-reports-list"].innerHTML = "";
|
|
389
|
+
|
|
390
|
+
els["pages-list"].innerHTML = pages.map((page) => `
|
|
391
|
+
<article class="item">
|
|
392
|
+
<span class="light ${page.deliveredAt ? "online" : ""}"></span>
|
|
393
|
+
<div>
|
|
394
|
+
<h3>${escapeHtml(page.fromUsername)} → ${escapeHtml(page.toUsername)} · ${escapeHtml(page.urgency)}</h3>
|
|
395
|
+
<p>${escapeHtml(page.message)}</p>
|
|
396
|
+
</div>
|
|
397
|
+
</article>
|
|
398
|
+
`).join("") || empty("No page traffic yet.", "Pages will appear here.");
|
|
399
|
+
|
|
400
|
+
els["devices-list"].innerHTML = empty("Hosted device pairing is unavailable in prototype mode.", "Use the hosted API path.");
|
|
401
|
+
els["audit-list"].innerHTML = (state.prototype?.auditEvents || []).map((event) => `
|
|
402
|
+
<article class="item">
|
|
403
|
+
<div>
|
|
404
|
+
<h3>${escapeHtml(event.action)}</h3>
|
|
405
|
+
<p>${new Date(event.at).toLocaleString()}${event.target ? ` · ${escapeHtml(event.target)}` : ""}</p>
|
|
406
|
+
</div>
|
|
407
|
+
</article>
|
|
408
|
+
`).join("") || empty("No security events yet.", "Account activity appears here.");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function signIn() {
|
|
412
|
+
requireSupabase();
|
|
413
|
+
const { error } = await state.supabase.auth.signInWithPassword(authCredentials());
|
|
414
|
+
if (error) throw error;
|
|
415
|
+
setNotice("Signed in.");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function signUp() {
|
|
419
|
+
requireSupabase();
|
|
420
|
+
const { error } = await state.supabase.auth.signUp(authCredentials());
|
|
421
|
+
if (error) throw error;
|
|
422
|
+
setNotice("Account created. Check email confirmation settings if sign-in is blocked.");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function signOut() {
|
|
426
|
+
requireSupabase();
|
|
427
|
+
await state.supabase.auth.signOut();
|
|
428
|
+
setNotice("Signed out.");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function saveProfile() {
|
|
432
|
+
const username = els["auth-username"].value.trim();
|
|
433
|
+
const displayName = els["auth-name"].value.trim() || username;
|
|
434
|
+
if (!username) throw new Error("Username is required.");
|
|
435
|
+
await hostedApi("/api/profiles", {
|
|
436
|
+
method: "POST",
|
|
437
|
+
body: { username, displayName }
|
|
438
|
+
});
|
|
439
|
+
setNotice("Profile saved.");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function saveSettings() {
|
|
443
|
+
const quietHours = {
|
|
444
|
+
enabled: els["quiet-enabled"].checked,
|
|
445
|
+
start: els["quiet-start"].value.trim() || "22:00",
|
|
446
|
+
end: els["quiet-end"].value.trim() || "08:00",
|
|
447
|
+
timezone: els["quiet-timezone"].value.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
|
448
|
+
};
|
|
449
|
+
const maxPagesPerFriendPerMinute = Number(els["rate-limit"].value || 8);
|
|
450
|
+
await hostedApi("/api/settings", {
|
|
451
|
+
method: "POST",
|
|
452
|
+
body: { quietHours, maxPagesPerFriendPerMinute }
|
|
453
|
+
});
|
|
454
|
+
setNotice("Settings saved.");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function createInvite() {
|
|
458
|
+
const result = await hostedApi("/api/invites", { method: "POST", body: {} });
|
|
459
|
+
els["invite-output"].value = result.url;
|
|
460
|
+
setNotice("Invite created.");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function revokeInvite(code) {
|
|
464
|
+
if (!code) throw new Error("Invite code is missing.");
|
|
465
|
+
await hostedApi(`/api/invites/${encodeURIComponent(code)}/revoke`, { method: "POST", body: {} });
|
|
466
|
+
setNotice("Invite revoked.");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function acceptInvite() {
|
|
470
|
+
const code = extractCode(els["friend-code"].value);
|
|
471
|
+
if (!code) throw new Error("Invite code is required.");
|
|
472
|
+
await hostedApi(`/api/invites/${encodeURIComponent(code)}/accept`, { method: "POST", body: {} });
|
|
473
|
+
setNotice("Friend added.");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function requestProfileAccess() {
|
|
477
|
+
const username = state.publicProfile?.profile?.username || state.routeUsername;
|
|
478
|
+
if (!username) throw new Error("No public profile selected.");
|
|
479
|
+
await hostedApi("/api/friend-requests", {
|
|
480
|
+
method: "POST",
|
|
481
|
+
body: {
|
|
482
|
+
toUsername: username,
|
|
483
|
+
note: els["friend-request-note"].value.trim()
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
setNotice(`Requested access to @${username}.`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function resolveFriendRequest(id, action) {
|
|
490
|
+
if (!id) throw new Error("Friend request id is missing.");
|
|
491
|
+
await hostedApi(`/api/friend-requests/${encodeURIComponent(id)}/${action}`, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
body: {}
|
|
494
|
+
});
|
|
495
|
+
const labels = {
|
|
496
|
+
approve: "approved",
|
|
497
|
+
deny: "denied",
|
|
498
|
+
cancel: "canceled"
|
|
499
|
+
};
|
|
500
|
+
setNotice(`Friend request ${labels[action] || "resolved"}.`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function sendPage() {
|
|
504
|
+
const to = els["page-friend"].value.trim();
|
|
505
|
+
const message = els["page-message"].value.trim();
|
|
506
|
+
const urgency = els["page-urgency"].value;
|
|
507
|
+
if (!to || !message) throw new Error("Friend and message are required.");
|
|
508
|
+
await hostedApi("/api/pages", { method: "POST", body: { to, message, urgency } });
|
|
509
|
+
els["page-message"].value = "";
|
|
510
|
+
setNotice("Page sent.");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function muteFriend(username) {
|
|
514
|
+
if (!username) throw new Error("Username is missing.");
|
|
515
|
+
await hostedApi("/api/mutes", { method: "POST", body: { friend: username, duration: "1h", reason: "web console" } });
|
|
516
|
+
setNotice(`Muted @${username} for 1h.`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function unmuteFriend(username) {
|
|
520
|
+
if (!username) throw new Error("Username is missing.");
|
|
521
|
+
await hostedApi(`/api/mutes/${encodeURIComponent(username)}/unmute`, { method: "POST", body: {} });
|
|
522
|
+
setNotice(`Unmuted @${username}.`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function removeFriend(username) {
|
|
526
|
+
if (!username) throw new Error("Username is missing.");
|
|
527
|
+
await hostedApi(`/api/friends/${encodeURIComponent(username)}/remove`, { method: "POST", body: {} });
|
|
528
|
+
setNotice(`Removed @${username}. They cannot page you unless friendship is approved again.`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function blockFriend(username) {
|
|
532
|
+
if (!username) throw new Error("Username is missing.");
|
|
533
|
+
await hostedApi("/api/blocks", { method: "POST", body: { username } });
|
|
534
|
+
setNotice(`Blocked @${username}.`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function reportUser(username) {
|
|
538
|
+
if (!username) throw new Error("Username is missing.");
|
|
539
|
+
await hostedApi("/api/abuse-reports", { method: "POST", body: { username, reason: "reported-user" } });
|
|
540
|
+
setNotice(`Report filed for @${username}.`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function reportPage(id) {
|
|
544
|
+
if (!id) throw new Error("Page id is missing.");
|
|
545
|
+
await hostedApi("/api/abuse-reports", { method: "POST", body: { pageId: id, reason: "reported-page" } });
|
|
546
|
+
setNotice("Report filed.");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function unblockUser(username) {
|
|
550
|
+
if (!username) throw new Error("Username is missing.");
|
|
551
|
+
await hostedApi(`/api/blocks/${encodeURIComponent(username)}/unblock`, { method: "POST", body: {} });
|
|
552
|
+
setNotice(`Unblocked @${username}.`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function approveDevice() {
|
|
556
|
+
const code = extractCode(els["device-code"].value);
|
|
557
|
+
if (!code) throw new Error("Pairing code is required.");
|
|
558
|
+
await hostedApi(`/api/device-pairings/${encodeURIComponent(code)}/approve`, { method: "POST", body: {} });
|
|
559
|
+
setNotice("Device approved.");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function revokeDevice(id) {
|
|
563
|
+
if (!id) throw new Error("Device id is missing.");
|
|
564
|
+
await hostedApi(`/api/devices/${encodeURIComponent(id)}/revoke`, { method: "POST", body: {} });
|
|
565
|
+
setNotice("Device revoked.");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function hostedApi(path, options = {}) {
|
|
569
|
+
if (!state.session?.access_token) throw new Error("Sign in first.");
|
|
570
|
+
const response = await fetch(path, {
|
|
571
|
+
method: options.method || "GET",
|
|
572
|
+
headers: {
|
|
573
|
+
authorization: `Bearer ${state.session.access_token}`,
|
|
574
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" })
|
|
575
|
+
},
|
|
576
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
|
577
|
+
});
|
|
578
|
+
const text = await response.text();
|
|
579
|
+
const json = text ? JSON.parse(text) : null;
|
|
580
|
+
if (!response.ok) throw new Error(json?.error || `${response.status} ${response.statusText}`);
|
|
581
|
+
return json;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function fetchJson(path) {
|
|
585
|
+
const response = await fetch(path);
|
|
586
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
587
|
+
return response.json();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function authCredentials() {
|
|
591
|
+
const email = els["auth-email"].value.trim();
|
|
592
|
+
const password = els["auth-password"].value;
|
|
593
|
+
if (!email || !password) throw new Error("Email and password are required.");
|
|
594
|
+
return { email, password };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function fillRouteInputs() {
|
|
598
|
+
const path = location.pathname;
|
|
599
|
+
if (path.startsWith("/invite/")) {
|
|
600
|
+
els["friend-code"].value = decodeURIComponent(path.split("/").filter(Boolean).pop() || "");
|
|
601
|
+
state.view = "friends";
|
|
602
|
+
}
|
|
603
|
+
if (path.startsWith("/@")) {
|
|
604
|
+
state.routeUsername = decodeURIComponent(path.slice(2)).trim().toLowerCase();
|
|
605
|
+
state.view = "friends";
|
|
606
|
+
}
|
|
607
|
+
if (path.startsWith("/app/devices/pair")) {
|
|
608
|
+
els["device-code"].value = new URL(location.href).searchParams.get("code") || "";
|
|
609
|
+
state.view = "devices";
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function renderPublicProfileCard() {
|
|
614
|
+
const card = els["profile-request-card"];
|
|
615
|
+
if (!state.routeUsername) {
|
|
616
|
+
card.classList.add("is-hidden");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
card.classList.remove("is-hidden");
|
|
620
|
+
if (!state.publicProfile?.profile) {
|
|
621
|
+
els["profile-request-title"].textContent = `@${state.routeUsername}`;
|
|
622
|
+
els["friend-request-note"].placeholder = "Profile not found or not public.";
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const profile = state.publicProfile.profile;
|
|
626
|
+
els["profile-request-title"].textContent = `Page my agent: @${profile.username}`;
|
|
627
|
+
els["friend-request-note"].placeholder = `Ask ${profile.displayName} for access`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function renderTabs() {
|
|
631
|
+
for (const tab of document.querySelectorAll(".tab")) {
|
|
632
|
+
tab.classList.toggle("is-active", tab.dataset.view === state.view);
|
|
633
|
+
}
|
|
634
|
+
for (const panel of document.querySelectorAll("[data-panel]")) {
|
|
635
|
+
panel.classList.toggle("is-hidden", panel.dataset.panel !== state.view);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function empty(title, detail) {
|
|
640
|
+
return `<article class="item"><div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(detail)}</p></div></article>`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function quietHoursDefaults(value) {
|
|
644
|
+
const object = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
645
|
+
return {
|
|
646
|
+
enabled: object.enabled === true,
|
|
647
|
+
start: typeof object.start === "string" ? object.start : "22:00",
|
|
648
|
+
end: typeof object.end === "string" ? object.end : "08:00",
|
|
649
|
+
timezone: typeof object.timezone === "string" ? object.timezone : "UTC"
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function extractCode(value) {
|
|
654
|
+
return String(value || "").split("?")[0].split("/").filter(Boolean).pop()?.trim().toUpperCase() || "";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function setNotice(message) {
|
|
658
|
+
state.notice = message;
|
|
659
|
+
render();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function requireSupabase() {
|
|
663
|
+
if (!state.supabase) throw new Error("Hosted Supabase config is not available.");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function formatTime(value) {
|
|
667
|
+
return new Date(value).toLocaleString();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function escapeHtml(value) {
|
|
671
|
+
return String(value ?? "")
|
|
672
|
+
.replaceAll("&", "&")
|
|
673
|
+
.replaceAll("<", "<")
|
|
674
|
+
.replaceAll(">", ">")
|
|
675
|
+
.replaceAll('"', """);
|
|
676
|
+
}
|