@takosjp/yurucommu-core 3.0.3 → 3.2.1

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.
Files changed (40) hide show
  1. package/README.en.md +92 -0
  2. package/README.md +56 -47
  3. package/migrations/0019_notification_push_delivery.sql +103 -0
  4. package/migrations/README.md +7 -6
  5. package/package.json +11 -5
  6. package/packages/api/package.json +1 -1
  7. package/packages/api/src/lib/api/browser-push.ts +545 -0
  8. package/packages/api/src/lib/api/communities.ts +25 -2
  9. package/packages/api/src/lib/api/dm.ts +14 -2
  10. package/packages/api/src/lib/api/normalize.ts +15 -4
  11. package/packages/api/src/lib/api/notification-target.ts +106 -0
  12. package/packages/api/src/lib/api/notifications.ts +53 -1
  13. package/packages/api/src/lib/api/push-config.ts +132 -0
  14. package/packages/api/src/lib/api.ts +3 -0
  15. package/packages/api/src/social-server.ts +9 -0
  16. package/packages/api/src/types/index.ts +48 -0
  17. package/src/backend/index.ts +67 -3
  18. package/src/backend/lib/attachments.ts +52 -0
  19. package/src/backend/lib/delivery/queue.ts +73 -0
  20. package/src/backend/lib/delivery/types.ts +15 -1
  21. package/src/backend/lib/notification-eligibility.ts +150 -0
  22. package/src/backend/lib/notification-push.ts +1213 -0
  23. package/src/backend/lib/notification-pusher-contract.ts +340 -0
  24. package/src/backend/lib/oauth-providers.ts +7 -6
  25. package/src/backend/lib/session-actor.ts +16 -1
  26. package/src/backend/lib/unread-counts.ts +79 -0
  27. package/src/backend/middleware/csrf.ts +11 -0
  28. package/src/backend/routes/account-teardown.ts +13 -0
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +124 -2
  31. package/src/backend/routes/communities/messages.ts +123 -9
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +51 -4
  34. package/src/backend/routes/notification-pushers.ts +93 -0
  35. package/src/backend/routes/notifications.ts +76 -69
  36. package/src/backend/routes/posts/transformers.ts +8 -10
  37. package/src/backend/server.ts +6 -0
  38. package/src/backend/types.ts +12 -0
  39. package/src/db/index.ts +11 -10
  40. package/src/db/schema/mobile.ts +99 -1
