@takosjp/yurucommu-core 3.4.0 → 3.4.3

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 (39) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/lib/api/notifications.ts +1 -0
  5. package/packages/api/src/lib/api/posts.ts +1 -0
  6. package/src/backend/index.ts +62 -12
  7. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  8. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  9. package/src/backend/lib/delivery/queue.ts +13 -9
  10. package/src/backend/lib/delivery/types.ts +12 -0
  11. package/src/backend/lib/notification-push.ts +2 -2
  12. package/src/backend/lib/oauth-providers.ts +9 -0
  13. package/src/backend/lib/strip-image-metadata.ts +50 -30
  14. package/src/backend/middleware/bearer-auth.ts +24 -9
  15. package/src/backend/public.ts +38 -1
  16. package/src/backend/retention.ts +78 -0
  17. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  18. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  19. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  20. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  21. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  22. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  23. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  24. package/src/backend/routes/activitypub/inbox.ts +410 -205
  25. package/src/backend/routes/activitypub/outbox.ts +0 -0
  26. package/src/backend/routes/actors.ts +5 -5
  27. package/src/backend/routes/auth.ts +2 -1
  28. package/src/backend/routes/posts/post-helpers.ts +42 -23
  29. package/src/backend/routes/stories/routes.ts +5 -7
  30. package/src/backend/runtime/cloudflare.ts +63 -2
  31. package/src/backend/runtime/managed-relational.ts +197 -0
  32. package/src/backend/runtime/managed-runtime.ts +631 -0
  33. package/src/backend/runtime/queue.ts +40 -0
  34. package/src/backend/server.ts +15 -18
  35. package/src/backend/types.ts +9 -2
  36. package/src/db/d1-write.ts +270 -0
  37. package/src/db/index.ts +17 -0
  38. package/src/db/schema/federation.ts +19 -0
  39. package/src/db/schema/index.ts +1 -0
@@ -19,7 +19,6 @@
19
19
  * TAKOS_URL - Optional Takos API base URL for proxy/tool integration
20
20
  */
21
21
 
22
- import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
23
22
  import { mkdir, readdir, readFile, stat } from "node:fs/promises";
24
23
  import process from "node:process";
25
24
  import { and, inArray, lt, or } from "drizzle-orm";
