@takosjp/yurucommu-core 4.1.0 → 4.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -59,8 +59,9 @@
59
59
  "start": "bun src/backend/server.ts",
60
60
  "dev": "bun src/backend/server.ts",
61
61
  "dev:server": "bun src/backend/server.ts",
62
- "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run test",
62
+ "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run check:worker-bundle && bun run test",
63
63
  "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
64
+ "check:worker-bundle": "bun scripts/check-worker-bundle-portable.mjs",
64
65
  "test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
65
66
  "test:backend": "bun test src/backend/",
66
67
  "build": "bun run build:api",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import { Hono, type Context } from "hono";
1
+ import { Hono, type Context, type Next } from "hono";
2
2
  import { MOBILE_PUSH_REGISTRATION_PATH } from "./lib/mobile-contract.ts";
3
3
  import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher-contract.ts";
4
4
  import type { Env, EnvVars, Variables } from "./types.ts";
@@ -9,7 +9,13 @@ import {
9
9
  wrapRuntimeBindings,
10
10
  wrapRuntimeMessageBatch,
11
11
  type PortableWorkerBindings,
12
+ type RuntimeLane,
12
13
  } from "./runtime/lane.ts";
14
+ import {
15
+ configuredAppUrl,
16
+ establishRequestPublicOrigin,
17
+ withRequiredBackgroundPublicOrigin,
18
+ } from "./runtime/public-origin.ts";
13
19
  import type { EdgeQueueBatch } from "./runtime/edge-facades.ts";
14
20
  import {
15
21
  getMobileOidcAudience,
@@ -925,6 +931,65 @@ function mountStaticFallback(app: YurucommuApp): void {
925
931
  });
926
932
  }
927
933
 
934
+ /**
935
+ * Give this request an `APP_URL` when the Host, not the deployer, chose it.
936
+ *
937
+ * Registered BEFORE every other route so `/readyz` and the `.well-known`
938
+ * discovery documents see the same origin the rest of the app mints ids from —
939
+ * a readiness probe that reported `APP_URL` missing on a Worker whose origin
940
+ * only its own traffic can reveal would never go ready.
941
+ *
942
+ * It runs on the `portable` lane alone. There, a wrapper host routes by
943
+ * hostname and delivers the request it received on the Worker's public
944
+ * endpoint, so the request URL IS the assigned origin (see
945
+ * runtime/public-origin.ts). A Worker deployed straight to Cloudflare answers
946
+ * on workers.dev, on every custom domain, and on every route pattern its
947
+ * account holds, so the same inference there would let whichever hostname
948
+ * happened to arrive first name the instance for good; that lane keeps
949
+ * requiring an explicit `APP_URL` exactly as before.
950
+ *
951
+ * It never fails a request. When no origin can be established — the request is
952
+ * plain http, KV is unbound, an operator hand-wrote the pin — `APP_URL` simply
953
+ * stays unset, which `/readyz` already reports as a hard missing binding.
954
+ * Turning that into a 500 would take the readiness probe down with it and
955
+ * replace a precise answer with a generic one.
956
+ */
957
+ function publicOriginMiddleware() {
958
+ let warned = false;
959
+ return async (
960
+ c: Context<{ Bindings: Env; Variables: Variables }>,
961
+ next: Next,
962
+ ) => {
963
+ if (configuredAppUrl(c.env) !== null) return next();
964
+ // An unrecognised lane declaration is refused at the binding boundary by
965
+ // wrapRuntimeBindings. Reaching here with one means the app was composed
966
+ // directly, and the safe reading of "not a lane I know" is "do not infer".
967
+ let lane: RuntimeLane;
968
+ try {
969
+ lane = resolveRuntimeLane(c.env.YURUCOMMU_RUNTIME_LANE);
970
+ } catch {
971
+ return next();
972
+ }
973
+ if (lane !== "portable") return next();
974
+ try {
975
+ const origin = await establishRequestPublicOrigin(c.env, c.req.raw);
976
+ c.env = { ...c.env, APP_URL: origin };
977
+ warned = false;
978
+ } catch (error) {
979
+ // Once per isolate: this condition is a property of the deployment, not
980
+ // of the request, so it would otherwise repeat on every single one.
981
+ if (!warned) {
982
+ warned = true;
983
+ log.warn("Could not establish this instance's public origin", {
984
+ event: "runtime.public_origin.unestablished",
985
+ error,
986
+ });
987
+ }
988
+ }
989
+ return next();
990
+ };
991
+ }
992
+
928
993
  export function createYurucommuBackendApp(
929
994
  options: CreateYurucommuBackendAppOptionsV1 = {},
930
995
  ): YurucommuApp {
@@ -941,6 +1006,10 @@ export function createYurucommuBackendApp(
941
1006
  }
942
1007
  }
943
1008
 
