@takosjp/yurucommu-core 3.4.3 → 3.4.4

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 (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -0,0 +1,438 @@
1
+ import { isSafeRemoteUrl } from "../../../federation-helpers.ts";
2
+ import { MAX_ATTACHMENTS_JSON_LENGTH } from "../../../lib/attachments.ts";
3
+ import type { Activity, ActivityObject } from "../inbox-types.ts";
4
+
5
+ const DEFAULT_DISPLAY_DURATION = "PT5S";
6
+ const MAX_DISPLAY_DURATION_SECONDS = 60;
7
+ const MAX_STORY_LIFETIME_MS = 25 * 60 * 60 * 1000;
8
+ const MAX_STORY_CAPTION_LENGTH = 500;
9
+ const MAX_STORY_OVERLAYS = 20;
10
+ const MAX_STORY_URL_LENGTH = 2048;
11
+ const MAX_STORY_TEXT_LENGTH = 500;
12
+ const MAX_MEDIA_TYPE_LENGTH = 255;
13
+ const MAX_MEDIA_DIMENSION = 32_768;
14
+ const MAX_STORY_ADDRESSES = 64;
15
+
16
+ type StoryAttachmentProjection = {
17
+ r2_key: string;
18
+ content_type: string;
19
+ url: string;
20
+ width: number;
21
+ height: number;
22
+ };
23
+
24
+ type StoryOverlayPosition = {
25
+ x: number;
26
+ y: number;
27
+ width: number;
28
+ height: number;
29
+ };
30
+
31
+ type StoryOverlayOption = {
32
+ type: "Note";
33
+ name: string;
34
+ };
35
+
36
+ type StoryOverlayProjection = {
37
+ type: "Question" | "Note" | "Link";
38
+ position: StoryOverlayPosition;
39
+ name?: string;
40
+ href?: string;
41
+ oneOf?: StoryOverlayOption[];
42
+ };
43
+
44
+ export type InboundStoryProjection = {
45
+ attachment: StoryAttachmentProjection;
46
+ displayDuration: string;
47
+ caption?: string;
48
+ overlays?: StoryOverlayProjection[];
49
+ };
50
+
51
+ export type InboundStoryProjectionResult = {
52
+ projection: InboundStoryProjection;
53
+ json: string;
54
+ };
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return value !== null && typeof value === "object" && !Array.isArray(value);
58
+ }
59
+
60
+ function boundedText(value: unknown, max: number): string | undefined {
61
+ if (typeof value !== "string") return undefined;
62
+ const trimmed = value.trim();
63
+ return trimmed.length > 0 ? trimmed.slice(0, max) : undefined;
64
+ }
65
+
66
+ function normalizedDimension(value: unknown, fallback: number): number {
67
+ if (
68
+ typeof value !== "number" ||
69
+ !Number.isFinite(value) ||
70
+ value <= 0 ||
71
+ value > MAX_MEDIA_DIMENSION
72
+ ) {
73
+ return fallback;
74
+ }
75
+ return Math.max(1, Math.round(value));
76
+ }
77
+
78
+ function normalizedMediaType(value: unknown): string {
79
+ if (typeof value !== "string") return "image/jpeg";
80
+ const mediaType = value.trim().toLowerCase();
81
+ if (
82
+ mediaType.length === 0 ||
83
+ mediaType.length > MAX_MEDIA_TYPE_LENGTH ||
84
+ !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mediaType)
85
+ ) {
86
+ return "image/jpeg";
87
+ }
88
+ return mediaType;
89
+ }
90
+
91
+ function normalizedRemoteAttachment(
92
+ raw: unknown,
93
+ ): StoryAttachmentProjection | null {
94
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
95
+ if (!isRecord(candidate)) return null;
96
+
97
+ const url = typeof candidate.url === "string" ? candidate.url.trim() : "";
98
+ if (
99
+ url.length === 0 ||
100
+ url.length > MAX_STORY_URL_LENGTH ||
101
+ !isSafeRemoteUrl(url)
102
+ ) {
103
+ return null;
104
+ }
105
+
106
+ return {
107
+ r2_key: "",
108
+ content_type: normalizedMediaType(
109
+ candidate.mediaType ?? candidate.content_type,
110
+ ),
111
+ url,
112
+ width: normalizedDimension(candidate.width, 1080),
113
+ height: normalizedDimension(candidate.height, 1920),
114
+ };
115
+ }
116
+
117
+ function normalizedPosition(raw: unknown): StoryOverlayPosition | null {
118
+ if (!isRecord(raw)) return null;
119
+ const fields = ["x", "y", "width", "height"] as const;
120
+ for (const field of fields) {
121
+ const value = raw[field];
122
+ if (
123
+ typeof value !== "number" ||
124
+ !Number.isFinite(value) ||
125
+ value < 0 ||
126
+ value > 1
127
+ ) {
128
+ return null;
129
+ }
130
+ }
131
+ return {
132
+ x: raw.x as number,
133
+ y: raw.y as number,
134
+ width: raw.width as number,
135
+ height: raw.height as number,
136
+ };
137
+ }
138
+
139
+ function normalizedQuestionOptions(raw: unknown): StoryOverlayOption[] | null {
140
+ if (!Array.isArray(raw) || raw.length < 2 || raw.length > 4) return null;
141
+ const options: StoryOverlayOption[] = [];
142
+ for (const candidate of raw) {
143
+ if (!isRecord(candidate)) return null;
144
+ const name = boundedText(candidate.name, MAX_STORY_TEXT_LENGTH);
145
+ if (!name) return null;
146
+ options.push({ type: "Note", name });
147
+ }
148
+ return options;
149
+ }
150
+
151
+ function normalizedOverlays(raw: unknown): StoryOverlayProjection[] | null {
152
+ if (raw === null) return [];
153
+ if (!Array.isArray(raw)) return null;
154
+
155
+ const overlays: StoryOverlayProjection[] = [];
156
+ let hasQuestion = false;
157
+ for (const candidate of raw.slice(0, MAX_STORY_OVERLAYS)) {
158
+ if (!isRecord(candidate)) continue;
159
+ const position = normalizedPosition(candidate.position);
160
+ if (!position) continue;
161
+
162
+ if (candidate.type === "Question") {
163
+ if (hasQuestion) continue;
164
+ const oneOf = normalizedQuestionOptions(candidate.oneOf);
165
+ if (!oneOf) continue;
166
+ hasQuestion = true;
167
+ const name = boundedText(candidate.name, MAX_STORY_TEXT_LENGTH);
168
+ overlays.push({
169
+ type: "Question",
170
+ position,
171
+ ...(name ? { name } : {}),
172
+ oneOf,
173
+ });
174
+ continue;
175
+ }
176
+
177
+ if (candidate.type === "Link") {
178
+ const href =
179
+ typeof candidate.href === "string" ? candidate.href.trim() : "";
180
+ if (
181
+ href.length === 0 ||
182
+ href.length > MAX_STORY_URL_LENGTH ||
183
+ !isSafeRemoteUrl(href)
184
+ ) {
185
+ continue;
186
+ }
187
+ const name = boundedText(candidate.name, MAX_STORY_TEXT_LENGTH);
188
+ overlays.push({
189
+ type: "Link",
190
+ position,
191
+ ...(name ? { name } : {}),
192
+ href,
193
+ });
194
+ continue;
195
+ }
196
+
197
+ if (candidate.type === "Note") {
198
+ const name = boundedText(candidate.name, MAX_STORY_TEXT_LENGTH);
199
+ overlays.push({
200
+ type: "Note",
201
+ position,
202
+ ...(name ? { name } : {}),
203
+ });
204
+ }
205
+ }
206
+ return overlays;
207
+ }
208
+
209
+ function normalizedDisplayDuration(raw: unknown): string | null {
210
+ if (typeof raw !== "string") return null;
211
+ const match = raw
212
+ .trim()
213
+ .match(
214
+ /^PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/,
215
+ );
216
+ if (!match || (!match[1] && !match[2] && !match[3])) return null;
217
+ const seconds =
218
+ Number(match[1] ?? 0) * 3600 +
219
+ Number(match[2] ?? 0) * 60 +
220
+ Number(match[3] ?? 0);
221
+ if (
222
+ !Number.isFinite(seconds) ||
223
+ seconds < 0.001 ||
224
+ seconds > MAX_DISPLAY_DURATION_SECONDS
225
+ ) {
226
+ return null;
227
+ }
228
+ return `PT${Number(seconds.toFixed(3))}S`;
229
+ }
230
+
231
+ function parseStoredStoryProjection(
232
+ json: string,
233
+ ): InboundStoryProjection | null {
234
+ let raw: unknown;
235
+ try {
236
+ raw = JSON.parse(json);
237
+ } catch {
238
+ return null;
239
+ }
240
+ if (!isRecord(raw)) return null;
241
+ const attachment = normalizedRemoteAttachment(raw.attachment);
242
+ const displayDuration = normalizedDisplayDuration(raw.displayDuration);
243
+ if (!attachment || !displayDuration) return null;
244
+
245
+ const overlays =
246
+ raw.overlays === undefined ? undefined : normalizedOverlays(raw.overlays);
247
+ if (overlays === null) return null;
248
+ const caption = boundedText(raw.caption, MAX_STORY_CAPTION_LENGTH);
249
+ return {
250
+ attachment,
251
+ displayDuration,
252
+ ...(caption ? { caption } : {}),
253
+ ...(overlays && overlays.length > 0 ? { overlays } : {}),
254
+ };
255
+ }
256
+
257
+ function serializedProjection(
258
+ projection: InboundStoryProjection,
259
+ dropOverlaysWhenOversized: boolean,
260
+ ): InboundStoryProjectionResult | null {
261
+ let normalized = projection;
262
+ let json = JSON.stringify(normalized);
263
+ if (
264
+ json.length > MAX_ATTACHMENTS_JSON_LENGTH &&
265
+ dropOverlaysWhenOversized &&
266
+ normalized.overlays
267
+ ) {
268
+ const { overlays: _overlays, ...withoutOverlays } = normalized;
269
+ normalized = withoutOverlays;
270
+ json = JSON.stringify(normalized);
271
+ }
272
+ return json.length <= MAX_ATTACHMENTS_JSON_LENGTH
273
+ ? { projection: normalized, json }
274
+ : null;
275
+ }
276
+
277
+ /** Build the canonical stored projection for a newly received Story. */
278
+ export function buildInboundStoryCreateProjection(
279
+ object: ActivityObject,
280
+ ): InboundStoryProjectionResult | null {
281
+ const attachment = normalizedRemoteAttachment(object.attachment);
282
+ if (!attachment) return null;
283
+ const displayDuration =
284
+ object.displayDuration === undefined
285
+ ? DEFAULT_DISPLAY_DURATION
286
+ : normalizedDisplayDuration(object.displayDuration);
287
+ if (!displayDuration) return null;
288
+ const overlays =
289
+ object.overlays === undefined
290
+ ? undefined
291
+ : normalizedOverlays(object.overlays);
292
+ if (overlays === null) return null;
293
+ const caption = boundedText(object.content, MAX_STORY_CAPTION_LENGTH);
294
+
295
+ return serializedProjection(
296
+ {
297
+ attachment,
298
+ displayDuration,
299
+ ...(caption ? { caption } : {}),
300
+ ...(overlays && overlays.length > 0 ? { overlays } : {}),
301
+ },
302
+ true,
303
+ );
304
+ }
305
+
306
+ /** Apply a presence-sensitive inbound Story patch to its stored projection. */
307
+ export function buildInboundStoryUpdateProjection(
308
+ object: ActivityObject,
309
+ existingJson: string,
310
+ ): InboundStoryProjectionResult | null {
311
+ const existing = parseStoredStoryProjection(existingJson);
312
+ if (!existing) return null;
313
+
314
+ const attachment =
315
+ object.attachment === undefined
316
+ ? existing.attachment
317
+ : normalizedRemoteAttachment(object.attachment);
318
+ if (!attachment) return null;
319
+ const displayDuration =
320
+ object.displayDuration === undefined
321
+ ? existing.displayDuration
322
+ : normalizedDisplayDuration(object.displayDuration);
323
+ if (!displayDuration) return null;
324
+ const overlays =
325
+ object.overlays === undefined
326
+ ? existing.overlays
327
+ : normalizedOverlays(object.overlays);
328
+ if (overlays === null) return null;
329
+ const caption =
330
+ object.content === undefined
331
+ ? existing.caption
332
+ : boundedText(object.content, MAX_STORY_CAPTION_LENGTH);
333
+
334
+ return serializedProjection(
335
+ {
336
+ attachment,
337
+ displayDuration,
338
+ ...(caption ? { caption } : {}),
339
+ ...(overlays && overlays.length > 0 ? { overlays } : {}),
340
+ },
341
+ false,
342
+ );
343
+ }
344
+
345
+ export function hasStoryProjectionUpdate(object: ActivityObject): boolean {
346
+ return (
347
+ object.attachment !== undefined ||
348
+ object.displayDuration !== undefined ||
349
+ object.content !== undefined ||
350
+ object.overlays !== undefined
351
+ );
352
+ }
353
+
354
+ function appendAddresses(target: Set<string>, value: unknown): boolean {
355
+ const candidates =
356
+ typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
357
+ for (const candidate of candidates) {
358
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
359
+ if (candidate.length > MAX_STORY_URL_LENGTH) return true;
360
+ if (target.has(candidate)) continue;
361
+ if (target.size >= MAX_STORY_ADDRESSES) return true;
362
+ target.add(candidate);
363
+ }
364
+ return false;
365
+ }
366
+
367
+ const STORY_ADDRESS_FIELDS = ["to", "cc", "bto", "bcc", "audience"] as const;
368
+
369
+ /** Collect both envelope and embedded-object reach, including hidden fields. */
370
+ export function storyAddressedCollections(
371
+ activity: Activity,
372
+ object: ActivityObject,
373
+ ): { addresses: string[]; overflow: boolean } {
374
+ const addresses = new Set<string>();
375
+ for (const source of [activity, object]) {
376
+ for (const field of STORY_ADDRESS_FIELDS) {
377
+ if (appendAddresses(addresses, source[field])) {
378
+ return { addresses: [...addresses], overflow: true };
379
+ }
380
+ }
381
+ }
382
+ return { addresses: [...addresses], overflow: false };
383
+ }
384
+
385
+ export function declaresStoryAddressing(
386
+ activity: Activity,
387
+ object: ActivityObject,
388
+ ): boolean {
389
+ return [activity, object].some((source) =>
390
+ STORY_ADDRESS_FIELDS.some((field) => source[field] !== undefined),
391
+ );
392
+ }
393
+
394
+ /** Normalize and clamp a Create(Story) expiry; already-expired input is dropped. */
395
+ export function normalizeInboundStoryCreateEndTime(
396
+ publishedAt: string,
397
+ requestedEndTime: string | undefined,
398
+ nowIso: string,
399
+ ): string | null {
400
+ const publishedMs = Date.parse(publishedAt);
401
+ const nowMs = Date.parse(nowIso);
402
+ if (!Number.isFinite(publishedMs) || !Number.isFinite(nowMs)) return null;
403
+ const maxEndMs = publishedMs + MAX_STORY_LIFETIME_MS;
404
+ const requestedMs = requestedEndTime ? Date.parse(requestedEndTime) : NaN;
405
+ const endMs = Number.isFinite(requestedMs)
406
+ ? Math.min(requestedMs, maxEndMs)
407
+ : maxEndMs;
408
+ return endMs > nowMs ? new Date(endMs).toISOString() : null;
409
+ }
410
+
411
+ /** Preserve/shorten an existing Story expiry; never extend or revive it. */
412
+ export function normalizeInboundStoryUpdateEndTime(
413
+ publishedAt: string,
414
+ existingEndTime: string | null,
415
+ requestedEndTime: string | undefined,
416
+ nowIso: string,
417
+ ): string | null {
418
+ const publishedMs = Date.parse(publishedAt);
419
+ const existingMs = existingEndTime ? Date.parse(existingEndTime) : NaN;
420
+ const nowMs = Date.parse(nowIso);
421
+ if (
422
+ !Number.isFinite(publishedMs) ||
423
+ !Number.isFinite(existingMs) ||
424
+ !Number.isFinite(nowMs) ||
425
+ existingMs <= nowMs
426
+ ) {
427
+ return null;
428
+ }
429
+ const requestedMs =
430
+ requestedEndTime === undefined ? existingMs : Date.parse(requestedEndTime);
431
+ if (!Number.isFinite(requestedMs)) return null;
432
+ const endMs = Math.min(
433
+ existingMs,
434
+ requestedMs,
435
+ publishedMs + MAX_STORY_LIFETIME_MS,
436
+ );
437
+ return new Date(endMs).toISOString();
438
+ }