@takosjp/yurucommu-core 3.2.0 → 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.
@@ -1,3 +1,9 @@
1
+ -- NO foreign keys on these tables. Cloudflare D1 ENFORCES declared FKs (see
2
+ -- 0010/0011, which dropped the actors FKs for exactly that reason), local
3
+ -- libsql runs with enforcement OFF, and the trigger below fires on EVERY
4
+ -- unread inbox insert — an actors FK here would let a single missing actors
5
+ -- row abort unrelated inbox writes in production only. Referential cleanup is
6
+ -- app-level, like everywhere else (routes/account-teardown.ts).
1
7
  CREATE TABLE IF NOT EXISTS notification_pushers (
2
8
  id TEXT PRIMARY KEY,
3
9
  actor_ap_id TEXT NOT NULL,
@@ -15,13 +21,11 @@ CREATE TABLE IF NOT EXISTS notification_pushers (
15
21
  gateway_url TEXT NOT NULL,
16
22
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
17
23
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
18
- last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
19
- FOREIGN KEY (actor_ap_id) REFERENCES actors(ap_id) ON DELETE CASCADE
24
+ last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
20
25
  );
21
26
 
22
- CREATE UNIQUE INDEX IF NOT EXISTS notification_pushers_actor_product_app_pushkey_idx
23
- ON notification_pushers(actor_ap_id, product, app_id, pushkey_hash);
24
-
27
+ -- Device uniqueness is (product, app_id, pushkey_hash) — strictly stronger
28
+ -- than any actor-scoped variant, so no second unique index is declared.
25
29
  CREATE INDEX IF NOT EXISTS notification_pushers_actor_product_idx
26
30
  ON notification_pushers(actor_ap_id, product);
27
31
 
@@ -44,8 +48,7 @@ CREATE TABLE IF NOT EXISTS notification_push_jobs (
44
48
  last_error TEXT,
45
49
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
46
50
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
47
- delivered_at TEXT,
48
- FOREIGN KEY (actor_ap_id) REFERENCES actors(ap_id) ON DELETE CASCADE
51
+ delivered_at TEXT
49
52
  );
50
53
 
51
54
  CREATE UNIQUE INDEX IF NOT EXISTS notification_push_jobs_actor_activity_idx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -32,8 +32,10 @@
32
32
  "packages/api/src/lib/api/moderation.ts",
33
33
  "packages/api/src/lib/api/notes.ts",
34
34
  "packages/api/src/lib/api/normalize.ts",
35
+ "packages/api/src/lib/api/notification-target.ts",
35
36
  "packages/api/src/lib/api/notifications.ts",
36
37
  "packages/api/src/lib/api/posts.ts",
38
+ "packages/api/src/lib/api/push-config.ts",
37
39
  "packages/api/src/lib/api/recommendations.ts",
38
40
  "packages/api/src/lib/api/search.ts",
39
41
  "packages/api/src/lib/api/stories.ts",
@@ -56,7 +58,8 @@
56
58
  "start": "bun src/backend/server.ts",
57
59
  "dev": "bun src/backend/server.ts",
58
60
  "dev:server": "bun src/backend/server.ts",
59
- "check": "tsc --noEmit",
61
+ "check": "tsc --noEmit && bun run check:no-opentofu-artifacts",
62
+ "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
60
63
  "test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
61
64
  "test:backend": "bun test src/backend/",
62
65
  "build": "bun run build:api",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -294,6 +294,17 @@ function normalizeServerOrigin(value: string): string | null {
294
294
  }
295
295
  }
296
296
 
