@takosjp/yurucommu-core 3.2.0 → 3.3.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.
Files changed (45) hide show
  1. package/migrations/0019_notification_push_delivery.sql +10 -7
  2. package/migrations/0020_call_sessions.sql +23 -0
  3. package/package.json +6 -2
  4. package/packages/api/package.json +1 -1
  5. package/packages/api/src/index.ts +1 -0
  6. package/packages/api/src/lib/api/browser-push.ts +11 -0
  7. package/packages/api/src/lib/api/normalize.ts +15 -4
  8. package/packages/api/src/lib/api/notification-target.ts +106 -0
  9. package/packages/api/src/lib/api/push-config.ts +132 -0
  10. package/packages/api/src/lib/api.ts +2 -0
  11. package/packages/api/src/lib/rtc-client.ts +542 -0
  12. package/packages/api/src/social-server.ts +7 -0
  13. package/packages/api/src/types/call.ts +306 -0
  14. package/packages/api/src/types/index.ts +16 -4
  15. package/src/backend/index.ts +65 -10
  16. package/src/backend/lib/attachments.ts +52 -0
  17. package/src/backend/lib/delivery/queue.ts +55 -0
  18. package/src/backend/lib/notification-eligibility.ts +150 -0
  19. package/src/backend/lib/notification-push.ts +159 -146
  20. package/src/backend/lib/rtc/call-store.ts +101 -0
  21. package/src/backend/lib/rtc/provider.ts +135 -0
  22. package/src/backend/lib/rtc/signal-transport.ts +125 -0
  23. package/src/backend/lib/session-actor.ts +16 -1
  24. package/src/backend/lib/unread-counts.ts +79 -0
  25. package/src/backend/middleware/csrf.ts +11 -0
  26. package/src/backend/public.ts +3 -0
  27. package/src/backend/routes/account-teardown.ts +13 -0
  28. package/src/backend/routes/activitypub.ts +5 -1
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +131 -3
  31. package/src/backend/routes/communities/messages.ts +16 -33
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +5 -39
  34. package/src/backend/routes/notifications.ts +27 -70
  35. package/src/backend/routes/posts/transformers.ts +8 -10
  36. package/src/backend/routes/rtc/index.ts +147 -0
  37. package/src/backend/runtime/call-hub-core.ts +470 -0
  38. package/src/backend/runtime/call-hub-port.ts +78 -0
  39. package/src/backend/runtime/call-signaling-do.ts +242 -0
  40. package/src/backend/runtime/signaling-hub.ts +187 -0
  41. package/src/backend/types.ts +28 -0
  42. package/src/db/index.ts +11 -10
  43. package/src/db/schema/calls.ts +46 -0
  44. package/src/db/schema/index.ts +1 -0
  45. 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
- 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
@@ -0,0 +1,23 @@
1
+ CREATE TABLE IF NOT EXISTS call_sessions (
2
+ id TEXT PRIMARY KEY,
3
+ local_actor_ap_id TEXT NOT NULL,
4
+ peer_actor_ap_id TEXT NOT NULL,
5
+ direction TEXT NOT NULL,
6
+ state TEXT NOT NULL DEFAULT 'ringing',
7
+ media_audio INTEGER NOT NULL DEFAULT 1,
8
+ media_video INTEGER NOT NULL DEFAULT 0,
9
+ sfu_focus TEXT,
10
+ peer_signal_endpoint TEXT,
11
+ end_reason TEXT,
12
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
13
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
14
+ connected_at TEXT,
15
+ ended_at TEXT,
16
+ FOREIGN KEY (local_actor_ap_id) REFERENCES actors(ap_id) ON DELETE CASCADE
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS call_sessions_local_created_idx
20
+ ON call_sessions(local_actor_ap_id, created_at);
21
+
22
+ CREATE INDEX IF NOT EXISTS call_sessions_state_idx
23
+ ON call_sessions(state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -19,6 +19,7 @@
19
19
  "packages/api/src/types",
20
20
  "packages/api/src/lib/api.ts",
21
21
  "packages/api/src/lib/transport.ts",
22
+ "packages/api/src/lib/rtc-client.ts",
22
23
  "packages/api/src/lib/fetch-with-timeout.ts",
23
24
  "packages/api/src/lib/api/account.ts",
24
25
  "packages/api/src/lib/api/actors.ts",
@@ -32,8 +33,10 @@
32
33
  "packages/api/src/lib/api/moderation.ts",
33
34
  "packages/api/src/lib/api/notes.ts",
34
35
  "packages/api/src/lib/api/normalize.ts",
36
+ "packages/api/src/lib/api/notification-target.ts",
35
37
  "packages/api/src/lib/api/notifications.ts",
36
38
  "packages/api/src/lib/api/posts.ts",
39
+ "packages/api/src/lib/api/push-config.ts",
37
40
  "packages/api/src/lib/api/recommendations.ts",
38
41
  "packages/api/src/lib/api/search.ts",
39
42
  "packages/api/src/lib/api/stories.ts",
@@ -56,7 +59,8 @@
56
59
  "start": "bun src/backend/server.ts",
57
60
  "dev": "bun src/backend/server.ts",
58
61
  "dev:server": "bun src/backend/server.ts",
59
- "check": "tsc --noEmit",
62
+ "check": "tsc --noEmit && bun run check:no-opentofu-artifacts",
63
+ "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
60
64
  "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
65
  "test:backend": "bun test src/backend/",
62
66
  "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.3.0",
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",
@@ -1,4 +1,5 @@
1
1
  export * from "./lib/api.ts";
2
2
  export * from "./lib/transport.ts";
3
+ export * from "./lib/rtc-client.ts";
3
4
  export * from "./social-server.ts";
4
5
  export * from "./types/index.ts";
@@ -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";