1009
+ // Before the readiness probes, because they report on APP_URL and the
1010
+ // `.well-known` discovery documents publish it. Reads no body and consults no
1011
+ // route, so it does not weaken the body-cap ordering below.
1012
+ app.use("*", publicOriginMiddleware());
944
1013
  mountReadinessRoutes(app, options.discovery);
945
1014
  // Body-size cap must run BEFORE any handler reads the body or executes
946
1015
  // expensive auth / rate-limit logic. Mounted after readiness probes so
@@ -1056,9 +1125,15 @@ export default {
1056
1125
  bindings: WorkerBindings,
1057
1126
  ): Promise<void> {
1058
1127
  const lane = resolveRuntimeLane(bindings.YURUCOMMU_RUNTIME_LANE);
1128
+ // Federation delivery signs and addresses from this instance's own actor
1129
+ // ids, and a queue invocation has no request to learn the origin from. When
1130
+ // `APP_URL` is unset and no request has pinned one yet, this THROWS: the
1131
+ // batch is retried later, after traffic has established the origin, instead
1132
+ // of being delivered under `undefined/ap/users/…` to peers that would cache
1133
+ // it. See runtime/public-origin.ts.
1059
1134
  return handleYurucommuQueueBatch(
1060
1135
  wrapRuntimeMessageBatch(batch, lane),
1061
- wrapRuntimeBindings(bindings),
1136
+ await withRequiredBackgroundPublicOrigin(wrapRuntimeBindings(bindings)),
1062
1137
  );
1063
1138
  },
1064
1139
 
@@ -53,6 +53,21 @@ export {
53
53
  wrapRuntimeBindings,
54
54
  wrapRuntimeMessageBatch,
55
55
  } from "./runtime/lane.ts";
56
+ // The public origin: `APP_URL` when the deployment could carry one, and the
57
+ // origin one request established when only the Host knew it. A product that
58
+ // composes its own Worker entry uses these for the handlers the core default
59
+ // export does not own.
60
+ export {
61
+ CANONICAL_ORIGIN_KV_KEY,
62
+ PublicOriginError,
63
+ canonicalPublicOrigin,
64
+ configuredAppUrl,
65
+ establishRequestPublicOrigin,
66
+ peekObservedPublicOrigin,
67
+ requireBackgroundPublicOrigin,
68
+ resetObservedPublicOrigin,
69
+ withRequiredBackgroundPublicOrigin,
70
+ } from "./runtime/public-origin.ts";
56
71
  export {
57
72
  EDGE_KV_MAX_EXPIRATION_TTL_SECONDS,
58
73
  EDGE_KV_MIN_EXPIRATION_TTL_SECONDS,
@@ -24,7 +24,7 @@ import {
24
24
  assertPathChainWithinBasePath,
25
25
  isPathWithinBasePath,
26
26
  resolvePathWithinBasePath,
27
- } from "./shared.ts";
27
+ } from "./node-paths.ts";
28
28
  import { MemoryKV } from "./memory-kv.ts";
29
29
  import { isBackendPath } from "../lib/backend-paths.ts";
30
30
  import { loadBunSqlite } from "./compat-bun/types.ts";
@@ -9,7 +9,7 @@
9
9
  * The managed Cloudflare backend and the self-host backend project the SAME
10
10
  * facade: same methods, same option keys, same error names. Takoserver's
11
11
  * ADR 0005 states this explicitly for object storage, and its self-host wrapper
12
- * repeats it for KV and SQL.
12
+ * repeats it for all four.
13
13
  *
14
14
  * This module is a TYPE MIRROR of that contract plus the structural probes the
15
15
  * lane selector uses. It deliberately contains no behaviour: the adapters that
@@ -21,7 +21,8 @@
21
21
  * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
22
22
  * `createQueueAdapter`, `createEdgeObjectsR2Adapter`
23
23
  * takoserver `src/providers/selfhost-worker-wrapper.ts`
