@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.
- package/migrations/0019_notification_push_delivery.sql +10 -7
- package/package.json +5 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +11 -0
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +2 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/index.ts +13 -4
- package/src/backend/index.ts +44 -9
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +55 -0
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +159 -146
- package/src/backend/lib/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +124 -2
- package/src/backend/routes/communities/messages.ts +16 -33
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +5 -39
- package/src/backend/routes/notifications.ts +27 -70
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/db/index.ts +11 -10
- package/src/db/schema/mobile.ts +7 -9
|
@@ -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
|
-
|
|
23
|
-
|
|
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.
|
|
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",
|
|
@@ -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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 {
|
package/src/backend/index.ts
CHANGED
|
@@ -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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
}
|