@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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
|
+
}
|