@@ -33,6 +32,13 @@ import type {
33
32
  import { buildDeliverEndpointMessage } from "./lib/delivery/queue.ts";
34
33
  import { deliveryQueue, getDbSQLite } from "../db/index.ts";
35
34
  import { logger } from "./lib/logger.ts";
35
+ import type {
36
+ IQueueBatch,
37
+ IQueueMessage,
38
+ IQueueProducer,
39
+ QueueBatchItem,
40
+ QueueSendOptions,
41
+ } from "./runtime/queue.ts";
36
42
 
37
43
  const log = logger.child({ component: "server.bootstrap" });
38
44
 
@@ -111,21 +117,12 @@ function isTruthyEnv(value: string | undefined): boolean {
111
117
 
112
118
  type LocalQueueBody = DeliveryQueueMessageV1 | DeliveryDlqMessageV1;
113
119
 
114
- type LocalQueueSendOptions = {
115
- delaySeconds?: number;
116
- };
117
-
118
- type LocalQueueBatchItem<T> = {
119
- body: T;
120
- delaySeconds?: number;
121
- };
122
-
123
120
  function createLocalMessageBatch<T extends LocalQueueBody>(
124
121
  queueName: string,
125
122
  bodies: T[],
126
123
  requeue: (body: T, delaySeconds?: number) => void,
127
- ): MessageBatch<T> {
128
- const messages = bodies.map((body): Message<T> => {
124
+ ): IQueueBatch<T> {
125
+ const messages = bodies.map((body): IQueueMessage<T> => {
129
126
  let settled = false;
130
127
  return {
131
128
  id: crypto.randomUUID(),
@@ -140,7 +137,7 @@ function createLocalMessageBatch<T extends LocalQueueBody>(
140
137
  settled = true;
141
138
  requeue(body, options?.delaySeconds);
142
139
  },
143
- } as Message<T>;
140
+ };
144
141
  });
145
142
 
146
143
  return {
@@ -152,13 +149,13 @@ function createLocalMessageBatch<T extends LocalQueueBody>(
152
149
  retryAll: (options?: { delaySeconds?: number }) => {
153
150
  for (const message of messages) message.retry(options);
154
151
  },
155
- } as unknown as MessageBatch<T>;
152
+ };
156
153
  }
157
154
 
158
155
  function createLocalQueue<T extends LocalQueueBody>(
159
156
  env: LocalServerEnv,
160
157
  queueName: string,
161
- ): Queue<T> {
158
+ ): IQueueProducer<T> {
162
159
  const pending: T[] = [];
163
160
  let draining = false;
164
161
  let drainScheduled = false;
@@ -206,15 +203,15 @@ function createLocalQueue<T extends LocalQueueBody>(
206
203
  }
207
204
 
208
205
  return {
209
- send: async (body: T, options?: LocalQueueSendOptions) => {
206
+ send: async (body: T, options?: QueueSendOptions) => {
210
207
  enqueue(body, options?.delaySeconds);
211
208
  },
212
- sendBatch: async (messages: Array<LocalQueueBatchItem<T>>) => {
209
+ sendBatch: async (messages: readonly QueueBatchItem<T>[]) => {
213
210
  for (const message of messages) {
214
211
  enqueue(message.body, message.delaySeconds);
215
212
  }
216
213
  },
217
- } as unknown as Queue<T>;
214
+ };
218
215
  }
219
216
 
220
217
  function attachLocalDeliveryQueues(env: LocalServerEnv): void {
@@ -4,6 +4,7 @@ import type {
4
4
  IObjectStorage,
5
5
  IStaticAssets,
6
6
  } from "./runtime/types.ts";
7
+ import type { IQueueProducer } from "./runtime/queue.ts";
7
8
  import type {
8
9
  DeliveryDlqMessageV1,
9
10
  DeliveryQueueMessageV1,
@@ -34,8 +35,14 @@ export interface EnvVars {
34
35
  // Pins the OIDC/OAuth subject allowed to take the single owner slot. When set,
35
36
  // only a first-login whose subject equals this value becomes `owner`; any other
36
37
  // first-login is refused. Prevents an owner-slot race on an OIDC-seeded Capsule.
38
+ // Accepts either the bare issuer subject or the namespaced `<provider>:<sub>`.
37
39
  OIDC_OWNER_SUB?: string;
38
40
  TAKOSUMI_ACCOUNTS_OWNER_SUB?: string;
41
+ // Explicit opt-in to let an UNPINNED first OAuth/OIDC login take the owner
42
+ // slot. Without a pin and without this flag, owner creation over OAuth is
43
+ // refused. It exists because a pairwise subject cannot be known before the
44
+ // first login: enable it, sign in, then pin OIDC_OWNER_SUB and clear it.
45
+ ALLOW_UNPINNED_OWNER_CLAIM?: string;
39
46
  // Comma-separated allowlist of OAuth/OIDC subjects permitted to auto-provision
40
47
  // a NON-owner (member) account. Empty/unset = member auto-provisioning is
41
48
  // CLOSED (single-user default): once the owner exists, no new external subject
@@ -141,8 +148,8 @@ export type Env = {
141
148
  MEDIA?: IObjectStorage;
142
149
  KV: IKeyValueStore;
143
150
  ASSETS?: IStaticAssets;
144
- DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
145
- DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
151
+ DELIVERY_QUEUE?: IQueueProducer<DeliveryQueueMessageV1>;
152
+ DELIVERY_DLQ?: IQueueProducer<DeliveryDlqMessageV1>;
146
153
  // Signaling hub for the call feature. Passes through wrapCloudflareBindings
147
154
  // untouched (it is not one of DB/MEDIA/KV/ASSETS). Optional: when unbound the
148
155
  // call routes 503 and the rest of the app serves normally.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * D1-safe write primitives (yurucommu-core owner module).
3
+ *
4
+ * Cloudflare D1 rejects any statement that binds more than 100 parameters and
5
+ * has no interactive transactions (`BEGIN`/`COMMIT` cannot be composed across
6
+ * its stateless prepared-statement round-trips). Neither limit exists on the
7
+ * libsql / bun:sqlite engines the test suite runs on (~32k parameters, real
8
+ * transactions), so an unbounded multi-row INSERT is green in CI and throws
9
+ * "too many SQL variables" in production.
10
+ *
11
+ * The dangerous shape is not the throw itself: it is `DELETE` followed by a
12
+ * separate `INSERT`. The DELETE has already committed when the INSERT is
13
+ * rejected, so exceeding the ceiling is DATA LOSS rather than a 500.
14
+ *
15
+ * This module makes that shape unrepresentable:
16
+ * - {@link insertMany} chunks by the real parameter budget and RETURNS
17
+ * statements instead of executing them, so a caller cannot accidentally
18
+ * issue a partial write;
19
+ * - {@link replaceSet} is the only supported delete-then-insert and emits a
20
+ * single `db.batch([...])`, which both drivers commit atomically — the
21
+ * DELETE cannot survive a failed INSERT because there is no way to hand it
22
+ * to the driver on its own.
23
+ *
24
+ * The same export surface exists in the other owner repos (takos, takosumi,
25
+ * takos-git, road-to-me); a root gate asserts the names and constants match.
26
+ * See `docs/quality/d1-write.md`.
27
+ */
28
+
29
+ import { getTableColumns, sql, type SQL } from "drizzle-orm";
30
+ import type { BatchItem } from "drizzle-orm/batch";
31
+ import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
32
+ import type { Database } from "./index.ts";
33
+
34
+ /**
35
+ * Bound-parameter budget for a single statement. D1's hard ceiling is 100; the
36
+ * remaining 10 are headroom for the WHERE / conflict-target parameters that
37
+ * share the statement with the row values.
38
+ */
39
+ export const D1_SAFE_PARAM_BUDGET = 90;
40
+
41
+ /**
42
+ * Statements per `db.batch([...])`. D1 accepts larger batches but degrades, and
43
+ * a batch this size already means the caller is writing an unbounded set in one
44
+ * shot — which is the pattern this module exists to stop.
45
+ */
46
+ export const D1_MAX_BATCH_STATEMENTS = 50;
47
+
48
+ /** A driver-executable statement, as accepted by `db.batch([...])`. */
49
+ export type D1Statement = BatchItem<"sqlite">;
50
+
51
+ /** The row shape or the caller's usage cannot be expressed within D1's limits. */
52
+ export class D1WriteShapeError extends Error {
53
+ constructor(message: string) {
54
+ super(message);
55
+ this.name = "D1WriteShapeError";
56
+ }
57
+ }
58
+
59
+ /** The driver in use does not expose the atomic `batch()` surface. */
60
+ export class D1BatchUnsupportedError extends Error {
61
+ constructor(message: string) {
62
+ super(message);
63
+ this.name = "D1BatchUnsupportedError";
64
+ }
65
+ }
66
+
67
+ /** The write needs more statements than one batch may carry. */
68
+ export class D1BatchTooLargeError extends Error {
69
+ constructor(message: string) {
70
+ super(message);
71
+ this.name = "D1BatchTooLargeError";
72
+ }
73
+ }
74
+
75
+ interface BatchableDb {
76
+ batch(statements: readonly [D1Statement, ...D1Statement[]]): Promise<unknown>;
77
+ }
78
+
79
+ function requireBatchable(db: Database, context: string): BatchableDb {
80
+ const candidate = db as unknown as Partial<BatchableDb>;
81
+ if (typeof candidate.batch !== "function") {
82
+ throw new D1BatchUnsupportedError(
83
+ `${context}: the active driver exposes no batch(); a delete-then-insert ` +
84
+ `cannot be made atomic without it. Both the D1 and libsql drivers do — ` +
85
+ `a driver that does not is a test double that must gain one.`,
86
+ );
87
+ }
88
+ return candidate as BatchableDb;
89
+ }
90
+
91
+ /**
92
+ * Bound parameters Drizzle emits for one row of `rows`.
93
+ *
94
+ * Drizzle builds ONE column list for the whole `values([...])` call (the union
95
+ * of the keys present across the rows) and binds a parameter per column per
96
+ * row, so the budget must be divided by that union — not by the keys of the
97
+ * first row.
98
+ */
99
+ function paramsPerRow(
100
+ table: SQLiteTable,
101
+ rows: readonly Record<string, unknown>[],
102
+ ): number {
103
+ const keys = new Set<string>();
104
+ for (const row of rows) {
105
+ for (const key of Object.keys(row)) keys.add(key);
106
+ }
107
+ const known = new Set(Object.keys(getTableColumns(table)));
108
+ for (const key of keys) {
109
+ if (!known.has(key)) {
110
+ throw new D1WriteShapeError(
111
+ `insertMany: row key "${key}" is not a column of the target table`,
112
+ );
113
+ }
114
+ }
115
+ return keys.size;
116
+ }
117
+
118
+ /**
119
+ * Build the INSERT statements for `rows`, chunked so no statement exceeds
120
+ * {@link D1_SAFE_PARAM_BUDGET} bound parameters.
121
+ *
122
+ * Returns statements; it deliberately does NOT execute them. Executing a
123
+ * multi-statement write is only safe inside one `db.batch([...])`, and making
124
+ * the caller pass the statements to a batch is what keeps a half-applied write
125
+ * from being expressible.
126
+ */
127
+ export function insertMany<TTable extends SQLiteTable>(
128
+ db: Database,
129
+ table: TTable,
130
+ rows: readonly TTable["$inferInsert"][],
131
+ opts?: { readonly conflict?: "error" | "ignore" },
132
+ ): readonly D1Statement[] {
133
+ if (rows.length === 0) return [];
134
+
135
+ const perRow = paramsPerRow(
136
+ table,
137
+ rows as readonly Record<string, unknown>[],
138
+ );
139
+ if (perRow === 0) {
140
+ throw new D1WriteShapeError("insertMany: rows bind no columns");
141
+ }
142
+ if (perRow > D1_SAFE_PARAM_BUDGET) {
143
+ throw new D1WriteShapeError(
144
+ `insertMany: a single row binds ${perRow} parameters, over the D1 ` +
145
+ `budget of ${D1_SAFE_PARAM_BUDGET}; no chunking can make this fit`,
146
+ );
147
+ }
148
+
149
+ const chunkSize = Math.floor(D1_SAFE_PARAM_BUDGET / perRow);
150
+ const statements: D1Statement[] = [];
151
+ for (let offset = 0; offset < rows.length; offset += chunkSize) {
152
+ const chunk = rows.slice(offset, offset + chunkSize);
153
+ // Drizzle's insert builder is generic over the table; the concrete row type
154
+ // is already checked by the public signature, so the builder-local widening
155
+ // here is the narrowest cast that keeps the call site typed.
156
+ const builder = db
157
+ .insert(table)
158
+ .values(chunk as TTable["$inferInsert"][]) as unknown as D1Statement & {
159
+ onConflictDoNothing(): D1Statement;
160
+ };
161
+ statements.push(
162
+ opts?.conflict === "ignore" ? builder.onConflictDoNothing() : builder,
163
+ );
164
+ }
165
+
166
+ if (statements.length > D1_MAX_BATCH_STATEMENTS) {
167
+ throw new D1BatchTooLargeError(
168
+ `insertMany: ${rows.length} rows need ${statements.length} statements, ` +
169
+ `over the batch cap of ${D1_MAX_BATCH_STATEMENTS}. Do not split the ` +
170
+ `batch — that reintroduces the partial-write window this module ` +
171
+ `exists to remove. Page the source set instead, or apply the ` +
172
+ `generational-replace pattern in docs/quality/d1-write.md §3.`,
173
+ );
174
+ }
175
+ return statements;
176
+ }
177
+
178
+ /**
179
+ * Atomically replace the rows matching `where` with `rows`.
180
+ *
181
+ * The DELETE and every chunked INSERT go to the driver as ONE batch, so a
182
+ * rejected INSERT rolls the DELETE back. There is no overload that returns the
183
+ * DELETE on its own.
184
+ */
185
+ export async function replaceSet<TTable extends SQLiteTable>(args: {
186
+ readonly db: Database;
187
+ readonly table: TTable;
188
+ readonly where: SQL;
189
+ readonly rows: readonly TTable["$inferInsert"][];
190
+ readonly conflict?: "error" | "ignore";
191
+ }): Promise<void> {
192
+ const { db, table, where, rows, conflict } = args;
193
+ const batch = requireBatchable(db, "replaceSet");
194
+ const inserts = insertMany(db, table, rows, { conflict });
195
+
196
+ if (inserts.length + 1 > D1_MAX_BATCH_STATEMENTS) {
197
+ throw new D1BatchTooLargeError(
198
+ `replaceSet: ${rows.length} rows need ${inserts.length + 1} statements, ` +
199
+ `over the batch cap of ${D1_MAX_BATCH_STATEMENTS}. See ` +
200
+ `docs/quality/d1-write.md §3.`,
201
+ );
202
+ }
203
+
204
+ const del = db.delete(table).where(where) as unknown as D1Statement;
205
+ await batch.batch([del, ...inserts] as [D1Statement, ...D1Statement[]]);
206
+ }
207
+
208
+ /**
209
+ * Execute `statements` as ONE atomic batch. Both the D1 and libsql drivers
210
+ * commit a batch all-or-nothing; this is the only atomicity primitive D1 has,
211
+ * since `BEGIN`/`COMMIT` cannot be composed across its stateless
212
+ * prepared-statement round-trips.
213
+ */
214
+ export async function runBatch(
215
+ db: Database,
216
+ statements: readonly [D1Statement, ...D1Statement[]],
217
+ ): Promise<void> {
218
+ await requireBatchable(db, "runBatch").batch(statements);
219
+ }
220
+
221
+ /**
222
+ * Run `run` once per chunk of at most {@link D1_SAFE_PARAM_BUDGET} ids and
223
+ * concatenate the results. Use for an `inArray(col, ids)` whose ids only exist
224
+ * as an in-memory array; prefer {@link notInSubquery} / a `db.select()`
225
+ * subquery when the ids are themselves a query result (zero bound parameters).
226
+ */
227
+ export async function inChunks<T, R>(
228
+ ids: readonly T[],
229
+ run: (chunk: readonly T[]) => Promise<R[]>,
230
+ ): Promise<R[]> {
231
+ if (ids.length === 0) return [];
232
+ if (ids.length <= D1_SAFE_PARAM_BUDGET) return await run(ids);
233
+ const out: R[] = [];
234
+ for (let i = 0; i < ids.length; i += D1_SAFE_PARAM_BUDGET) {
235
+ out.push(...(await run(ids.slice(i, i + D1_SAFE_PARAM_BUDGET))));
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /**
241
+ * `col NOT IN (<subquery>)` with zero per-element bound parameters.
242
+ *
243
+ * There is deliberately no `notInChunks`: NOT IN does not decompose over
244
+ * chunks (the per-chunk results must be INTERSECTED, not concatenated), and
245
+ * every attempt to write one has been wrong. Express the excluded set as a
246
+ * subquery instead.
247
+ */
248
+ export function notInSubquery(col: SQLiteColumn, subquery: SQL | unknown): SQL {
249
+ return sql`${col} NOT IN ${subquery}`;
250
+ }
251
+
252
+ /**
253
+ * Escape hatch for a statement whose parameter count is bounded by something
254
+ * the type system cannot see (a fixed-arity literal list, a `LIMIT`ed page).
255
+ *
256
+ * `reason.boundedBy` must name the concrete bound, and this call is what a
257
+ * reviewer greps for. It performs no work at runtime; its whole value is that
258
+ * an unchunked statement cannot be written without saying why.
259
+ */
260
+ export function unsafeUnchunkedStatement(
261
+ stmt: D1Statement,
262
+ reason: { readonly why: string; readonly boundedBy: string },
263
+ ): D1Statement {
264
+ if (!reason.why.trim() || !reason.boundedBy.trim()) {
265
+ throw new D1WriteShapeError(
266
+ "unsafeUnchunkedStatement: both `why` and `boundedBy` must be stated",
267
+ );
268
+ }
269
+ return stmt;
270
+ }
package/src/db/index.ts CHANGED
@@ -96,3 +96,20 @@ export function notDeleted(
96
96
 
97
97
  export { nowIso } from "./schema.ts";
98
98
  export * from "./schema.ts";
99
+
100
+ // D1-safe write primitives. Re-exported here so a writer reaching for the DB
101
+ // module finds the chunking/batch surface without knowing the file name.
102
+ export {
103
+ D1_MAX_BATCH_STATEMENTS,
104
+ D1_SAFE_PARAM_BUDGET,
105
+ D1BatchTooLargeError,
106
+ D1BatchUnsupportedError,
107
+ D1WriteShapeError,
108
+ inChunks,
109
+ insertMany,
110
+ notInSubquery,
111
+ replaceSet,
112
+ runBatch,
113
+ unsafeUnchunkedStatement,
114
+ type D1Statement,
115
+ } from "./d1-write.ts";
@@ -0,0 +1,19 @@
1
+ import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { nowIsoUtc } from "./date-utils.ts";
3
+
4
+ /**
5
+ * Short-lived fencing leases for inbound ActivityPub dispatch.
6
+ *
7
+ * The durable activity remains in `activities`; this table only identifies the
8
+ * Worker currently allowed to apply and commit its effects.
9
+ */
10
+ export const inboundActivityClaims = sqliteTable(
11
+ "inbound_activity_claims",
12
+ {
13
+ activityApId: text("activity_ap_id").primaryKey(),
14
+ processingToken: text("processing_token"),
15
+ leaseExpiresAt: text("lease_expires_at"),
16
+ updatedAt: text("updated_at").notNull().$defaultFn(nowIsoUtc),
17
+ },
18
+ (t) => [index("inbound_activity_claims_lease_idx").on(t.leaseExpiresAt)],
19
+ );
@@ -16,4 +16,5 @@ export * from "./notes.ts";
16
16
  export * from "./messaging.ts";
17
17
  export * from "./mobile.ts";
18
18
  export * from "./calls.ts";
19
+ export * from "./federation.ts";
19
20
  export * from "./relations.ts";