@takosjp/yurucommu-core 3.0.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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * PII / Secret masking for structured logging.
3
+ *
4
+ * Ported from `takos/app/packages/control/src/shared/utils/logger.ts`.
5
+ * Duplicated locally per F26 scope (extraction to a shared package is
6
+ * tracked as a follow-up).
7
+ *
8
+ * Two public helpers:
9
+ * - `maskSensitiveData(value)` recursively walks objects/arrays/strings
10
+ * and returns a masked clone. Object keys matching the SENSITIVE_KEY
11
+ * regex are replaced with `[redacted]`. Strings are pattern-masked.
12
+ * - `maskSensitiveString(s)` pattern-masks a single string.
13
+ *
14
+ * Both helpers are pure and never mutate the input.
15
+ *
16
+ * NOTE: This module deliberately uses `[redacted]` (lowercase) per the
17
+ * F26 spec. Some patterns retain the upstream `[REDACTED_*]` tokens to
18
+ * preserve information about the redacted *kind* (JWT, GHP, PEM, etc).
19
+ */
20
+
21
+ interface SensitivePattern {
22
+ pattern: RegExp;
23
+ replacement: string;
24
+ }
25
+
26
+ const SENSITIVE_KEY_RE =
27
+ /password|secret|token|apikey|api_key|credential|private|cookie|authorization/i;
28
+
29
+ const SENSITIVE_PATTERNS: ReadonlyArray<SensitivePattern> = [
30
+ // JWT (header.payload.signature, base64url segments)
31
+ {
32
+ pattern:
33
+ /\beyJ[A-Za-z0-9_-]{1,2048}\.eyJ[A-Za-z0-9_-]{1,2048}\.[A-Za-z0-9_-]{1,512}/g,
34
+ replacement: "[REDACTED_JWT]",
35
+ },
36
+ // Bearer / token prefixes
37
+ {
38
+ pattern: /\b(Bearer|token)\s+([A-Za-z0-9_.\-+/=]{16,512})/gi,
39
+ replacement: "$1 [redacted]",
40
+ },
41
+ // Stripe live / test secret keys
42
+ {
43
+ pattern: /\bsk_live_[A-Za-z0-9]{16,256}/g,
44
+ replacement: "[REDACTED_STRIPE_LIVE]",
45
+ },
46
+ {
47
+ pattern: /\bsk_test_[A-Za-z0-9]{16,256}/g,
48
+ replacement: "[REDACTED_STRIPE_TEST]",
49
+ },
50
+ // OpenAI-style sk- tokens
51
+ { pattern: /\bsk-[A-Za-z0-9]{20,256}/g, replacement: "[REDACTED_SK]" },
52
+ // GitHub personal access tokens
53
+ { pattern: /\bghp_[A-Za-z0-9]{20,256}/g, replacement: "[REDACTED_GHP]" },
54
+ { pattern: /\bgho_[A-Za-z0-9]{20,256}/g, replacement: "[REDACTED_GHO]" },
55
+ // AWS access key id
56
+ { pattern: /\bAKIA[0-9A-Z]{16}/g, replacement: "[REDACTED_AWS_ACCESS_KEY]" },
57
+ // PEM private key bodies
58
+ {
59
+ pattern:
60
+ /-----BEGIN\s+(?:RSA\s+|EC\s+|OPENSSH\s+|DSA\s+|PGP\s+)?PRIVATE KEY-----[\s\S]{1,16384}?-----END\s+(?:RSA\s+|EC\s+|OPENSSH\s+|DSA\s+|PGP\s+)?PRIVATE KEY-----/g,
61
+ replacement: "[REDACTED_PRIVATE_KEY]",
62
+ },
63
+ // password=foo / passwd: bar / secret="baz"
64
+ {
65
+ pattern:
66
+ /\b(password|passwd|pwd|secret|api[_-]?key|apikey|api_token|auth[_-]?token|session[_-]?id|sessionid)\s*[=:]\s*"?([^"\s,}{]{1,256})"?/gi,
67
+ replacement: "$1=[redacted]",
68
+ },
69
+ // Email addresses
70
+ {
71
+ pattern:
72
+ /([A-Za-z0-9._%+\-]{1,64})@([A-Za-z0-9.\-]{1,255}\.[A-Za-z]{2,10})/g,
73
+ replacement: "***@$2",
74
+ },
75
+ ];
76
+
77
+ /** Test whether `value` looks like a real credit card via Luhn. */
78
+ function isValidLuhn(digits: string): boolean {
79
+ let sum = 0;
80
+ let alt = false;
81
+ for (let i = digits.length - 1; i >= 0; i--) {
82
+ const ch = digits.charCodeAt(i) - 48;
83
+ if (ch < 0 || ch > 9) return false;
84
+ let n = ch;
85
+ if (alt) {
86
+ n *= 2;
87
+ if (n > 9) n -= 9;
88
+ }
89
+ sum += n;
90
+ alt = !alt;
91
+ }
92
+ return sum > 0 && sum % 10 === 0;
93
+ }
94
+
95
+ /** Mask 13-19 digit numbers that pass Luhn (credit card). */
96
+ function maskCreditCards(input: string): string {
97
+ return input.replace(/\b(?:\d[ -]?){13,19}\b/g, (match) => {
98
+ const digits = match.replace(/[ \-]/g, "");
99
+ if (digits.length < 13 || digits.length > 19) return match;
100
+ if (!isValidLuhn(digits)) return match;
101
+ return "[REDACTED_CC]";
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Pattern-mask a single string.
107
+ *
108
+ * Replaces JWT, Bearer tokens, Stripe / OpenAI / GitHub / AWS secrets,
109
+ * PEM private keys, password/secret/key=value pairs, email addresses,
110
+ * and Luhn-valid credit card numbers.
111
+ */
112
+ export function maskSensitiveString(input: string): string {
113
+ if (typeof input !== "string" || input.length === 0) return input;
114
+ let result = input;
115
+ for (const { pattern, replacement } of SENSITIVE_PATTERNS) {
116
+ result = result.replace(pattern, replacement);
117
+ }
118
+ result = maskCreditCards(result);
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Recursively mask `value`. Returns a masked clone (input is not mutated).
124
+ *
125
+ * - `string` -> pattern-masked
126
+ * - `Array` -> mapped element-wise
127
+ * - `Object` -> key/value walked; values for sensitive keys are
128
+ * replaced with `"[redacted]"`
129
+ * - Other primitives are returned unchanged.
130
+ *
131
+ * Cycle-safe via a WeakSet on the walk path.
132
+ */
133
+ export function maskSensitiveData(value: unknown): unknown {
134
+ return walk(value, new WeakSet<object>());
135
+ }
136
+
137
+ function walk(value: unknown, seen: WeakSet<object>): unknown {
138
+ try {
139
+ return walkInner(value, seen);
140
+ } catch {
141
+ // Masking must NEVER throw — a single un-serializable field would
142
+ // otherwise crash the entire log call (and the request that triggered
143
+ // it). Fall back to an opaque placeholder for any value we cannot walk.
144
+ return "[unserializable]";
145
+ }
146
+ }
147
+
148
+ function walkInner(value: unknown, seen: WeakSet<object>): unknown {
149
+ if (value === null || value === undefined) return value;
150
+ if (typeof value === "string") return maskSensitiveString(value);
151
+ // `bigint` is not JSON-serializable: `JSON.stringify` throws on it, which
152
+ // would crash the log call. Coerce to a string with the canonical `n`
153
+ // suffix so the value (and its bigint-ness) survives in the log.
154
+ if (typeof value === "bigint") return maskSensitiveString(`${value}n`);
155
+ // `symbol` / `function` are not JSON-serializable either; stringify them
156
+ // rather than letting them silently disappear or throw.
157
+ if (typeof value === "symbol") return value.toString();
158
+ if (typeof value === "function") return "[function]";
159
+ if (typeof value !== "object") return value;
160
+
161
+ if (seen.has(value as object)) return "[circular]";
162
+ seen.add(value as object);
163
+
164
+ if (Array.isArray(value)) {
165
+ return value.map((entry) => walk(entry, seen));
166
+ }
167
+
168
+ // Preserve Error shape (name/message/stack) but mask string fields.
169
+ if (value instanceof Error) {
170
+ return {
171
+ name: value.name,
172
+ message: maskSensitiveString(value.message),
173
+ stack: value.stack ? maskSensitiveString(value.stack) : undefined,
174
+ };
175
+ }
176
+
177
+ // `Date` JSON-serializes to its ISO string already, but going through the
178
+ // object branch below would flatten it to `{}`. Keep the ISO string.
179
+ if (value instanceof Date) {
180
+ return Number.isNaN(value.getTime())
181
+ ? "[invalid-date]"
182
+ : value.toISOString();
183
+ }
184
+
185
+ // `Map` / `Set` have no enumerable own properties, so the object branch
186
+ // would silently flatten them to `{}` (fidelity loss). Represent them as
187
+ // their entry/value collections with masked contents.
188
+ if (value instanceof Map) {
189
+ const out: Record<string, unknown> = {};
190
+ for (const [k, v] of value.entries()) {
191
+ const key = typeof k === "string" ? k : String(k);
192
+ if (SENSITIVE_KEY_RE.test(key)) {
193
+ out[key] = "[redacted]";
194
+ continue;
195
+ }
196
+ out[key] = walk(v, seen);
197
+ }
198
+ return out;
199
+ }
200
+ if (value instanceof Set) {
201
+ return Array.from(value, (entry) => walk(entry, seen));
202
+ }
203
+
204
+ const out: Record<string, unknown> = {};
205
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
206
+ if (SENSITIVE_KEY_RE.test(key)) {
207
+ out[key] = "[redacted]";
208
+ continue;
209
+ }
210
+ out[key] = walk(child, seen);
211
+ }
212
+ return out;
213
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Structured Logger for Yurucommu
3
+ *
4
+ * Zero-dependency structured logging compatible with all runtimes
5
+ * (Cloudflare Workers, Node.js, Bun).
6
+ *
7
+ * - JSON output in production for observability pipelines
8
+ * - Human-readable output in development
9
+ * - Uses console.warn/console.error (ESLint-allowed) for warn/error levels
10
+ * - Uses console.log (with eslint-disable) only for debug/info levels
11
+ * - All emitted payloads pass through `maskSensitiveData` so JWTs,
12
+ * bearer tokens, password/secret/token/cookie/authorization-keyed
13
+ * fields, PEM keys, AWS access keys, emails, and Luhn credit cards
14
+ * are redacted before they reach stdout.
15
+ */
16
+
17
+ import { maskSensitiveData, maskSensitiveString } from "./log-mask.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type LogLevel = "debug" | "info" | "warn" | "error";
24
+
25
+ const LEVEL_ORDER: Record<LogLevel, number> = {
26
+ debug: 0,
27
+ info: 1,
28
+ warn: 2,
29
+ error: 3,
30
+ };
31
+
32
+ const LEVEL_NAMES = Object.fromEntries(
33
+ Object.entries(LEVEL_ORDER).map(([k, v]) => [v, k]),
34
+ ) as Record<number, LogLevel>;
35
+
36
+ interface LogEntry {
37
+ level: LogLevel;
38
+ msg: string;
39
+ ts: string;
40
+ service?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export interface LoggerOptions {
45
+ /** Service / component name attached to every entry */
46
+ service?: string;
47
+ /** Minimum log level (default: "debug") */
48
+ level?: LogLevel;
49
+ /** Extra fields merged into every entry */
50
+ defaultFields?: Record<string, unknown>;
51
+ /**
52
+ * Output format.
53
+ * - "json" : one JSON object per line (default, best for prod)
54
+ * - "pretty" : human-readable coloured output (best for dev)
55
+ */
56
+ format?: "json" | "pretty";
57
+ }
58
+
59
+ export interface Logger {
60
+ debug(msg: string, data?: Record<string, unknown>): void;
61
+ info(msg: string, data?: Record<string, unknown>): void;
62
+ warn(msg: string, data?: Record<string, unknown>): void;
63
+ error(msg: string, data?: Record<string, unknown>): void;
64
+ /** Create a child logger that inherits settings and merges extra fields */
65
+ child(fields: Record<string, unknown>): Logger;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function assertNever(x: never): never {
73
+ throw new Error(`Unhandled log level: ${JSON.stringify(x)}`);
74
+ }
75
+
76
+ function normalizeError(value: unknown): Record<string, unknown> {
77
+ if (value instanceof Error) {
78
+ return {
79
+ name: value.name,
80
+ message: value.message,
81
+ stack: value.stack,
82
+ };
83
+ }
84
+ return { message: String(value) };
85
+ }
86
+
87
+ function formatData(
88
+ data?: Record<string, unknown>,
89
+ ): Record<string, unknown> | undefined {
90
+ if (!data) return undefined;
91
+ const result: Record<string, unknown> = {};
92
+ for (const [key, value] of Object.entries(data)) {
93
+ result[key] = value instanceof Error ? normalizeError(value) : value;
94
+ }
95
+ return result;
96
+ }
97
+
98
+ /** Detect whether we are likely running in a production environment. */
99
+ function detectProduction(): boolean {
100
+ // Cloudflare Workers have no Node process; treat that path as production by
101
+ // default.
102
+ const processEnv = (
103
+ globalThis as {
104
+ process?: { env?: Record<string, string | undefined> };
105
+ }
106
+ ).process?.env;
107
+ if (!processEnv) return true;
108
+ const env = processEnv.NODE_ENV || "";
109
+ return env === "production";
110
+ }
111
+
112
+ const PRETTY_LEVEL_TAG: Record<LogLevel, string> = {
113
+ debug: "DBG",
114
+ info: "INF",
115
+ warn: "WRN",
116
+ error: "ERR",
117
+ };
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Implementation
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /** @internal */
124
+ interface InternalOptions extends LoggerOptions {
125
+ _fields?: Record<string, unknown>;
126
+ }
127
+
128
+ class LoggerImpl implements Logger {
129
+ private minLevel: number;
130
+ private service?: string;
131
+ private fields: Record<string, unknown>;
132
+ private useJson: boolean;
133
+
134
+ constructor(opts?: InternalOptions) {
135
+ this.minLevel = LEVEL_ORDER[opts?.level ?? "debug"];
136
+ this.service = opts?.service;
137
+ this.fields = { ...opts?.defaultFields, ...opts?._fields };
138
+
139
+ if (opts?.format) {
140
+ this.useJson = opts.format === "json";
141
+ } else {
142
+ this.useJson = detectProduction();
143
+ }
144
+ }
145
+
146
+ // ---- core emit ----------------------------------------------------------
147
+
148
+ private emit(
149
+ level: LogLevel,
150
+ msg: string,
151
+ data?: Record<string, unknown>,
152
+ ): void {
153
+ if (LEVEL_ORDER[level] < this.minLevel) return;
154
+
155
+ if (this.useJson) {
156
+ this.emitJson(level, msg, data);
157
+ } else {
158
+ this.emitPretty(level, msg, data);
159
+ }
160
+ }
161
+
162
+ // ---- JSON output --------------------------------------------------------
163
+
164
+ private emitJson(
165
+ level: LogLevel,
166
+ msg: string,
167
+ data?: Record<string, unknown>,
168
+ ): void {
169
+ const rawEntry: LogEntry = {
170
+ level,
171
+ msg,
172
+ ts: new Date().toISOString(),
173
+ ...(this.service ? { service: this.service } : {}),
174
+ ...this.fields,
175
+ ...formatData(data),
176
+ };
177
+ // Mask before serialization. `level` / `ts` / `service` are safe and
178
+ // are restored after masking so they always retain canonical shape.
179
+ const masked = maskSensitiveData(rawEntry) as Record<string, unknown>;
180
+ const entry: LogEntry = {
181
+ ...masked,
182
+ level: rawEntry.level,
183
+ msg: maskSensitiveString(rawEntry.msg),
184
+ ts: rawEntry.ts,
185
+ ...(rawEntry.service ? { service: rawEntry.service } : {}),
186
+ };
187
+ const line = JSON.stringify(entry);
188
+ this.write(level, line);
189
+ }
190
+
191
+ // ---- Pretty output ------------------------------------------------------
192
+
193
+ private emitPretty(
194
+ level: LogLevel,
195
+ msg: string,
196
+ data?: Record<string, unknown>,
197
+ ): void {
198
+ const ts = new Date().toISOString();
199
+ const tag = PRETTY_LEVEL_TAG[level];
200
+ const svc = this.service ? ` [${this.service}]` : "";
201
+
202
+ const merged = maskSensitiveData({
203
+ ...this.fields,
204
+ ...formatData(data),
205
+ }) as Record<string, unknown>;
206
+ const extra =
207
+ Object.keys(merged).length > 0 ? " " + JSON.stringify(merged) : "";
208
+
209
+ const line = `${ts} ${tag}${svc} ${maskSensitiveString(msg)}${extra}`;
210
+ this.write(level, line);
211
+ }
212
+
213
+ // ---- console dispatch ---------------------------------------------------
214
+
215
+ private write(level: LogLevel, line: string): void {
216
+ switch (level) {
217
+ case "debug":
218
+ case "info":
219
+ // eslint-disable-next-line no-console
220
+ console.log(line);
221
+ break;
222
+ case "warn":
223
+ console.warn(line);
224
+ break;
225
+ case "error":
226
+ console.error(line);
227
+ break;
228
+ default:
229
+ assertNever(level);
230
+ }
231
+ }
232
+
233
+ // ---- public API ---------------------------------------------------------
234
+
235
+ debug(msg: string, data?: Record<string, unknown>): void {
236
+ this.emit("debug", msg, data);
237
+ }
238
+
239
+ info(msg: string, data?: Record<string, unknown>): void {
240
+ this.emit("info", msg, data);
241
+ }
242
+
243
+ warn(msg: string, data?: Record<string, unknown>): void {
244
+ this.emit("warn", msg, data);
245
+ }
246
+
247
+ error(msg: string, data?: Record<string, unknown>): void {
248
+ this.emit("error", msg, data);
249
+ }
250
+
251
+ child(fields: Record<string, unknown>): Logger {
252
+ return new LoggerImpl({
253
+ level: LEVEL_NAMES[this.minLevel],
254
+ service: this.service,
255
+ format: this.useJson ? "json" : "pretty",
256
+ _fields: { ...this.fields, ...fields },
257
+ });
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Public API
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Create a new logger instance.
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const log = createLogger({ service: 'activitypub', level: 'info' });
271
+ * log.info('inbox received', { actorId: '...' });
272
+ *
273
+ * const childLog = log.child({ requestId: crypto.randomUUID() });
274
+ * childLog.warn('signature verification slow', { durationMs: 430 });
275
+ * ```
276
+ */
277
+ export function createLogger(opts?: LoggerOptions): Logger {
278
+ return new LoggerImpl(opts);
279
+ }
280
+
281
+ /**
282
+ * Default logger instance for quick usage.
283
+ * Service name defaults to "yurucommu".
284
+ */
285
+ export const logger: Logger = createLogger({ service: "yurucommu" });
@@ -0,0 +1,137 @@
1
+ export const MOBILE_PUSH_REGISTRATION_PATH =
2
+ "/api/mobile/push-registrations" as const;
3
+
4
+ export const SOCIAL_CLIENT_KINDS = ["yurucommu", "yurume"] as const;
5
+
6
+ export type SocialClientKind = (typeof SOCIAL_CLIENT_KINDS)[number];
7
+ export type MobileProductKind = SocialClientKind;
8
+
9
+ export interface MobilePushHostRegistrationRequest {
10
+ readonly product: MobileProductKind;
11
+ readonly token: string;
12
+ readonly environment?: string;
13
+ readonly host_url?: string | null;
14
+ }
15
+
16
+ export interface ParsedMobilePushHostRegistrationRequest {
17
+ readonly product: MobileProductKind;
18
+ readonly token: string;
19
+ readonly environment: string;
20
+ readonly hostUrl: string | null;
21
+ }
22
+
23
+ export interface MobilePushHostRegistration {
24
+ readonly id: string;
25
+ readonly product: MobileProductKind;
26
+ readonly environment: string;
27
+ readonly host_url: string | null;
28
+ readonly registered_at: string;
29
+ readonly last_seen_at: string;
30
+ }
31
+
32
+ export interface MobilePushHostUnregistrationResponse {
33
+ readonly unregistered: true;
34
+ }
35
+
36
+ export interface MobilePushHostRegistrationParseError {
37
+ readonly code: "BAD_REQUEST";
38
+ readonly error: string;
39
+ readonly field?: keyof MobilePushHostRegistrationRequest;
40
+ }
41
+
42
+ export type MobilePushHostRegistrationParseResult =
43
+ | {
44
+ readonly ok: true;
45
+ readonly value: ParsedMobilePushHostRegistrationRequest;
46
+ }
47
+ | {
48
+ readonly ok: false;
49
+ readonly error: MobilePushHostRegistrationParseError;
50
+ };
51
+
52
+ export function parseMobilePushHostRegistrationRequest(
53
+ body: unknown,
54
+ ): MobilePushHostRegistrationParseResult {
55
+ if (!isRecord(body)) {
56
+ return badRequest("body must be an object");
57
+ }
58
+
59
+ const product = parseMobileProductKind(body.product);
60
+ if (!product) {
61
+ return badRequest("product must be yurucommu or yurume", "product");
62
+ }
63
+
64
+ const token = parseNonEmptyString(body.token);
65
+ if (!token || token.length > 4096) {
66
+ return badRequest("token is invalid", "token");
67
+ }
68
+
69
+ const environment =
70
+ body.environment == null
71
+ ? "production"
72
+ : parseShortIdentifier(body.environment);
73
+ if (!environment) {
74
+ return badRequest("environment is invalid", "environment");
75
+ }
76
+
77
+ const hostUrl = parseOptionalHttpUrl(body.host_url);
78
+ if (hostUrl === undefined) {
79
+ return badRequest("host_url is invalid", "host_url");
80
+ }
81
+
82
+ return {
83
+ ok: true,
84
+ value: {
85
+ product,
86
+ token,
87
+ environment,
88
+ hostUrl,
89
+ },
90
+ };
91
+ }
92
+
93
+ function badRequest(
94
+ error: string,
95
+ field?: keyof MobilePushHostRegistrationRequest,
96
+ ): MobilePushHostRegistrationParseResult {
97
+ return { ok: false, error: { code: "BAD_REQUEST", error, field } };
98
+ }
99
+
100
+ function parseShortIdentifier(value: unknown): string | null {
101
+ const text = parseNonEmptyString(value);
102
+ if (!text || text.length > 64) return null;
103
+ return /^[a-z0-9._:-]+$/i.test(text) ? text : null;
104
+ }
105
+
106
+ function parseNonEmptyString(value: unknown): string | null {
107
+ if (typeof value !== "string") return null;
108
+ const trimmed = value.trim();
109
+ return trimmed.length > 0 ? trimmed : null;
110
+ }
111
+
112
+ function parseMobileProductKind(value: unknown): MobileProductKind | null {
113
+ if (typeof value !== "string") return null;
114
+ return SOCIAL_CLIENT_KINDS.includes(value as SocialClientKind)
115
+ ? (value as SocialClientKind)
116
+ : null;
117
+ }
118
+
119
+ function parseOptionalHttpUrl(value: unknown): string | null | undefined {
120
+ if (value == null) return null;
121
+ if (typeof value !== "string") return undefined;
122
+ const trimmed = value.trim();
123
+ if (!trimmed || trimmed.length > 2048) return undefined;
124
+ try {
125
+ const parsed = new URL(trimmed);
126
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
127
+ return undefined;
128
+ }
129
+ return parsed.toString().replace(/\/$/, "");
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ function isRecord(value: unknown): value is Record<string, unknown> {
136
+ return typeof value === "object" && value !== null && !Array.isArray(value);
137
+ }