@takosjp/yurucommu-core 3.0.3 → 3.2.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.en.md +92 -0
- package/README.md +56 -47
- package/migrations/0019_notification_push_delivery.sql +100 -0
- package/migrations/README.md +7 -6
- package/package.json +7 -4
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +534 -0
- package/packages/api/src/lib/api/communities.ts +25 -2
- package/packages/api/src/lib/api/dm.ts +14 -2
- package/packages/api/src/lib/api/notifications.ts +53 -1
- package/packages/api/src/lib/api.ts +1 -0
- package/packages/api/src/social-server.ts +2 -0
- package/packages/api/src/types/index.ts +39 -0
- package/src/backend/index.ts +29 -0
- package/src/backend/lib/delivery/queue.ts +18 -0
- package/src/backend/lib/delivery/types.ts +15 -1
- package/src/backend/lib/notification-push.ts +1200 -0
- package/src/backend/lib/notification-pusher-contract.ts +340 -0
- package/src/backend/lib/oauth-providers.ts +7 -6
- package/src/backend/routes/communities/messages.ts +140 -9
- package/src/backend/routes/dm/messages.ts +85 -4
- package/src/backend/routes/notification-pushers.ts +93 -0
- package/src/backend/routes/notifications.ts +50 -0
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/schema/mobile.ts +101 -1
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationPusherProduct,
|
|
3
|
+
NotificationPusherRegistration,
|
|
4
|
+
} from "../../types/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
registerNotificationPusher,
|
|
7
|
+
unregisterNotificationPusher,
|
|
8
|
+
} from "./notifications.ts";
|
|
9
|
+
|
|
10
|
+
export type BrowserNotificationPushState =
|
|
11
|
+
"unsupported" | "unconfigured" | "denied" | "disabled" | "enabled";
|
|
12
|
+
|
|
13
|
+
export interface BrowserNotificationPushConfig {
|
|
14
|
+
readonly product: NotificationPusherProduct;
|
|
15
|
+
readonly appId: string;
|
|
16
|
+
readonly appDisplayName: string;
|
|
17
|
+
/** Origin of the yurucommu-compatible API that owns this registration. */
|
|
18
|
+
readonly serverOrigin: string;
|
|
19
|
+
/** Public stateless gateway notify endpoint. */
|
|
20
|
+
readonly gatewayUrl: string;
|
|
21
|
+
/** Public uncompressed P-256 VAPID key, base64url encoded. */
|
|
22
|
+
readonly vapidPublicKey: string;
|
|
23
|
+
readonly serviceWorkerPath: string;
|
|
24
|
+
readonly scope?: string;
|
|
25
|
+
readonly lang?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface BrowserPushSubscriptionLike {
|
|
29
|
+
readonly endpoint: string;
|
|
30
|
+
readonly options?: {
|
|
31
|
+
readonly applicationServerKey: ArrayBuffer | null;
|
|
32
|
+
};
|
|
33
|
+
unsubscribe(): Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BrowserPushManagerLike {
|
|
37
|
+
getSubscription(): Promise<BrowserPushSubscriptionLike | null>;
|
|
38
|
+
subscribe(options: {
|
|
39
|
+
readonly userVisibleOnly: true;
|
|
40
|
+
readonly applicationServerKey: BufferSource;
|
|
41
|
+
}): Promise<BrowserPushSubscriptionLike>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface BrowserServiceWorkerRegistrationLike {
|
|
45
|
+
readonly pushManager: BrowserPushManagerLike;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BrowserServiceWorkerContainerLike {
|
|
49
|
+
register(
|
|
50
|
+
scriptURL: string,
|
|
51
|
+
options?: RegistrationOptions,
|
|
52
|
+
): Promise<BrowserServiceWorkerRegistrationLike>;
|
|
53
|
+
getRegistration(
|
|
54
|
+
clientURL?: string,
|
|
55
|
+
): Promise<BrowserServiceWorkerRegistrationLike | undefined>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface BrowserNotificationApiLike {
|
|
59
|
+
readonly permission: NotificationPermission;
|
|
60
|
+
requestPermission(): Promise<NotificationPermission>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface BrowserPushStorageLike {
|
|
64
|
+
getItem(key: string): string | null;
|
|
65
|
+
setItem(key: string, value: string): void;
|
|
66
|
+
removeItem(key: string): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BrowserNotificationPushRuntime {
|
|
70
|
+
readonly serviceWorker?: BrowserServiceWorkerContainerLike;
|
|
71
|
+
readonly notification?: BrowserNotificationApiLike;
|
|
72
|
+
readonly storage?: BrowserPushStorageLike;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getBrowserNotificationPushState(
|
|
76
|
+
config: BrowserNotificationPushConfig | null | undefined,
|
|
77
|
+
runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
|
|
78
|
+
): Promise<BrowserNotificationPushState> {
|
|
79
|
+
if (!runtime.serviceWorker || !runtime.notification) return "unsupported";
|
|
80
|
+
const normalized = normalizeBrowserPushConfig(config);
|
|
81
|
+
if (!normalized) return "unconfigured";
|
|
82
|
+
if (runtime.notification.permission === "denied") return "denied";
|
|
83
|
+
const registration = await runtime.serviceWorker.getRegistration();
|
|
84
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
85
|
+
return subscription &&
|
|
86
|
+
subscriptionMatchesConfig(normalized, subscription, runtime)
|
|
87
|
+
? "enabled"
|
|
88
|
+
: "disabled";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function enableBrowserNotificationPush(
|
|
92
|
+
config: BrowserNotificationPushConfig,
|
|
93
|
+
runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
|
|
94
|
+
): Promise<{
|
|
95
|
+
readonly state: BrowserNotificationPushState;
|
|
96
|
+
readonly registration?: NotificationPusherRegistration;
|
|
97
|
+
}> {
|
|
98
|
+
if (!runtime.serviceWorker || !runtime.notification) {
|
|
99
|
+
return { state: "unsupported" };
|
|
100
|
+
}
|
|
101
|
+
const normalized = requireBrowserPushConfig(config);
|
|
102
|
+
const permission =
|
|
103
|
+
runtime.notification.permission === "default"
|
|
104
|
+
? await runtime.notification.requestPermission()
|
|
105
|
+
: runtime.notification.permission;
|
|
106
|
+
if (permission !== "granted") {
|
|
107
|
+
return { state: permission === "denied" ? "denied" : "disabled" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const serviceWorker = await runtime.serviceWorker.register(
|
|
111
|
+
normalized.serviceWorkerPath,
|
|
112
|
+
{ scope: "/" },
|
|
113
|
+
);
|
|
114
|
+
let existing = await serviceWorker.pushManager.getSubscription();
|
|
115
|
+
if (existing && !subscriptionMatchesConfig(normalized, existing, runtime)) {
|
|
116
|
+
await retireBrowserSubscription(normalized, existing, runtime);
|
|
117
|
+
existing = null;
|
|
118
|
+
}
|
|
119
|
+
const subscription =
|
|
120
|
+
existing ??
|
|
121
|
+
(await serviceWorker.pushManager.subscribe({
|
|
122
|
+
userVisibleOnly: true,
|
|
123
|
+
applicationServerKey: normalized.applicationServerKey,
|
|
124
|
+
}));
|
|
125
|
+
const registration = await registerBrowserSubscription(
|
|
126
|
+
normalized,
|
|
127
|
+
subscription,
|
|
128
|
+
);
|
|
129
|
+
storeBrowserPushBinding(normalized, subscription, runtime);
|
|
130
|
+
return { state: "enabled", registration };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rebind an existing browser subscription to the current signed-in actor.
|
|
135
|
+
* This never requests permission and never creates a new subscription.
|
|
136
|
+
*/
|
|
137
|
+
export async function refreshBrowserNotificationPush(
|
|
138
|
+
config: BrowserNotificationPushConfig | null | undefined,
|
|
139
|
+
runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
|
|
140
|
+
): Promise<BrowserNotificationPushState> {
|
|
141
|
+
if (!runtime.serviceWorker || !runtime.notification) return "unsupported";
|
|
142
|
+
const normalized = normalizeBrowserPushConfig(config);
|
|
143
|
+
if (!normalized) return "unconfigured";
|
|
144
|
+
if (runtime.notification.permission !== "granted") {
|
|
145
|
+
return runtime.notification.permission === "denied" ? "denied" : "disabled";
|
|
146
|
+
}
|
|
147
|
+
const serviceWorker = await runtime.serviceWorker.register(
|
|
148
|
+
normalized.serviceWorkerPath,
|
|
149
|
+
{ scope: "/" },
|
|
150
|
+
);
|
|
151
|
+
const subscription = await serviceWorker.pushManager.getSubscription();
|
|
152
|
+
if (!subscription) return "disabled";
|
|
153
|
+
if (!subscriptionMatchesConfig(normalized, subscription, runtime)) {
|
|
154
|
+
await retireBrowserSubscription(normalized, subscription, runtime);
|
|
155
|
+
return "disabled";
|
|
156
|
+
}
|
|
157
|
+
await registerBrowserSubscription(normalized, subscription);
|
|
158
|
+
storeBrowserPushBinding(normalized, subscription, runtime);
|
|
159
|
+
return "enabled";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function disableBrowserNotificationPush(
|
|
163
|
+
config: BrowserNotificationPushConfig,
|
|
164
|
+
runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
|
|
165
|
+
): Promise<BrowserNotificationPushState> {
|
|
166
|
+
if (!runtime.serviceWorker || !runtime.notification) return "unsupported";
|
|
167
|
+
const normalized = requireBrowserPushConfig(config);
|
|
168
|
+
const serviceWorker = await runtime.serviceWorker.getRegistration();
|
|
169
|
+
const subscription = await serviceWorker?.pushManager.getSubscription();
|
|
170
|
+
if (!subscription) {
|
|
171
|
+
clearBrowserPushBinding(normalized, runtime);
|
|
172
|
+
return "disabled";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Explicit disable is privacy-first: always invalidate the local endpoint,
|
|
176
|
+
// even when the host cannot remove its row. The next rejected delivery lets
|
|
177
|
+
// the host clean that stale row.
|
|
178
|
+
await retireBrowserSubscription(normalized, subscription, runtime, true);
|
|
179
|
+
return "disabled";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Invalidate this product's browser endpoint without requiring runtime push
|
|
184
|
+
* configuration. Use this during logout/account teardown so a removed or
|
|
185
|
+
* temporarily unavailable server cannot keep waking a signed-out device.
|
|
186
|
+
*/
|
|
187
|
+
export async function clearBrowserNotificationPush(
|
|
188
|
+
identity: Pick<
|
|
189
|
+
BrowserNotificationPushConfig,
|
|
190
|
+
"product" | "appId" | "serviceWorkerPath"
|
|
191
|
+
>,
|
|
192
|
+
runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
|
|
193
|
+
): Promise<BrowserNotificationPushState> {
|
|
194
|
+
if (!runtime.serviceWorker) return "unsupported";
|
|
195
|
+
const registration = await runtime.serviceWorker.getRegistration();
|
|
196
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
197
|
+
if (subscription) await subscription.unsubscribe();
|
|
198
|
+
clearBrowserPushBindingForIdentity(identity, runtime);
|
|
199
|
+
return "disabled";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function browserPushRuntime(): BrowserNotificationPushRuntime {
|
|
203
|
+
const navigatorValue = globalThis.navigator as Navigator | undefined;
|
|
204
|
+
const notificationValue = globalThis.Notification;
|
|
205
|
+
let storage: BrowserPushStorageLike | undefined;
|
|
206
|
+
try {
|
|
207
|
+
storage = globalThis.localStorage;
|
|
208
|
+
} catch {
|
|
209
|
+
storage = undefined;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
...(navigatorValue?.serviceWorker
|
|
213
|
+
? {
|
|
214
|
+
serviceWorker:
|
|
215
|
+
navigatorValue.serviceWorker as unknown as BrowserServiceWorkerContainerLike,
|
|
216
|
+
}
|
|
217
|
+
: {}),
|
|
218
|
+
...(notificationValue
|
|
219
|
+
? {
|
|
220
|
+
notification:
|
|
221
|
+
notificationValue as unknown as BrowserNotificationApiLike,
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
224
|
+
...(storage ? { storage } : {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
type NormalizedBrowserPushConfig = BrowserNotificationPushConfig & {
|
|
229
|
+
readonly applicationServerKey: BufferSource;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function requireBrowserPushConfig(
|
|
233
|
+
config: BrowserNotificationPushConfig,
|
|
234
|
+
): NormalizedBrowserPushConfig {
|
|
235
|
+
const normalized = normalizeBrowserPushConfig(config);
|
|
236
|
+
if (!normalized) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"Browser notification push requires a valid HTTPS gateway URL, VAPID public key, app id, and root-relative service worker path.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return normalized;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeBrowserPushConfig(
|
|
245
|
+
config: BrowserNotificationPushConfig | null | undefined,
|
|
246
|
+
): NormalizedBrowserPushConfig | null {
|
|
247
|
+
if (!config) return null;
|
|
248
|
+
const appId = config.appId.trim();
|
|
249
|
+
const appDisplayName = config.appDisplayName.trim();
|
|
250
|
+
const serverOrigin = normalizeServerOrigin(config.serverOrigin);
|
|
251
|
+
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl);
|
|
252
|
+
const vapidPublicKey = config.vapidPublicKey.trim();
|
|
253
|
+
const applicationServerKey = decodeVapidPublicKey(vapidPublicKey);
|
|
254
|
+
const serviceWorkerPath = config.serviceWorkerPath.trim();
|
|
255
|
+
if (
|
|
256
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(appId) ||
|
|
257
|
+
!appDisplayName ||
|
|
258
|
+
appDisplayName.length > 255 ||
|
|
259
|
+
!serverOrigin ||
|
|
260
|
+
!gatewayUrl ||
|
|
261
|
+
!applicationServerKey ||
|
|
262
|
+
!serviceWorkerPath.startsWith("/") ||
|
|
263
|
+
serviceWorkerPath.startsWith("//") ||
|
|
264
|
+
serviceWorkerPath.length > 512
|
|
265
|
+
) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
...config,
|
|
270
|
+
appId,
|
|
271
|
+
appDisplayName,
|
|
272
|
+
serverOrigin,
|
|
273
|
+
gatewayUrl,
|
|
274
|
+
vapidPublicKey,
|
|
275
|
+
serviceWorkerPath,
|
|
276
|
+
applicationServerKey,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeServerOrigin(value: string): string | null {
|
|
281
|
+
try {
|
|
282
|
+
const url = new URL(value.trim());
|
|
283
|
+
if (url.username || url.password || url.hash) return null;
|
|
284
|
+
const loopback =
|
|
285
|
+
url.hostname === "localhost" ||
|
|
286
|
+
url.hostname === "127.0.0.1" ||
|
|
287
|
+
url.hostname === "[::1]";
|
|
288
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return url.origin;
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeGatewayUrl(value: string): string | null {
|
|
298
|
+
try {
|
|
299
|
+
const url = new URL(value.trim());
|
|
300
|
+
if (url.username || url.password || url.hash) return null;
|
|
301
|
+
if (url.protocol === "https:") {
|
|
302
|
+
if (url.port && url.port !== "443") return null;
|
|
303
|
+
if (!isPublicHttpsHostname(url.hostname)) return null;
|
|
304
|
+
return url.toString();
|
|
305
|
+
}
|
|
306
|
+
if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return url.toString();
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
316
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
317
|
+
if (
|
|
318
|
+
normalized === "localhost" ||
|
|
319
|
+
normalized.endsWith(".localhost") ||
|
|
320
|
+
normalized === "::1"
|
|
321
|
+
) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
const octets = normalized.split(".").map(Number);
|
|
325
|
+
return (
|
|
326
|
+
octets.length === 4 &&
|
|
327
|
+
octets.every(
|
|
328
|
+
(part) => Number.isInteger(part) && part >= 0 && part <= 255,
|
|
329
|
+
) &&
|
|
330
|
+
octets[0] === 127
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isPublicHttpsHostname(hostname: string): boolean {
|
|
335
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
336
|
+
if (
|
|
337
|
+
!normalized.includes(".") ||
|
|
338
|
+
normalized.endsWith(".localhost") ||
|
|
339
|
+
normalized.endsWith(".local") ||
|
|
340
|
+
normalized.endsWith(".internal") ||
|
|
341
|
+
normalized.endsWith(".home") ||
|
|
342
|
+
normalized.endsWith(".lan")
|
|
343
|
+
) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
const ipv4 = normalized.split(".").map(Number);
|
|
347
|
+
if (
|
|
348
|
+
ipv4.length === 4 &&
|
|
349
|
+
ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
|
|
350
|
+
) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
return !normalized.includes(":");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function decodeVapidPublicKey(value: string): BufferSource | null {
|
|
357
|
+
const normalized = value.trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
358
|
+
if (!normalized || normalized.length > 256) return null;
|
|
359
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
360
|
+
try {
|
|
361
|
+
const binary = globalThis.atob(padded);
|
|
362
|
+
const bytes = Uint8Array.from(binary, (character) =>
|
|
363
|
+
character.charCodeAt(0),
|
|
364
|
+
);
|
|
365
|
+
// Web Push uses an uncompressed P-256 public key.
|
|
366
|
+
return bytes.byteLength === 65 && bytes[0] === 0x04 ? bytes : null;
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function registerBrowserSubscription(
|
|
373
|
+
config: NormalizedBrowserPushConfig,
|
|
374
|
+
subscription: BrowserPushSubscriptionLike,
|
|
375
|
+
): Promise<NotificationPusherRegistration> {
|
|
376
|
+
const registration = await registerNotificationPusher({
|
|
377
|
+
product: config.product,
|
|
378
|
+
...(config.scope ? { scope: config.scope } : {}),
|
|
379
|
+
pusher: {
|
|
380
|
+
kind: "http",
|
|
381
|
+
app_id: config.appId,
|
|
382
|
+
app_display_name: config.appDisplayName,
|
|
383
|
+
pushkey: subscription.endpoint,
|
|
384
|
+
...(config.lang ? { lang: config.lang } : {}),
|
|
385
|
+
data: {
|
|
386
|
+
url: config.gatewayUrl,
|
|
387
|
+
format: "event_id_only",
|
|
388
|
+
provider: "webpush",
|
|
389
|
+
ttl: 60,
|
|
390
|
+
urgency: "normal",
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
return registration;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
type StoredBrowserPushBinding = {
|
|
398
|
+
readonly endpoint: string;
|
|
399
|
+
readonly serverOrigin: string;
|
|
400
|
+
readonly vapidPublicKey: string;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
function browserPushBindingKey(
|
|
404
|
+
identity: Pick<BrowserNotificationPushConfig, "product" | "appId">,
|
|
405
|
+
): string {
|
|
406
|
+
return `yurucommu.browser-push.v1.${identity.product}.${identity.appId}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function readBrowserPushBinding(
|
|
410
|
+
config: NormalizedBrowserPushConfig,
|
|
411
|
+
runtime: BrowserNotificationPushRuntime,
|
|
412
|
+
): StoredBrowserPushBinding | null {
|
|
413
|
+
try {
|
|
414
|
+
const value = runtime.storage?.getItem(browserPushBindingKey(config));
|
|
415
|
+
if (!value || value.length > 4096) return null;
|
|
416
|
+
const parsed = JSON.parse(value) as Partial<StoredBrowserPushBinding>;
|
|
417
|
+
return typeof parsed.endpoint === "string" &&
|
|
418
|
+
typeof parsed.serverOrigin === "string" &&
|
|
419
|
+
typeof parsed.vapidPublicKey === "string"
|
|
420
|
+
? {
|
|
421
|
+
endpoint: parsed.endpoint,
|
|
422
|
+
serverOrigin: parsed.serverOrigin,
|
|
423
|
+
vapidPublicKey: parsed.vapidPublicKey,
|
|
424
|
+
}
|
|
425
|
+
: null;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function storeBrowserPushBinding(
|
|
432
|
+
config: NormalizedBrowserPushConfig,
|
|
433
|
+
subscription: BrowserPushSubscriptionLike,
|
|
434
|
+
runtime: BrowserNotificationPushRuntime,
|
|
435
|
+
): void {
|
|
436
|
+
try {
|
|
437
|
+
runtime.storage?.setItem(
|
|
438
|
+
browserPushBindingKey(config),
|
|
439
|
+
JSON.stringify({
|
|
440
|
+
endpoint: subscription.endpoint,
|
|
441
|
+
serverOrigin: config.serverOrigin,
|
|
442
|
+
vapidPublicKey: config.vapidPublicKey,
|
|
443
|
+
} satisfies StoredBrowserPushBinding),
|
|
444
|
+
);
|
|
445
|
+
} catch {
|
|
446
|
+
// Storage can be disabled independently of Push. The subscription's own
|
|
447
|
+
// applicationServerKey still protects key rotation in that case.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function clearBrowserPushBinding(
|
|
452
|
+
config: NormalizedBrowserPushConfig,
|
|
453
|
+
runtime: BrowserNotificationPushRuntime,
|
|
454
|
+
): void {
|
|
455
|
+
clearBrowserPushBindingForIdentity(config, runtime);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function clearBrowserPushBindingForIdentity(
|
|
459
|
+
identity: Pick<BrowserNotificationPushConfig, "product" | "appId">,
|
|
460
|
+
runtime: BrowserNotificationPushRuntime,
|
|
461
|
+
): void {
|
|
462
|
+
try {
|
|
463
|
+
runtime.storage?.removeItem(browserPushBindingKey(identity));
|
|
464
|
+
} catch {
|
|
465
|
+
// Best-effort metadata cleanup; the Push endpoint itself is authoritative.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function subscriptionMatchesConfig(
|
|
470
|
+
config: NormalizedBrowserPushConfig,
|
|
471
|
+
subscription: BrowserPushSubscriptionLike,
|
|
472
|
+
runtime: BrowserNotificationPushRuntime,
|
|
473
|
+
): boolean {
|
|
474
|
+
const binding = readBrowserPushBinding(config, runtime);
|
|
475
|
+
if (
|
|
476
|
+
binding &&
|
|
477
|
+
(binding.endpoint !== subscription.endpoint ||
|
|
478
|
+
binding.serverOrigin !== config.serverOrigin ||
|
|
479
|
+
binding.vapidPublicKey !== config.vapidPublicKey)
|
|
480
|
+
) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const existingKey = subscription.options?.applicationServerKey;
|
|
485
|
+
if (!existingKey) return binding !== null;
|
|
486
|
+
return equalBufferSources(existingKey, config.applicationServerKey);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function equalBufferSources(left: BufferSource, right: BufferSource): boolean {
|
|
490
|
+
const leftBytes = bufferSourceBytes(left);
|
|
491
|
+
const rightBytes = bufferSourceBytes(right);
|
|
492
|
+
if (leftBytes.byteLength !== rightBytes.byteLength) return false;
|
|
493
|
+
for (let index = 0; index < leftBytes.byteLength; index += 1) {
|
|
494
|
+
if (leftBytes[index] !== rightBytes[index]) return false;
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function bufferSourceBytes(value: BufferSource): Uint8Array {
|
|
500
|
+
return ArrayBuffer.isView(value)
|
|
501
|
+
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
502
|
+
: new Uint8Array(value);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function retireBrowserSubscription(
|
|
506
|
+
config: NormalizedBrowserPushConfig,
|
|
507
|
+
subscription: BrowserPushSubscriptionLike,
|
|
508
|
+
runtime: BrowserNotificationPushRuntime,
|
|
509
|
+
unregisterCurrentServer = false,
|
|
510
|
+
): Promise<void> {
|
|
511
|
+
const binding = readBrowserPushBinding(config, runtime);
|
|
512
|
+
const canUnregisterCurrentServer =
|
|
513
|
+
unregisterCurrentServer ||
|
|
514
|
+
!binding ||
|
|
515
|
+
binding.serverOrigin === config.serverOrigin;
|
|
516
|
+
if (canUnregisterCurrentServer) {
|
|
517
|
+
try {
|
|
518
|
+
await unregisterNotificationPusher({
|
|
519
|
+
product: config.product,
|
|
520
|
+
...(config.scope ? { scope: config.scope } : {}),
|
|
521
|
+
app_id: config.appId,
|
|
522
|
+
pushkey: subscription.endpoint,
|
|
523
|
+
});
|
|
524
|
+
} catch {
|
|
525
|
+
// Local invalidation below is the privacy boundary. A rejected delivery
|
|
526
|
+
// lets the old server remove a stale row when it becomes reachable.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
await subscription.unsubscribe();
|
|
531
|
+
} finally {
|
|
532
|
+
clearBrowserPushBinding(config, runtime);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MediaAttachment } from "../../types/index.ts";
|
|
1
2
|
import { normalizeActor } from "./normalize.ts";
|
|
2
3
|
import {
|
|
3
4
|
apiDelete,
|
|
@@ -47,9 +48,20 @@ export interface CommunityMessage {
|
|
|
47
48
|
icon_url: string | null;
|
|
48
49
|
};
|
|
49
50
|
content: string;
|
|
51
|
+
/** Media attachments (image/video), same shape as post attachments. */
|
|
52
|
+
attachments?: MediaAttachment[];
|
|
50
53
|
created_at: string;
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A member's chat read position (LOCAL-ONLY read receipt). Only local members
|
|
58
|
+
* that opened the chat appear; remote members never report read state.
|
|
59
|
+
*/
|
|
60
|
+
export interface CommunityReadState {
|
|
61
|
+
actor_ap_id: string;
|
|
62
|
+
last_read_at: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
export interface JoinCommunityResult {
|
|
54
66
|
status: "joined" | "pending" | "invite_required";
|
|
55
67
|
}
|
|
@@ -160,7 +172,12 @@ export async function leaveCommunity(identifier: string): Promise<void> {
|
|
|
160
172
|
export async function fetchCommunityMessages(
|
|
161
173
|
identifier: string,
|
|
162
174
|
options?: { limit?: number; before?: string },
|
|
163
|
-
): Promise<{
|
|
175
|
+
): Promise<{
|
|
176
|
+
messages: CommunityMessage[];
|
|
177
|
+
hasMore: boolean;
|
|
178
|
+
/** Per-member read positions (local members only; see CommunityReadState). */
|
|
179
|
+
readStates: CommunityReadState[];
|
|
180
|
+
}> {
|
|
164
181
|
const params = new URLSearchParams();
|
|
165
182
|
if (options?.limit) params.set("limit", String(options.limit));
|
|
166
183
|
if (options?.before) params.set("before", options.before);
|
|
@@ -172,20 +189,26 @@ export async function fetchCommunityMessages(
|
|
|
172
189
|
const data = (await res.json()) as {
|
|
173
190
|
messages?: CommunityMessage[];
|
|
174
191
|
has_more?: boolean;
|
|
192
|
+
read_states?: CommunityReadState[];
|
|
175
193
|
};
|
|
176
194
|
return {
|
|
177
195
|
messages: (data.messages || []).map(normalizeCommunityMessage),
|
|
178
196
|
hasMore: data.has_more ?? false,
|
|
197
|
+
readStates: data.read_states ?? [],
|
|
179
198
|
};
|
|
180
199
|
}
|
|
181
200
|
|
|
182
201
|
export async function sendCommunityMessage(
|
|
183
202
|
identifier: string,
|
|
184
203
|
content: string,
|
|
204
|
+
attachments?: MediaAttachment[],
|
|
185
205
|
): Promise<CommunityMessage> {
|
|
186
206
|
const res = await apiPost(
|
|
187
207
|
`/api/communities/${encodeURIComponent(identifier)}/messages`,
|
|
188
|
-
{
|
|
208
|
+
{
|
|
209
|
+
content,
|
|
210
|
+
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
211
|
+
},
|
|
189
212
|
);
|
|
190
213
|
await assertOk(res, "Failed to send message");
|
|
191
214
|
const data = (await res.json()) as { message: CommunityMessage };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DMMessage } from "../../types/index.ts";
|
|
1
|
+
import type { DMMessage, MediaAttachment } from "../../types/index.ts";
|
|
2
2
|
import { normalizeActor } from "./normalize.ts";
|
|
3
3
|
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
4
|
|
|
@@ -110,6 +110,12 @@ export async function fetchUserDMMessages(
|
|
|
110
110
|
messages: DMMessage[];
|
|
111
111
|
conversation_id: string | null;
|
|
112
112
|
hasMore: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The partner's last-read time (LOCAL-ONLY read receipt), or null when
|
|
115
|
+
* unknown — a remote partner never reports read state, so null must render
|
|
116
|
+
* as "no receipt", not "unread".
|
|
117
|
+
*/
|
|
118
|
+
partnerLastReadAt: string | null;
|
|
113
119
|
}> {
|
|
114
120
|
const params = new URLSearchParams();
|
|
115
121
|
if (options?.limit) params.set("limit", String(options.limit));
|
|
@@ -122,21 +128,27 @@ export async function fetchUserDMMessages(
|
|
|
122
128
|
messages?: DMMessage[];
|
|
123
129
|
conversation_id?: string | null;
|
|
124
130
|
has_more?: boolean;
|
|
131
|
+
partner_last_read_at?: string | null;
|
|
125
132
|
};
|
|
126
133
|
return {
|
|
127
134
|
messages: (data.messages || []).map(normalizeDmMessage),
|
|
128
135
|
conversation_id: data.conversation_id ?? null,
|
|
129
136
|
hasMore: data.has_more ?? false,
|
|
137
|
+
partnerLastReadAt: data.partner_last_read_at ?? null,
|
|
130
138
|
};
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
export async function sendUserDMMessage(
|
|
134
142
|
userApId: string,
|
|
135
143
|
content: string,
|
|
144
|
+
attachments?: MediaAttachment[],
|
|
136
145
|
): Promise<{ message: DMMessage; conversation_id: string }> {
|
|
137
146
|
const res = await apiPost(
|
|
138
147
|
`/api/dm/user/${encodeURIComponent(userApId)}/messages`,
|
|
139
|
-
{
|
|
148
|
+
{
|
|
149
|
+
content,
|
|
150
|
+
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
151
|
+
},
|
|
140
152
|
);
|
|
141
153
|
await assertOk(res, "Failed to send message");
|
|
142
154
|
const data = (await res.json()) as {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Notification,
|
|
3
|
+
NotificationPusherInput,
|
|
4
|
+
NotificationPusherProduct,
|
|
5
|
+
NotificationPusherRegistration,
|
|
6
|
+
} from "../../types/index.ts";
|
|
2
7
|
import { normalizeNotification } from "./normalize.ts";
|
|
3
8
|
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
9
|
|
|
@@ -59,3 +64,50 @@ export async function archiveAllNotifications(): Promise<number> {
|
|
|
59
64
|
const data = (await res.json()) as { archived_count?: number };
|
|
60
65
|
return data.archived_count ?? 0;
|
|
61
66
|
}
|
|
67
|
+
|
|
68
|
+
export async function registerNotificationPusher(input: {
|
|
69
|
+
product: NotificationPusherProduct;
|
|
70
|
+
scope?: string;
|
|
71
|
+
pusher: NotificationPusherInput;
|
|
72
|
+
}): Promise<NotificationPusherRegistration> {
|
|
73
|
+
const res = await apiPost("/api/notifications/pushers", input);
|
|
74
|
+
await assertOk(res, "Failed to register notification pusher");
|
|
75
|
+
const data = (await res.json()) as {
|
|
76
|
+
pusher: NotificationPusherRegistration;
|
|
77
|
+
};
|
|
78
|
+
return data.pusher;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function unregisterNotificationPusher(input: {
|
|
82
|
+
product: NotificationPusherProduct;
|
|
83
|
+
scope?: string;
|
|
84
|
+
app_id: string;
|
|
85
|
+
pushkey: string;
|
|
86
|
+
}): Promise<void> {
|
|
87
|
+
const res = await apiDelete("/api/notifications/pushers", input);
|
|
88
|
+
await assertOk(res, "Failed to unregister notification pusher");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface NotificationPusherPublicConfig {
|
|
92
|
+
readonly enabled: boolean;
|
|
93
|
+
readonly gateway_url: string | null;
|
|
94
|
+
readonly web_push_public_key: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Non-secret runtime configuration used by browser/PWA clients. */
|
|
98
|
+
export async function fetchNotificationPusherPublicConfig(): Promise<NotificationPusherPublicConfig> {
|
|
99
|
+
const res = await apiFetch("/api/notifications/pushers/config");
|
|
100
|
+
await assertOk(res, "Failed to load notification pusher configuration");
|
|
101
|
+
const value = (await res.json()) as Partial<NotificationPusherPublicConfig>;
|
|
102
|
+
const gatewayUrl =
|
|
103
|
+
typeof value.gateway_url === "string" ? value.gateway_url : null;
|
|
104
|
+
const publicKey =
|
|
105
|
+
typeof value.web_push_public_key === "string"
|
|
106
|
+
? value.web_push_public_key
|
|
107
|
+
: null;
|
|
108
|
+
return {
|
|
109
|
+
enabled: Boolean(gatewayUrl && publicKey),
|
|
110
|
+
gateway_url: gatewayUrl,
|
|
111
|
+
web_push_public_key: publicKey,
|
|
112
|
+
};
|
|
113
|
+
}
|