24
- * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`
24
+ * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
25
+ * `createQueueAdapter`, `createObjectsAdapter`
25
26
  *
26
27
  * Every method rejects with an `Error` whose `name` is the portable error code
27
28
  * (`invalid_key`, `invalid_value`, `value_too_large`, `metadata_too_large`,
@@ -235,22 +236,24 @@ function hasMethods(value: unknown, names: readonly string[]): boolean {
235
236
  /**
236
237
  * Structural probes.
237
238
  *
238
- * Only SOME bindings can be told apart by shape, and the difference matters:
239
+ * Only ONE binding can be told apart by shape, and the difference matters:
239
240
  *
240
241
  * decisive `DB` — `execute`/`query`/`transaction` (facade) against
241
242
  * `prepare`/`batch` (D1). Disjoint method sets.
242
- * decisive `MEDIA` — R2 carries the multipart helpers the facade omits.
243
243
  * decisive a queue *batch* — `acknowledgeAll` (facade) against `ackAll`
244
244
  * (Cloudflare `MessageBatch`).
245
245
  * AMBIGUOUS `KV` — `edge.kv` and `KVNamespace` expose the same five
246
246
  * method names.
247
247
  * AMBIGUOUS a queue *producer* — both are `send`/`sendBatch`.
248
+ * AMBIGUOUS `MEDIA` — `edge.objects@1.0.0` is R2's method set by design,
249
+ * multipart helpers included. See below.
248
250
  *
249
251
  * That is why the lane is a DECLARED variable rather than something sniffed:
250
- * two of the five bindings cannot be identified at all. The declaration is then
251
- * cross-checked against the decisive bindings, so a Worker whose var and whose
252
- * bindings disagree refuses to start instead of calling `kv.get(key, {type})`
253
- * on a facade that would silently treat the options object as nothing.
252
+ * three of the five bindings cannot be identified at all. The declaration is
253
+ * then cross-checked against the decisive bindings, so a Worker whose var and
254
+ * whose bindings disagree refuses to start instead of calling
255
+ * `kv.get(key, {type})` on a facade that would silently treat the options
256
+ * object as nothing.
254
257
  */
255
258
  export function isEdgeSqlBinding(value: unknown): value is EdgeSqlBinding {
256
259
  return (
@@ -274,24 +277,38 @@ export function isEdgeQueueBatch(value: unknown): value is EdgeQueueBatch {
274
277
  );
275
278
  }
276
279
 
280
+ /**
281
+ * A bucket-shaped binding with `edge.objects@1.0.0`'s call signatures.
282
+ *
283
+ * NOT DECISIVE, and never a lane test. The facade is R2's method set on
284
+ * purpose — `head`, `get`, `put`, `delete`, `list`, and the four multipart
285
+ * calls — so that an app written against R2 ports over unchanged. A real
286
+ * `R2Bucket` therefore answers `true` here too. Use it to check that SOMETHING
287
+ * bucket-shaped arrived, never to decide which host projected it: that is what
288
+ * `YURUCOMMU_RUNTIME_LANE` is for.
289
+ *
290
+ * The arity check is the facade's own contract rather than a discriminator:
291
+ * the Host counts `arguments.length`, so `get` takes its options slot even when
292
+ * that slot is `undefined`.
293
+ */
277
294
  export function isEdgeObjectsBinding(
278
295
  value: unknown,
279
296
  ): value is EdgeObjectsBinding {
280
297
  return (
281
298
  hasMethods(value, ["head", "get", "put", "delete", "list"]) &&
282
- // R2 exposes multipart helpers on the binding itself; the facade does not
283
- // give a bucket-shaped object those names.
284
- typeof (value as Record<string, unknown>).createMultipartUpload !==
285
- "function" &&
286
- // Arity is part of the facade's contract and is asserted rather than
287
- // assumed: the Host checks `arguments.length`, so `get` and `list` take
288
- // their options slot even when it is `undefined`. Anything bucket-shaped
289
- // whose `get` takes one argument is some other adapter, not this facade.
290
299
  (value as { get: (...args: unknown[]) => unknown }).get.length === 2
291
300
  );
292
301
  }
293
302
 
294
- /** Cloudflare's `R2Bucket`. */
303
+ /**
304
+ * Cloudflare's `R2Bucket` — and, unavoidably, the `edge.objects` facade.
305
+ *
306
+ * A method-name test cannot separate the two, because Takoserver's facade
307
+ * carries `createMultipartUpload` as well (`selfhost-worker-wrapper.ts`
308
+ * `createObjectsAdapter`). 4.1.0 used this function to refuse the portable
309
+ * lane and so refused every self-hosted deployment. Keep it for describing a
310
+ * binding; do not let it decide a lane.
311
+ */
295
312
  export function isNativeR2Bucket(value: unknown): boolean {
296
313
  return hasMethods(value, [
297
314
  "head",
@@ -21,10 +21,14 @@
21
21
  * - NO ENUMERATION OR HEAD. The port does not carry them, so neither does the
22
22
  * adapter, even though the Host projects both.
23
23
  *
24
- * AVAILABILITY: `edge.objects` is projected by the managed Cloudflare backend
25
- * (`createEdgeObjectsR2Adapter`). The self-host backend projects only
26
- * `edge.kv` and `edge.sql`, so a self-hosted Worker has no object binding and
27
- * the core's existing "object storage unavailable" behaviour applies.
24
+ * AVAILABILITY: BOTH wrapper backends project `edge.objects`. The managed
25
+ * Cloudflare backend does it over provider-private R2
26
+ * (`createEdgeObjectsR2Adapter`); the self-host backend realizes its own object
27
+ * store for a Version's `bucketBindings` and projects the same facade, byte for
28
+ * byte. A Worker on the `portable` lane therefore receives `env.MEDIA` on
29
+ * either host. What still leaves `MEDIA` unbound is a Version that declared no
30
+ * bucket at all, and the core's existing "object storage unavailable" (503)
31
+ * behaviour is what applies then.
28
32
  */
29
33
 
30
34
  import type {
@@ -14,12 +14,12 @@
14
14
  * and the body arrives as `{encoding:"base64", data}`. `retry` also refuses
15
15
  * `delaySeconds: 0`, which Cloudflare accepts as "no delay".
16
16
  *
17
- * AVAILABILITY: the managed Cloudflare backend projects queue bindings; the
18
- * self-host backend projects only `edge.kv` and `edge.sql` today (see
19
- * takoserver `selfhost-worker-wrapper.ts` `projectEnv`). A self-hosted Worker
20
- * therefore has no queue binding at all, and the core's existing behaviour for
21
- * an unbound `DELIVERY_QUEUE` — synchronous fallback delivery, reported by the
22
- * readiness surface — is what applies there.
17
+ * AVAILABILITY: both wrapper backends project queue bindings (see takoserver
18
+ * `selfhost-worker-wrapper.ts` `projectEnv`, whose data-binding kinds are
19
+ * `edge.kv`, `edge.objects`, `edge.queue` and `edge.sql`). What leaves
20
+ * `DELIVERY_QUEUE` unbound is a Version that declared no queue, and the core's
21
+ * existing behaviour for that — synchronous fallback delivery, reported by the
22
+ * readiness surface — is what applies then.
23
23
  */
24
24
 
25
25
  import {
@@ -28,8 +28,18 @@
28
28
  * So the lane comes from `YURUCOMMU_RUNTIME_LANE`, which a self-host or managed
29
29
  * Workers-for-Platforms deployment sets to `portable` and every raw-binding
30
30
  * deployment leaves unset (or `cloudflare`). The declaration is then
31
- * cross-checked against the bindings that ARE decisive — `DB` always, `MEDIA`
32
- * when it is bound. A disagreement refuses to start.
31
+ * cross-checked against the ONE binding that is decisive — `DB`. A
32
+ * disagreement refuses to start.
33
+ *
34
+ * `MEDIA` is NOT decisive and must never be cross-checked. Takoserver's
35
+ * `edge.objects@1.0.0` facade is method-for-method a bucket: `head`, `get`,
36
+ * `put`, `delete`, `list`, `createMultipartUpload`, `uploadPart`,
37
+ * `completeMultipartUpload`, `abortMultipartUpload` — the same names, the same
38
+ * option keys, deliberately, so that an app written against R2 ports over
39
+ * unchanged (ADR 0005/0007). 4.1.0 read that identity backwards and refused the
40
+ * portable lane whenever `MEDIA` looked R2-shaped, which is to say always: a
41
+ * self-hosted Yurucommu Worker could not boot on the lane its own README
42
+ * documents. The declaration decides `MEDIA`.
33
43
  */
34
44
 
35
45
  import type {
@@ -47,7 +57,6 @@ import {
47
57
  isEdgeQueueBatch,
48
58
  isEdgeSqlBinding,
49
59
  isNativeD1Database,
50
- isNativeR2Bucket,
51
60
  type EdgeKvBinding,
52
61
  type EdgeObjectsBinding,
53
62
  type EdgeQueueBatch,
@@ -114,23 +123,35 @@ export function resolveRuntimeLane(declared: unknown): RuntimeLane {
114
123
 
115
124
  interface LaneBindings {
116
125
  readonly DB?: unknown;
126
+ /**
127
+ * Accepted so a caller can pass the whole `env`, and deliberately not read:
128
+ * the bucket binding carries no evidence about the lane. See
129
+ * {@link assertRuntimeLaneBindings}.
130
+ */
117
131
  readonly MEDIA?: unknown;
118
132
  }
119
133
 
120
134
  /**
121
- * Prove the declared lane against the bindings that can actually be identified.
135
+ * Prove the declared lane against the ONE binding that can be identified.
136
+ *
137
+ * `DB` is decisive in both directions: `execute`/`query`/`transaction` and
138
+ * `prepare`/`batch` are disjoint method sets, so a Worker that was handed the
139
+ * wrong one would fail at its first query anyway and is better stopped here
140
+ * with a message that names the variable to fix.
122
141
  *
123
- * `DB` is always decisive: `execute`/`query`/`transaction` and
124
- * `prepare`/`batch` are disjoint. `MEDIA` is decisive only in one direction —
125
- * an `R2Bucket` is recognisable by its multipart helpers, whereas a plain
126
- * five-method object could be the facade or an adapter a host repository
127
- * supplied so only the direction that can be proven is checked.
142
+ * `MEDIA` is checked against NOTHING. The portable `edge.objects@1.0.0` facade
143
+ * is intentionally indistinguishable from an `R2Bucket` that identity is the
144
+ * point of the Interface so a shape test on it can only produce false
145
+ * refusals. On `portable` the bucket is wrapped as the facade, on `cloudflare`
146
+ * as native R2, and the declaration is the whole of the evidence. Getting it
147
+ * wrong is loud and immediate (the first `MEDIA` call throws), not the silent
148
+ * misread that `KV`'s ambiguity would cause.
128
149
  */
129
150
  export function assertRuntimeLaneBindings(
130
151
  lane: RuntimeLane,
131
152
  bindings: LaneBindings,
132
153
  ): void {
133
- const { DB, MEDIA } = bindings;
154
+ const { DB } = bindings;
134
155
  if (lane === "portable") {
135
156
  if (isNativeD1Database(DB)) {
136
157
  throw new RuntimeLaneError(
@@ -148,13 +169,6 @@ export function assertRuntimeLaneBindings(
148
169
  `neither that nor D1's prepare/batch.`,