@@ -0,0 +1,106 @@
1
+ import type {
2
+ Notification,
3
+ NotificationTargetKind,
4
+ } from "../../types/index.ts";
5
+
6
+ export interface NotificationTarget {
7
+ readonly target_kind: NotificationTargetKind;
8
+ readonly target_id: string | null;
9
+ /**
10
+ * Same-origin in-app path shaped for the yurucommu web client's routing.
11
+ * Other clients should prefer `target_kind` + `target_id` and build their own
12
+ * path. Never treat this as an external URL.
13
+ */
14
+ readonly target_url: string;
15
+ }
16
+
17
+ /** True if the string contains any C0 control character (0x00-0x1f). */
18
+ function hasControlChar(value: string): boolean {
19
+ for (let index = 0; index < value.length; index += 1) {
20
+ if (value.charCodeAt(index) < 0x20) return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * Guard an in-app navigation path against open-redirect / protocol-relative /
27
+ * control-character abuse. Returns the value only if it is a plain same-origin
28
+ * absolute path (`/...`, not `//...`), else null. Promoted from the yurucommu
29
+ * and yurume web clients, which each carried an identical copy.
30
+ */
31
+ export function safeNotificationPath(
32
+ value: string | null | undefined,
33
+ ): string | null {
34
+ if (
35
+ !value ||
36
+ !value.startsWith("/") ||
37
+ value.startsWith("//") ||
38
+ value.includes("\\") ||
39
+ hasControlChar(value)
40
+ ) {
41
+ return null;
42
+ }
43
+ return value;
44
+ }
45
+
46
+ function isStoryNotification(notification: Notification): boolean {
47
+ return (
48
+ notification.target_kind === "story" ||
49
+ !!notification.object_ap_id?.includes("/ap/stories/")
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Resolve the navigation target for a notification. Prefers the server-provided
55
+ * `target_*` fields (3.2.0+) after re-validating `target_url`; when a server
56
+ * older than 3.2.0 omitted them, synthesizes the same mapping the server uses
57
+ * from `type` + `object_ap_id`. Always returns a safe same-origin path.
58
+ */
59
+ export function resolveNotificationTarget(
60
+ notification: Notification,
61
+ ): NotificationTarget {
62
+ const declaredKind = notification.target_kind;
63
+ const declaredUrl = safeNotificationPath(notification.target_url);
64
+ if (declaredKind && declaredUrl) {
65
+ return {
66
+ target_kind: declaredKind,
67
+ target_id: notification.target_id ?? null,
68
+ target_url: declaredUrl,
69
+ };
70
+ }
71
+
72
+ if (
73
+ notification.type === "follow" ||
74
+ notification.type === "follow_request"
75
+ ) {
76
+ const actorApId = notification.actor?.ap_id ?? null;
77
+ return {
78
+ target_kind: "profile",
79
+ target_id: actorApId,
80
+ target_url: actorApId
81
+ ? `/profile/${encodeURIComponent(actorApId)}`
82
+ : "/notifications",
83
+ };
84
+ }
85
+
86
+ const objectApId = notification.object_ap_id;
87
+ if (objectApId && isStoryNotification(notification)) {
88
+ return {
89
+ target_kind: "story",
90
+ target_id: objectApId,
91
+ target_url: `/?story=${encodeURIComponent(objectApId)}`,
92
+ };
93
+ }
94
+ if (objectApId) {
95
+ return {
96
+ target_kind: "post",
97
+ target_id: objectApId,
98
+ target_url: `/post/${encodeURIComponent(objectApId)}`,
99
+ };
100
+ }
101
+ return {
102
+ target_kind: "notifications",
103
+ target_id: null,
104
+ target_url: "/notifications",
105
+ };
106
+ }
@@ -1,4 +1,9 @@
1
- import type { Notification } from "../../types/index.ts";
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
+ }
@@ -0,0 +1,132 @@
1
+ import type { NotificationPusherProduct } from "../../types/index.ts";
2
+ import {
3
+ clearBrowserNotificationPush,
4
+ disableBrowserNotificationPush,
5
+ type BrowserNotificationPushConfig,
6
+ } from "./browser-push.ts";
7
+ import { fetchNotificationPusherPublicConfig } from "./notifications.ts";
8
+
9
+ /**
10
+ * Stable identity of a browser/PWA push client. Everything here is a per-app
11
+ * constant; only the gateway URL, VAPID key, and server origin are resolved at
12
+ * runtime.
13
+ */
14
+ export interface BrowserPushClientIdentity {
15
+ readonly product: NotificationPusherProduct;
16
+ readonly appId: string;
17
+ readonly appDisplayName: string;
18
+ /** Root-relative service worker script path (e.g. "/notification-push-sw.js"). */
19
+ readonly serviceWorkerPath: string;
20
+ readonly scope?: string;
21
+ readonly lang?: string;
22
+ }
23
+
24
+ /** Non-secret runtime gateway values, from a fetch or build-time fallback. */
25
+ export interface BrowserPushRuntimeValues {
26
+ readonly gatewayUrl?: string | null;
27
+ readonly vapidPublicKey?: string | null;
28
+ }
29
+
30
+ export interface BrowserPushConfigResolverOptions {
31
+ readonly identity: BrowserPushClientIdentity;
32
+ /**
33
+ * Origin of the yurucommu-compatible API that owns the registration. Returns
34
+ * null when it cannot be determined (e.g. SSR, or an unconfigured shell), in
35
+ * which case config resolution yields null.
36
+ */
37
+ readonly resolveServerOrigin: () => string | null;
38
+ /**
39
+ * Build-time fallback used only while a server rolls forward to 3.2.0; a
40
+ * responding runtime config always wins.
41
+ */
42
+ readonly buildTimeValues?: () => BrowserPushRuntimeValues | null;
43
+ }
44
+
45
+ export interface BrowserPushConfigResolver {
46
+ /** Build config from build-time values only (no network). */
47
+ readonly buildTimeConfig: () => BrowserNotificationPushConfig | null;
48
+ /**
49
+ * Resolve config from the server's runtime pusher config, falling back to
50
+ * build-time values if the server is unreachable or has push disabled.
51
+ */
52
+ readonly resolveConfig: () => Promise<BrowserNotificationPushConfig | null>;
53
+ /**
54
+ * Invalidate this device's endpoint before sign-out / account teardown,
55
+ * disabling via the resolved config when possible and always clearing the
56
+ * local subscription so a signed-out device is never woken.
57
+ */
58
+ readonly clearBeforeSignOut: () => Promise<void>;
59
+ }
60
+
61
+ /**
62
+ * Build a browser push config resolver for a single client identity. Promoted
63
+ * from the near-identical per-app resolvers in the yurucommu and yurume web
64
+ * clients so the fetch/fallback/clear flow lives in one place.
65
+ */
66
+ export function createBrowserPushConfigResolver(
67
+ options: BrowserPushConfigResolverOptions,
68
+ ): BrowserPushConfigResolver {
69
+ const { identity, resolveServerOrigin, buildTimeValues } = options;
70
+
71
+ const build = (
72
+ values: BrowserPushRuntimeValues | null | undefined,
73
+ ): BrowserNotificationPushConfig | null => {
74
+ const gatewayUrl = values?.gatewayUrl?.trim();
75
+ const vapidPublicKey = values?.vapidPublicKey?.trim();
76
+ if (!gatewayUrl || !vapidPublicKey) return null;
77
+ const serverOrigin = resolveServerOrigin();
78
+ if (!serverOrigin) return null;
79
+ return {
80
+ product: identity.product,
81
+ appId: identity.appId,
82
+ appDisplayName: identity.appDisplayName,
83
+ serverOrigin,
84
+ gatewayUrl,
85
+ vapidPublicKey,
86
+ serviceWorkerPath: identity.serviceWorkerPath,
87
+ ...(identity.scope ? { scope: identity.scope } : {}),
88
+ ...(identity.lang ? { lang: identity.lang } : {}),
89
+ };
90
+ };
91
+
92
+ const buildTimeConfig = (): BrowserNotificationPushConfig | null =>
93
+ build(buildTimeValues?.() ?? null);
94
+
95
+ const resolveConfig =
96
+ async (): Promise<BrowserNotificationPushConfig | null> => {
97
+ try {
98
+ const runtime = await fetchNotificationPusherPublicConfig();
99
+ if (
100
+ !runtime.enabled ||
101
+ !runtime.gateway_url ||
102
+ !runtime.web_push_public_key
103
+ ) {
104
+ return null;
105
+ }
106
+ return build({
107
+ gatewayUrl: runtime.gateway_url,
108
+ vapidPublicKey: runtime.web_push_public_key,
109
+ });
110
+ } catch {
111
+ // Compatibility with older servers while they roll forward. Build-time
112
+ // public values are a fallback only; a responding runtime config is the
113
+ // deployment authority.
114
+ return buildTimeConfig();
115
+ }
116
+ };
117
+
118
+ const clearBeforeSignOut = async (): Promise<void> => {
119
+ const config = await resolveConfig().catch(() => null);
120
+ if (config) {
121
+ try {
122
+ await disableBrowserNotificationPush(config);
123
+ return;
124
+ } catch {
125
+ // Fall through to local endpoint invalidation.
126
+ }
127
+ }
128
+ await clearBrowserNotificationPush(identity).catch(() => undefined);
129
+ };
130
+
131
+ return { buildTimeConfig, resolveConfig, clearBeforeSignOut };
132
+ }
@@ -7,6 +7,9 @@ export * from "./api/posts.ts";
7
7
  export * from "./api/communities.ts";
8
8
  export * from "./api/dm.ts";
9
9
  export * from "./api/notifications.ts";
10
+ export * from "./api/notification-target.ts";
11
+ export * from "./api/browser-push.ts";
12
+ export * from "./api/push-config.ts";
10
13
  export * from "./api/search.ts";
11
14
  export * from "./api/media.ts";
12
15
  export * from "./api/stories.ts";
@@ -16,6 +16,11 @@ export interface SocialServerDiscovery {
16
16
  readonly defaultEntry: "feed" | "messages";
17
17
  }[];
18
18
  readonly issuer: string;
19
+ readonly oidcClientId?: string;
20
+ readonly auth?: {
21
+ readonly oidc: boolean;
22
+ readonly password: boolean;
23
+ };
19
24
  readonly apiBaseUrl: string;
20
25
  readonly activitypubOrigin: string;
21
26
  readonly mediaOrigin: string;
@@ -24,10 +29,14 @@ export interface SocialServerDiscovery {
24
29
  readonly endpoints: {
25
30
  readonly api: string;
26
31
  readonly authProviders: string;
32
+ readonly mobilePasswordLogin: string;
33
+ readonly mobileOidcExchange: string;
27
34
  readonly currentUser: string;
28
35
  readonly timeline: string;
29
36
  readonly conversations: string;
30
37
  readonly notifications: string;
38
+ readonly notificationPushers: string;
39
+ /** @deprecated Use notificationPushers. */
31
40
  readonly mobilePushRegistrations: string;
32
41
  };
33
42
  }
@@ -102,6 +102,8 @@ export interface DMMessage {
102
102
  id: string;
103
103
  sender: DMSender;
104
104
  content: string;
105
+ /** Media attachments (image/video), same shape as post attachments. */
106
+ attachments?: MediaAttachment[];
105
107
  created_at: string;
106
108
  }
107
109
 
@@ -120,10 +122,56 @@ export interface Notification {
120
122
  type: "follow" | "follow_request" | "like" | "announce" | "reply" | "mention";
121
123
  actor: NotificationActor;
122
124
  object_ap_id: string | null;
125
+ /**
126
+ * Navigation target for the notification. OPTIONAL: a server older than
127
+ * 3.2.0 omits these fields, and `normalizeNotification` then synthesizes them
128
+ * from `object_ap_id`. Prefer `target_kind` + `target_id` and build your own
129
+ * in-app path; `target_url` is shaped for the yurucommu web client's routing
130
+ * and is same-origin only — never treat it as an external URL.
131
+ */
132
+ target_kind?: NotificationTargetKind;
133
+ target_id?: string | null;
134
+ target_url?: string;
123
135
  read: boolean;
124
136
  created_at: string;
125
137
  }
126
138
 
139
+ export type NotificationTargetKind =
140
+ "post" | "story" | "profile" | "notifications";
141
+
142
+ export type NotificationPusherProduct = "yurucommu" | "yurume";
143
+
144
+ export interface NotificationPusherInput {
145
+ kind: "http";
146
+ app_id: string;
147
+ pushkey: string;
148
+ app_display_name?: string;
149
+ device_display_name?: string;
150
+ profile_tag?: string;
151
+ lang?: string;
152
+ data: {
153
+ url: string;
154
+ format?: "event_id_only" | "full";
155
+ [key: string]: unknown;
156
+ };
157
+ }
158
+
159
+ export interface NotificationPusherRegistration {
160
+ id: string;
161
+ kind: "http";
162
+ app_id: string;
163
+ app_display_name?: string;
164
+ device_display_name?: string;
165
+ profile_tag?: string;
166
+ lang?: string;
167
+ data: Record<string, unknown>;
168
+ gateway_url: string;
169
+ product: NotificationPusherProduct;
170
+ scope: string | null;
171
+ registered_at: string;
172
+ last_seen_at: string;
173
+ }
174
+
127
175
  // Story attachment (image or video)
128
176
  export interface StoryAttachment {
129
177
  type: string; // "Document" or "Video"
@@ -1,5 +1,6 @@
1
1
  import { Hono, type Context } from "hono";
2
2
  import { MOBILE_PUSH_REGISTRATION_PATH } from "./lib/mobile-contract.ts";
3
+ import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher-contract.ts";
3
4
  import type { Env, EnvVars, Variables } from "./types.ts";
4
5
  import { extractActorFromSession } from "./lib/session-actor.ts";
5
6
  import { isBackendPath } from "./lib/backend-paths.ts";
@@ -27,6 +28,7 @@ import recommendationsRoutes from "./routes/recommendations.ts";
27
28
  import { moderationRoutes } from "./routes/moderation.ts";
28
29
  import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
29
30
  import mobileRoutes from "./routes/mobile.ts";
31
+ import notificationPusherRoutes from "./routes/notification-pushers.ts";
30
32
 
31
33
  import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
32
34
  import { csrfProtection } from "./middleware/csrf.ts";
@@ -38,6 +40,7 @@ import {
38
40
  import { logger } from "./lib/logger.ts";
39
41
 
40
42
  const log = logger.child({ component: "backend.index" });
43
+ let lastNotificationPushRecoverySweep = 0;
41
44
  import type { MessageBatch } from "@cloudflare/workers-types";
42
45
  import type {
43
46
  DeliveryDlqMessageV1,
@@ -47,6 +50,7 @@ import {
47
50
  handleDeliveryDlqBatch,
48
51
  handleDeliveryQueueBatch,
49
52
  } from "./lib/delivery/queue.ts";
53
+ import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
50
54
 
51
55
  type YurucommuApp = Hono<{ Bindings: Env; Variables: Variables }>;
52
56
 
@@ -106,6 +110,7 @@ const DEFAULT_DISCOVERY_OPTIONS = {
106
110
  "activitypub.server.v1",
107
111
  "client.yurucommu.feed.v1",
108
112
  "client.yurume.messages.v1",
113
+ "notification.pushers.v1",
109
114
  ],
110
115
  } satisfies Required<YurucommuBackendDiscoveryOptionsV1>;
111
116
 
@@ -214,6 +219,7 @@ function buildSocialServerDiscovery(
214
219
  appUrl: string,
215
220
  issuer: string,
216
221
  options: YurucommuBackendDiscoveryOptionsV1 = {},
222
+ auth: { oidcClientId?: string; passwordEnabled?: boolean } = {},
217
223
  ) {
218
224
  const discovery = {
219
225
  ...DEFAULT_DISCOVERY_OPTIONS,
@@ -233,6 +239,11 @@ function buildSocialServerDiscovery(
233
239
  },
234
240
  clients: discovery.clients,
235
241
  issuer,
242
+ oidcClientId: auth.oidcClientId,
243
+ auth: {
244
+ oidc: Boolean(auth.oidcClientId),
245
+ password: Boolean(auth.passwordEnabled),
246
+ },
236
247
  apiBaseUrl: appUrl,
237
248
  activitypubOrigin: appUrl,
238
249
  mediaOrigin: `${appUrl}/media`,
@@ -241,10 +252,14 @@ function buildSocialServerDiscovery(
241
252
  endpoints: {
242
253
  api: `${appUrl}/api`,
243
254
  authProviders: `${appUrl}/api/auth/providers`,
255
+ mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
256
+ mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
244
257
  currentUser: `${appUrl}/api/auth/me`,
245
258
  timeline: `${appUrl}/api/timeline`,
246
259
  conversations: `${appUrl}/api/dm/contacts`,
247
260
  notifications: `${appUrl}/api/notifications`,
261
+ notificationPushers: `${appUrl}${NOTIFICATION_PUSHER_REGISTRATION_PATH}`,
262
+ // Retained for older mobile clients. New clients use notificationPushers.
248
263
  mobilePushRegistrations: `${appUrl}${MOBILE_PUSH_REGISTRATION_PATH}`,
249
264
  },
250
265
  };
@@ -324,9 +339,19 @@ function mountReadinessRoutes(
324
339
  ) => {
325
340
  const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
326
341
  const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
327
- return c.json(buildSocialServerDiscovery(appUrl, issuer, discovery), 200, {
328
- "Cache-Control": "public, max-age=300",
329
- });
342
+ const { clientId } = getOidcClientCredentials(c.env);
343
+ return c.json(
344
+ buildSocialServerDiscovery(appUrl, issuer, discovery, {
345
+ oidcClientId: getOidcIssuerUrl(c.env)
346
+ ? clientId || undefined
347
+ : undefined,
348
+ passwordEnabled: Boolean(c.env.AUTH_PASSWORD_HASH?.trim()),
349
+ }),
350
+ 200,
351
+ {
352
+ "Cache-Control": "public, max-age=300",
353
+ },
354
+ );
330
355
  };
331
356
 
332
357
  app.get("/.well-known/yurucommu", wellKnownSocialServer);
@@ -545,6 +570,44 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
545
570
  app.use("*", async (c, next) => {
546
571
  c.set("db", c.env.DB_INSTANCE);
547
572
  await next();
573
+
574
+ // Every unread inbox insert is captured by the DB outbox trigger. Flush it
575
+ // after the request instead of wiring every follow/like/story/DM write path
576
+ // separately (which is both leak- and duplicate-prone). Queue binding is
577
+ // optional; when absent this is an immediate no-op and the durable rows stay
578
+ // pending until a correctly configured runtime handles later traffic.
579
+ const method = c.req.method.toUpperCase();
580
+ const now = Date.now();
581
+ const mutating = !["GET", "HEAD", "OPTIONS"].includes(method);
582
+ const recoveryDue = now - lastNotificationPushRecoverySweep >= 60_000;
583
+ if (mutating || recoveryDue) {
584
+ if (recoveryDue) lastNotificationPushRecoverySweep = now;
585
+ const sweep = (async () => {
586
+ try {
587
+ await enqueuePendingNotificationPushJobs(c.env);
588
+ } catch (error) {
589
+ log.error("Failed to enqueue notification push outbox", {
590
+ event: "notification.push.enqueue_failed",
591
+ error,
592
+ });
593
+ }
594
+ })();
595
+ // Prefer to run the sweep AFTER the response is sent (waitUntil) so its
596
+ // 2-4 D1 round-trips never add latency to the request. `executionCtx`
597
+ // throws when no runtime context exists (tests / plain fetch); fall back
598
+ // to awaiting inline there.
599
+ let deferred = false;
600
+ try {
601
+ const ctx = c.executionCtx;
602
+ if (ctx && typeof ctx.waitUntil === "function") {
603
+ ctx.waitUntil(sweep);
604
+ deferred = true;
605
+ }
606
+ } catch {
607
+ // No execution context; await inline below.
608
+ }
609
+ if (!deferred) await sweep;
610
+ }
548
611
  });
549
612
 
550
613
  app.use("/api/*", async (c, next) => {
@@ -690,6 +753,7 @@ function mountCoreRoutes(app: YurucommuApp): void {
690
753
  });
691
754
 
692
755
  app.route("/api/notifications", notificationsRoutes);
756
+ app.route("/api/notifications/pushers", notificationPusherRoutes);
693
757
  app.route("/api/mobile", mobileRoutes);
694
758
  app.route("/api/stories", storiesRoutes);
695
759
  app.route("/api/search", searchRoutes);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared attachment bounds + validation.
3
+ *
4
+ * Owner of the attachment payload limits used by every write surface (post
5
+ * create, DM messages, community chat) and by inbound-federation bounding.
6
+ * Route files must import from here instead of copy-pasting the checks or
7
+ * reaching into another route's transformers module.
8
+ */
9
+
10
+ // Bound the attachments payload. An attachment is an open-ended record, so cap
11
+ // both the COUNT and the serialized SIZE — the size cap bounds row/federated-doc
12
+ // bloat regardless of internal shape (key count / field length). 16 KiB is ample
13
+ // for MAX_ATTACHMENTS media descriptors with alt text + blurhash.
14
+ export const MAX_ATTACHMENTS = 8;
15
+ export const MAX_ATTACHMENTS_JSON_LENGTH = 16 * 1024;
16
+
17
+ /** Drop an oversized inbound attachments blob to "[]" rather than store it. */
18
+ export function boundAttachmentsJson(json: string): string {
19
+ return json.length > MAX_ATTACHMENTS_JSON_LENGTH ? "[]" : json;
20
+ }
21
+
22
+ export type ChatAttachment = Record<string, unknown>;
23
+
24
+ export type ChatAttachmentsResult =
25
+ | { readonly ok: true; readonly attachments: ChatAttachment[] }
26
+ | { readonly ok: false; readonly error: string };
27
+
28
+ /**
29
+ * Validate a chat message's attachments array (mirrors the post-create bounds:
30
+ * records only, capped count + serialized size). Returns the validated array
31
+ * ([] when absent) or an error message. Used by both the DM and community-chat
32
+ * send routes so the two cannot drift.
33
+ */
34
+ export function validateChatAttachments(raw: unknown): ChatAttachmentsResult {
35
+ if (raw === undefined || raw === null) return { ok: true, attachments: [] };
36
+ if (!Array.isArray(raw)) {
37
+ return { ok: false, error: "attachments must be an array" };
38
+ }
39
+ if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
40
+ return { ok: false, error: "attachments must be objects" };
41
+ }
42
+ if (raw.length > MAX_ATTACHMENTS) {
43
+ return {
44
+ ok: false,
45
+ error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
46
+ };
47
+ }
48
+ if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
49
+ return { ok: false, error: "attachments payload too large" };
50
+ }
51
+ return { ok: true, attachments: raw as ChatAttachment[] };
52
+ }