297
+ /**
298
+ * Client-side gateway URL policy. MUST stay behaviorally equivalent to the
299
+ * server's `normalizeGatewayUrl` in the notification pusher contract — a
300
+ * client-accepted / server-rejected URL yields a confusing "registers but
301
+ * 400s" failure. The equivalence is pinned by a shared-fixture drift test.
302
+ * Exported for that test; app code calls the higher-level helpers above.
303
+ */
304
+ export function normalizeBrowserPushGatewayUrl(value: string): string | null {
305
+ return normalizeGatewayUrl(value);
306
+ }
307
+
297
308
  function normalizeGatewayUrl(value: string): string | null {
298
309
  try {
299
310
  const url = new URL(value.trim());
@@ -6,6 +6,7 @@ import {
6
6
  Post,
7
7
  Story,
8
8
  } from "../../types/index.ts";
9
+ import { resolveNotificationTarget } from "./notification-target.ts";
9
10
 
10
11
  type ActorLike = {
11
12
  ap_id: string;
@@ -71,7 +72,17 @@ export const normalizeActorNote = (note: ActorNote): ActorNote => ({
71
72
 
72
73
  export const normalizeNotification = (
73
74
  notification: Notification,
74
- ): Notification => ({
75
- ...notification,
76
- actor: normalizeActor(notification.actor),
77
- });
75
+ ): Notification => {
76
+ // Fill the navigation target so consumers get a stable, safe same-origin
77
+ // path even from a pre-3.2.0 server that omitted target_* (or sent an unsafe
78
+ // target_url). resolveNotificationTarget re-validates a declared target and
79
+ // otherwise synthesizes it from type + object_ap_id.
80
+ const target = resolveNotificationTarget(notification);
81
+ return {
82
+ ...notification,
83
+ actor: normalizeActor(notification.actor),
84
+ target_kind: target.target_kind,
85
+ target_id: target.target_id,
86
+ target_url: target.target_url,
87
+ };
88
+ };
@@ -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
+ }
@@ -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,7 +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";
10
11
  export * from "./api/browser-push.ts";
12
+ export * from "./api/push-config.ts";
11
13
  export * from "./api/search.ts";
12
14
  export * from "./api/media.ts";
13
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,6 +29,8 @@ 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;
@@ -122,14 +122,23 @@ export interface Notification {
122
122
  type: "follow" | "follow_request" | "like" | "announce" | "reply" | "mention";
123
123
  actor: NotificationActor;
124
124
  object_ap_id: string | null;
125
- target_kind: "post" | "story" | "profile" | "community" | "notifications";
126
- target_id: string | null;
127
- /** Same-origin in-app path. Clients must not treat this as an external URL. */
128
- target_url: string;
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;
129
135
  read: boolean;
130
136
  created_at: string;
131
137
  }
132
138
 
139
+ export type NotificationTargetKind =
140
+ "post" | "story" | "profile" | "notifications";
141
+
133
142
  export type NotificationPusherProduct = "yurucommu" | "yurume";
134
143
 
135
144
  export interface NotificationPusherInput {
@@ -219,6 +219,7 @@ function buildSocialServerDiscovery(
219
219
  appUrl: string,
220
220
  issuer: string,
221
221
  options: YurucommuBackendDiscoveryOptionsV1 = {},
222
+ auth: { oidcClientId?: string; passwordEnabled?: boolean } = {},
222
223
  ) {
223
224
  const discovery = {
224
225
  ...DEFAULT_DISCOVERY_OPTIONS,
@@ -238,6 +239,11 @@ function buildSocialServerDiscovery(
238
239
  },
239
240
  clients: discovery.clients,
240
241
  issuer,
242
+ oidcClientId: auth.oidcClientId,
243
+ auth: {
244
+ oidc: Boolean(auth.oidcClientId),
245
+ password: Boolean(auth.passwordEnabled),
246
+ },
241
247
  apiBaseUrl: appUrl,
242
248
  activitypubOrigin: appUrl,
243
249
  mediaOrigin: `${appUrl}/media`,
@@ -246,6 +252,8 @@ function buildSocialServerDiscovery(
246
252
  endpoints: {
247
253
  api: `${appUrl}/api`,
248
254
  authProviders: `${appUrl}/api/auth/providers`,
255
+ mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
256
+ mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
249
257
  currentUser: `${appUrl}/api/auth/me`,
250
258
  timeline: `${appUrl}/api/timeline`,
251
259
  conversations: `${appUrl}/api/dm/contacts`,
@@ -331,9 +339,19 @@ function mountReadinessRoutes(
331
339
  ) => {
332
340
  const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
333
341
  const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
334
- return c.json(buildSocialServerDiscovery(appUrl, issuer, discovery), 200, {
335
- "Cache-Control": "public, max-age=300",
336
- });
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
+ );
337
355
  };
338
356
 
339
357
  app.get("/.well-known/yurucommu", wellKnownSocialServer);
@@ -564,14 +582,31 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
564
582
  const recoveryDue = now - lastNotificationPushRecoverySweep >= 60_000;
565
583
  if (mutating || recoveryDue) {
566
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;
567
600
  try {
568
- await enqueuePendingNotificationPushJobs(c.env);
569
- } catch (error) {
570
- log.error("Failed to enqueue notification push outbox", {
571
- event: "notification.push.enqueue_failed",
572
- error,
573
- });
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.
574
608
  }
609
+ if (!deferred) await sweep;
575
610
  }
576
611
  });
577
612
 
@@ -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
+ }
@@ -24,6 +24,7 @@ import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
24
24
  import {
25
25
  enqueuePendingNotificationPushJobs,
26
26
  processNotificationPushJob,
27
+ recoverDeadLetteredNotificationPushJob,
27
28
  } from "../notification-push.ts";
28
29
 
29
30
  const log = logger.child({ component: "delivery.queue" });
@@ -539,10 +540,21 @@ export async function handleDeliveryDlqBatch(
539
540
  for (const message of batch.messages) {
540
541
  const body = message.body;
541
542
  if (!isDeliveryDlqMessageV1(body)) {
543
+ // Not an app-built `dlq` message. Cloudflare Queues also delivers here
544
+ // the RAW original body of any MAIN-queue message that exhausted its
545
+ // retries (automatic dead-lettering). Those must NOT be silently acked as
546
+ // "invalid": a lost fanout/resolve drops local notifications or delivery
547
+ // planning, and a lost notification_push strands its durable outbox row.
548
+ if (isDeliveryQueueMessageV1(body)) {
549
+ await handleAutoDeadLetteredMessage(env, body);
550
+ message.ack();
551
+ continue;
552
+ }
542
553
  log.warn("Invalid DLQ message format, skipping", {
543
554
  event: "delivery.dlq.invalid_message",
544
555
  bodyPreview: JSON.stringify(body).slice(0, 200),
545
556
  });
557
+ emitMetric("delivery.dlq.invalid_message", 1, {});
546
558
  message.ack();
547
559
  continue;
548
560
  }
@@ -592,3 +604,46 @@ export async function handleDeliveryDlqBatch(
592
604
  message.ack();
593
605
  }
594
606
  }
607
+
608
+ /**
609
+ * Recover / account for a MAIN-queue message that Cloudflare auto-dead-lettered
610
+ * (retries exhausted with the raw body). `notification_push` rows are durable,
611
+ * so reset the job to retry through the outbox instead of stranding it;
612
+ * everything else is logged with an alerting metric rather than swallowed.
613
+ */
614
+ async function handleAutoDeadLetteredMessage(
615
+ env: Env,
616
+ body: DeliveryQueueMessageV1,
617
+ ): Promise<void> {
618
+ if (body.type === "notification_push") {
619
+ try {
620
+ const recovered = await recoverDeadLetteredNotificationPushJob(
621
+ env.DB_INSTANCE,
622
+ body.jobId,
623
+ );
624
+ log.error("notification_push dead-lettered; reset durable outbox row", {
625
+ event: "delivery.dlq.notification_push_recovered",
626
+ jobId: body.jobId,
627
+ recovered,
628
+ });
629
+ emitMetric("delivery.dlq.notification_push_recovered", 1, {});
630
+ } catch (error) {
631
+ log.error("Failed to recover dead-lettered notification_push", {
632
+ event: "delivery.dlq.notification_push_recover_failed",
633
+ jobId: body.jobId,
634
+ error,
635
+ });
636
+ emitMetric("delivery.dlq.notification_push_recover_failed", 1, {});
637
+ }
638
+ return;
639
+ }
640
+
641
+ // fanout_*/resolve_actor/deliver_endpoint/reconcile_job: no durable ledger to
642
+ // rewind here, but make the loss explicit (alertable) rather than a silent
643
+ // "invalid message" ack.
644
+ log.error("Delivery message auto-dead-lettered; dropping", {
645
+ event: "delivery.dlq.auto_dead_lettered",
646
+ messageType: body.type,
647
+ });
648
+ emitMetric("delivery.dlq.auto_dead_lettered", 1, { message_type: body.type });
649
+ }