149
170
  );
150
171
  }
151
- if (MEDIA !== undefined && isNativeR2Bucket(MEDIA)) {
152
- throw new RuntimeLaneError(
153
- `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
154
- `but env.MEDIA is a native R2Bucket. A portable bucket binding ` +
155
- `arrives as the edge.objects@1.0.0 facade.`,
156
- );
157
- }
158
172
  return;
159
173
  }
160
174
  if (isEdgeSqlBinding(DB)) {
@@ -287,5 +301,9 @@ export function wrapRuntimeMessageBatch<T>(
287
301
  return wrapCloudflareMessageBatch(batch as MessageBatch<T>);
288
302
  }
289
303
 
290
- /** Re-exported so a Worker entry can probe MEDIA without importing internals. */
304
+ /**
305
+ * Re-exported so a Worker entry can assert that SOMETHING bucket-shaped
306
+ * arrived without importing internals. It does not identify the lane — a
307
+ * native `R2Bucket` satisfies it too — so never branch on it.
308
+ */
291
309
  export { isEdgeObjectsBinding };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Filesystem path containment for the Bun/Node runtime ONLY.
3
+ *
4
+ * These helpers need `node:path`, so they live apart from `shared.ts`. That
5
+ * separation is load-bearing rather than tidy: `shared.ts` is reached from
6
+ * `edge-kv.ts` and `edge-objects.ts`, which are on the portable Worker's
7
+ * import path. A `node:` specifier anywhere in that graph survives bundling as
8
+ * a real static import, and a wrapper host (self-hosted Takoserver, managed
9
+ * Workers-for-Platforms) runs the Worker with no `nodejs_compat` flag — the
10
+ * portable `WorkerVersion` form has nowhere to ask for one — so the module
11
+ * fails to load with `No such module "node:path"` before a single request is
12
+ * served. Nothing in this file may be imported from a module the Worker
13
+ * bundle reaches; `scripts/check-worker-bundle-portable.mjs` enforces that.
14
+ */
15
+
16
+ import path from "node:path";
17
+
18
+ import { hasNulByte } from "./shared.ts";
19
+
20
+ export function isPathWithinBasePath(
21
+ basePath: string,
22
+ candidatePath: string,
23
+ ): boolean {
24
+ const relative = path.relative(basePath, candidatePath);
25
+ return (
26
+ relative === "" ||
27
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
28
+ );
29
+ }
30
+
31
+ export function resolvePathWithinBasePath(
32
+ basePath: string,
33
+ key: string,
34
+ ): string {
35
+ if (hasNulByte(key)) {
36
+ throw new Error("Invalid path");
37
+ }
38
+ const resolvedPath = path.resolve(basePath, key);
39
+ if (!isPathWithinBasePath(basePath, resolvedPath)) {
40
+ throw new Error("Path escapes base directory");
41
+ }
42
+ return resolvedPath;
43
+ }
44
+
45
+ function isNotFoundError(error: unknown): boolean {
46
+ return (
47
+ typeof error === "object" &&
48
+ error !== null &&
49
+ "code" in error &&
50
+ (error as { code?: unknown }).code === "ENOENT"
51
+ );
52
+ }
53
+
54
+ export async function assertPathChainWithinBasePath(
55
+ basePath: string,
56
+ targetPath: string,
57
+ realpath: (path: string) => Promise<string>,
58
+ ): Promise<void> {
59
+ let currentPath = targetPath;
60
+
61
+ while (true) {
62
+ try {
63
+ const realCurrentPath = await realpath(currentPath);
64
+ if (!isPathWithinBasePath(basePath, realCurrentPath)) {
65
+ throw new Error("Path escapes base directory");
66
+ }
67
+ return;
68
+ } catch (error) {
69
+ if (!isNotFoundError(error)) {
70
+ throw error;
71
+ }
72
+ const parentPath = path.dirname(currentPath);
73
+ if (parentPath === currentPath) {
74
+ throw error;
75
+ }
76
+ currentPath = parentPath;
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The one absolute origin this instance is: `APP_URL`, or the one a request
3
+ * established when the Host — not the deployer — chose it.
4
+ *
5
+ * Every federated identity this app mints is absolute. Actor ids, activity and
6
+ * object ids, `inbox` / `outbox` / `followers` collections, the OIDC
7
+ * `redirect_uri`, notification links, and the `.well-known` discovery documents
8
+ * are all `${APP_URL}/…`, and a wrong one is not a broken page — it is a
9
+ * permanent, federated wrong answer that remote servers have already cached.
10
+ *
11
+ * `APP_URL` is a plain variable, and on a wrapper host it cannot always be one.
12
+ * A Takoform `WorkerEndpoint` allocates the Worker's public origin AFTER the
13
+ * `WorkerVersion` that would have carried the variable is already immutable, so
14
+ * the deployer does not know the value at apply time and there is no second
15
+ * apply that could inject it. The origin exists, but only the Host knows it,
16
+ * and the only place it is ever spoken is on the requests the Host routes here.
17
+ *
18
+ * So on the `portable` lane an unset `APP_URL` is answered by OBSERVING one
19
+ * request and PINNING what it observed:
20
+ *
21
+ * 1. `APP_URL` is authoritative whenever it is set. It is used exactly as the
22
+ * operator wrote it and is never validated, cached, or persisted here —
23
+ * an operator who sets it has already decided, and this module has no
24
+ * standing to refuse a value the previous release accepted.
25
+ * 2. Otherwise the origin PINNED IN KV wins, for every request and for
26
+ * background work alike. First writer wins: once a value is stored, no
27
+ * later request replaces it, whatever `Host` that request carried.
28
+ * 3. Otherwise a request may establish it, from the request URL's own origin
29
+ * and from nothing else.
30
+ * 4. Otherwise there is no origin, and background work refuses rather than
31
+ * minting `undefined/ap/users/alice`.
32
+ *
33
+ * WHAT IS TRUSTED. The request URL as the runtime delivers it, and only that.
34
+ * Not `X-Forwarded-Host`, not `X-Forwarded-Proto`, not `Host` read out of the
35
+ * headers — nothing a client can write. Both wrapper hosts route by hostname
36
+ * and deliver the request they received on the Worker's own public endpoint:
37
+ * Takoserver's managed Workers-for-Platforms gateway looks up a host route for
38
+ * `new URL(request.url).hostname` and dispatches the SAME `Request` object, and
39
+ * the self-host workerd router picks a service from a table keyed by hostname
40
+ * and forwards unchanged. A hostname nobody published for this Worker is a 404
41
+ * before any of this code runs, so the origin on the request is one the Host
42
+ * assigned — which is exactly the value that could not be delivered as a var.
43
+ *
44
+ * WHY HTTPS. A public fediverse origin is https, and Takoserver's own
45
+ * `WorkerEndpoint` can only ever assign an https origin. Requiring it here
46
+ * means an http request cannot pin an origin that would then sign deliveries
47
+ * and mint actor ids. Loopback http is the one exception, because `localhost`
48
+ * is not routable and is the origin a developer actually serves on.
49
+ *
50
+ * A wrapper host that terminates TLS in FRONT of workerd and speaks plain http
51
+ * to it therefore establishes nothing: `request.url` is `http://…` and the
52
+ * derivation refuses. That deployment must set `APP_URL`, which it can, because
53
+ * an operator who terminates TLS chose the hostname themselves. Refusing is the
54
+ * point — the alternative is trusting a forwarded-proto header that the same
55
+ * proxy may or may not be the only writer of.
56
+ */
57
+
58
+ import type { Env, EnvVars } from "../types.ts";
59
+ import type { IKeyValueStore } from "./types.ts";
60
+
61
+ /**
62
+ * Where the observed origin is pinned.
63
+ *
64
+ * The key is shared with the origin pin Yurucommu's own generated Worker entry
65
+ * writes, so a deployment that pinned an origin under the product's
66
+ * implementation keeps it when the product delegates to this one.
67
+ */
68
+ export const CANONICAL_ORIGIN_KV_KEY =
69
+ "__yurucommu/runtime/canonical-origin/v1";
70
+
71
+ /** No usable public origin, or a candidate that may not become one. */
72
+ export class PublicOriginError extends Error {
73
+ constructor(message: string) {
74
+ super(message);
75
+ this.name = "PublicOriginError";
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Hostnames whose http origin is still trustworthy, because they are not
81
+ * routable off the machine. `*.localhost` is included: RFC 6761 reserves the
82
+ * whole tree for loopback, and a self-host Worker endpoint on a local Takoserver
83
+ * is `<script>.localhost`.
84
+ */
85
+ function isLoopbackHostname(hostname: string): boolean {
86
+ return (
87
+ hostname === "localhost" ||
88
+ hostname === "127.0.0.1" ||
89
+ hostname === "[::1]" ||
90
+ hostname.endsWith(".localhost")
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Reduce a candidate to a bare origin, or refuse it.
96
+ *
97
+ * Refuses anything that is not just an origin — a path, a query, a fragment,
98
+ * embedded credentials — because the value is concatenated with `/ap/users/…`
99
+ * at hundreds of call sites and a stray path would silently produce a second,
100
+ * parallel set of actor ids.
101
+ */
102
+ export function canonicalPublicOrigin(value: string): string {
103
+ let url: URL;
104
+ try {
105
+ url = new URL(value);
106
+ } catch {
107
+ throw new PublicOriginError(
108
+ `"${value}" is not a URL and cannot be this instance's public origin.`,
109
+ );
110
+ }
111
+ if (
112
+ url.username !== "" ||
113
+ url.password !== "" ||
114
+ url.search !== "" ||
115
+ url.hash !== "" ||
116
+ url.pathname !== "/"
117
+ ) {
118
+ throw new PublicOriginError(
119
+ `"${value}" is not a bare origin; this instance's public origin must ` +
120
+ `carry no path, query, fragment, or credentials.`,
121
+ );
122
+ }
123
+ if (
124
+ url.protocol !== "https:" &&
125
+ !(url.protocol === "http:" && isLoopbackHostname(url.hostname))
126
+ ) {
127
+ throw new PublicOriginError(
128
+ `"${value}" is not an https origin. A public origin observed from a ` +
129
+ `request must be https (loopback http is the only exception); set ` +
130
+ `APP_URL explicitly when this Worker is served over plain http.`,
131
+ );
132
+ }
133
+ return url.origin;
134
+ }
135
+
136
+ /** `APP_URL` exactly as the operator set it, or null when it is not set. */
137
+ export function configuredAppUrl(env: Partial<EnvVars>): string | null {
138
+ const raw = typeof env.APP_URL === "string" ? env.APP_URL.trim() : "";
139
+ return raw.length > 0 ? raw : null;
140
+ }
141
+
142
+ /**
143
+ * The origin this isolate has already established.
144
+ *
145
+ * Cached because the alternative is a KV read on the hot path of every single
146
+ * request, and because the value cannot legitimately change: first writer wins,
147
+ * so a second read can only ever return what the first one did. An operator who
148
+ * deliberately re-pins a different origin (see {@link resetObservedPublicOrigin})
149
+ * is served the new value by isolates started after the change.
150
+ */
151
+ let observedPublicOrigin: string | null = null;
152
+
153
+ /** Forget this isolate's observation. Tests, and an operator-driven re-pin. */
154
+ export function resetObservedPublicOrigin(): void {
155
+ observedPublicOrigin = null;
156
+ }
157
+
158
+ /** What this isolate has observed so far, without touching KV. */
159
+ export function peekObservedPublicOrigin(): string | null {
160
+ return observedPublicOrigin;
161
+ }
162
+
163
+ type PublicOriginEnv = Partial<EnvVars> & { KV?: IKeyValueStore };
164
+
165
+ function requireKv(env: PublicOriginEnv): IKeyValueStore {
166
+ if (!env.KV) {
167
+ throw new PublicOriginError(
168
+ "KV is not bound, so this instance's public origin can be neither read " +
169
+ "nor pinned. Bind KV, or set APP_URL.",
170
+ );
171
+ }
172
+ return env.KV;
173
+ }
174
+
175
+ async function readPinnedOrigin(kv: IKeyValueStore): Promise<string | null> {
176
+ const stored = await kv.get(CANONICAL_ORIGIN_KV_KEY);
177
+ if (stored === null) return null;
178
+ // A stored value that no longer canonicalizes is a refusal, never a silent
179
+ // fallback to the current request: it means somebody wrote the key by hand.
180
+ return canonicalPublicOrigin(stored);
181
+ }
182
+
183
+ /**
184
+ * Establish this instance's public origin from one request, once.
185
+ *
186
+ * CONSISTENCY. The pin lives in KV, the one store both lanes always have (`DB`
187
+ * is equally present, but the origin is needed by the readiness probe and by
188
+ * queue work that must not open a transaction to learn its own name). KV is
189
+ * eventually consistent and has no compare-and-swap, so "first writer wins" is
190
+ * enforced by reading before writing and then READING BACK: an isolate that
191
+ * finds a different origin on the read-back lost the race and refuses this
192
+ * request rather than serving two identities. The next request reads the
193
+ * winner's value through the ordinary stored-value path. A read-back that has
194
+ * not converged yet (null) is treated as our own write, because we wrote it.
195
+ */
196
+ export async function establishRequestPublicOrigin(
197
+ env: PublicOriginEnv,
198
+ request: Request,
199
+ ): Promise<string> {
200
+ if (observedPublicOrigin !== null) return observedPublicOrigin;
201
+
202
+ const kv = requireKv(env);
203
+ const pinned = await readPinnedOrigin(kv);
204
+ if (pinned !== null) {
205
+ observedPublicOrigin = pinned;
206
+ return pinned;
207
+ }
208
+
209
+ const requestOrigin = canonicalPublicOrigin(new URL(request.url).origin);
210
+ await kv.put(CANONICAL_ORIGIN_KV_KEY, requestOrigin);
211
+ const readback = await kv.get(CANONICAL_ORIGIN_KV_KEY);
212
+ if (readback !== null && canonicalPublicOrigin(readback) !== requestOrigin) {
213
+ throw new PublicOriginError(
214
+ `this instance's public origin was concurrently pinned to ` +
215
+ `"${readback}" while this request was establishing ` +
216
+ `"${requestOrigin}". The pinned origin stands; retry.`,
217
+ );
218
+ }
219
+ observedPublicOrigin = requestOrigin;
220
+ return requestOrigin;
221
+ }
222
+
223
+ /**
224
+ * The public origin for work that has no request to read it from.
225
+ *
226
+ * Queue consumers sign federation deliveries and address them from this
227
+ * instance's actor ids; there is no request in scope and nothing to derive one
228
+ * from. `APP_URL` first, then the pinned origin, then a refusal — never a
229
+ * guess, and never `undefined` concatenated into an actor id.
230
+ */
231
+ export async function requireBackgroundPublicOrigin(
232
+ env: PublicOriginEnv,
233
+ ): Promise<string> {
234
+ const configured = configuredAppUrl(env);
235
+ if (configured !== null) return configured;
236
+ if (observedPublicOrigin !== null) return observedPublicOrigin;
237
+
238
+ const pinned = await readPinnedOrigin(requireKv(env));
239
+ if (pinned === null) {
240
+ throw new PublicOriginError(
241
+ "this instance's public origin has not been observed yet: APP_URL is " +
242
+ "unset and no request has pinned an origin. Serve one request on the " +
243
+ "Worker's public endpoint before background delivery can address " +
244
+ "anything.",
245
+ );
246
+ }
247
+ observedPublicOrigin = pinned;
248
+ return pinned;
249
+ }
250
+
251
+ /**
252
+ * The env background work should run with: `APP_URL` present, or a refusal.
253
+ *
254
+ * Returned as a copy rather than by mutating the caller's bindings, so the same
255
+ * `env` may be handed to several handlers without one of them rewriting what
256
+ * the others read.
257
+ */
258
+ export async function withRequiredBackgroundPublicOrigin(
259
+ env: Env,
260
+ ): Promise<Env> {
261
+ if (configuredAppUrl(env) !== null) return env;
262
+ return { ...env, APP_URL: await requireBackgroundPublicOrigin(env) };
263
+ }
@@ -1,4 +1,12 @@
1
- import path from "node:path";
1
+ /**
2
+ * Runtime helpers shared by EVERY lane, including the portable Worker.
3
+ *
4
+ * `edge-kv.ts` and `edge-objects.ts` import this module, so it is part of the
5
+ * bundle a wrapper host loads with no `nodejs_compat` flag. It must therefore
6
+ * stay free of `node:` specifiers; the filesystem path helpers that need
7
+ * `node:path` live in `node-paths.ts`, which only the Bun/Node runtime
8
+ * imports.
9
+ */
2
10
 
3
11
  export const DEFAULT_LIST_LIMIT = 1000;
4
12
 
@@ -35,67 +43,6 @@ export function hasNulByte(value: string): boolean {
35
43
  return value.includes("\0");
36
44
  }
37
45
 
38
- export function isPathWithinBasePath(
39
- basePath: string,
40
- candidatePath: string,
41
- ): boolean {
42
- const relative = path.relative(basePath, candidatePath);
43
- return (
44
- relative === "" ||
45
- (!relative.startsWith("..") && !path.isAbsolute(relative))
46
- );
47
- }
48
-
49
- export function resolvePathWithinBasePath(
50
- basePath: string,
51
- key: string,
52
- ): string {
53
- if (hasNulByte(key)) {
54
- throw new Error("Invalid path");
55
- }
56
- const resolvedPath = path.resolve(basePath, key);
57
- if (!isPathWithinBasePath(basePath, resolvedPath)) {
58
- throw new Error("Path escapes base directory");
59
- }
60
- return resolvedPath;
61
- }
62
-
63
- function isNotFoundError(error: unknown): boolean {
64
- return (
65
- typeof error === "object" &&
66
- error !== null &&
67
- "code" in error &&
68
- (error as { code?: unknown }).code === "ENOENT"
69
- );
70
- }
71
-
72
- export async function assertPathChainWithinBasePath(
73
- basePath: string,
74
- targetPath: string,
75
- realpath: (path: string) => Promise<string>,
76
- ): Promise<void> {
77
- let currentPath = targetPath;
78
-
79
- while (true) {
80
- try {
81
- const realCurrentPath = await realpath(currentPath);
82
- if (!isPathWithinBasePath(basePath, realCurrentPath)) {
83
- throw new Error("Path escapes base directory");
84
- }
85
- return;
86
- } catch (error) {
87
- if (!isNotFoundError(error)) {
88
- throw error;
89
- }
90
- const parentPath = path.dirname(currentPath);
91
- if (parentPath === currentPath) {
92
- throw error;
93
- }
94
- currentPath = parentPath;
95
- }
96
- }
97
- }
98
-
99
46
  export async function readStream(
100
47
  stream: ReadableStream<Uint8Array>,
101
48
  ): Promise<Uint8Array> {