@takosjp/yurucommu-core 4.1.1 → 4.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "4.1.1",
3
+ "version": "4.1.3",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "4.1.1",
3
+ "version": "4.1.3",
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";
@@ -10,6 +10,11 @@ import {
10
10
  wrapRuntimeMessageBatch,
11
11
  type PortableWorkerBindings,
12
12
  } from "./runtime/lane.ts";
13
+ import {
14
+ configuredAppUrl,
15
+ establishRequestPublicOrigin,
16
+ withRequiredBackgroundPublicOrigin,
17
+ } from "./runtime/public-origin.ts";
13
18
  import type { EdgeQueueBatch } from "./runtime/edge-facades.ts";
14
19
  import {
15
20
  getMobileOidcAudience,
@@ -925,6 +930,63 @@ function mountStaticFallback(app: YurucommuApp): void {
925
930
  });
926
931
  }
927
932
 
933
+ /**
934
+ * Give this request an `APP_URL` when the Host, not the deployer, chose it.
935
+ *
936
+ * Registered BEFORE every other route so `/readyz` and the `.well-known`
937
+ * discovery documents see the same origin the rest of the app mints ids from —
938
+ * a readiness probe that reported `APP_URL` missing on a Worker whose origin
939
+ * only its own traffic can reveal would never go ready.
940
+ *
941
+ * It runs on EVERY lane, because the lane names the SHAPE OF THE BINDINGS and
942
+ * not who chose the endpoint. A Takoform-hosted Worker on the production
943
+ * Takoserver receives raw Cloudflare bindings — the `cloudflare` lane — and its
944
+ * origin is still allocated by a `WorkerEndpoint` after the `WorkerVersion`
945
+ * that would have carried `APP_URL` was sealed, and the Takoform module passes
946
+ * no `APP_URL` either. Gating the inference on the lane left exactly that
947
+ * install unable to ever name itself, and so never ready.
948
+ *
949
+ * What makes the request URL trustworthy is a property of the runtime rather
950
+ * than of the binding shape: the origin on the request is the origin the
951
+ * request actually arrived on, and no forwarded header is ever consulted (see
952
+ * runtime/public-origin.ts). The remaining risk on a Worker deployed straight
953
+ * to Cloudflare — it also answers on workers.dev and on every custom domain and
954
+ * route pattern its account holds, so the first hostname to arrive would name
955
+ * the instance for good — is answered the same way it always was: an operator
956
+ * who owns more than one of those hostnames sets `APP_URL`, which always wins.
957
+ *
958
+ * It never fails a request. When no origin can be established — the request is
959
+ * plain http on a routable host, KV is unbound, an operator hand-wrote the pin
960
+ * — `APP_URL` simply stays unset, which `/readyz` already reports as a hard
961
+ * missing binding. Turning that into a 500 would take the readiness probe down
962
+ * with it and replace a precise answer with a generic one.
963
+ */
964
+ function publicOriginMiddleware() {
965
+ let warned = false;
966
+ return async (
967
+ c: Context<{ Bindings: Env; Variables: Variables }>,
968
+ next: Next,
969
+ ) => {
970
+ if (configuredAppUrl(c.env) !== null) return next();
971
+ try {
972
+ const origin = await establishRequestPublicOrigin(c.env, c.req.raw);
973
+ c.env = { ...c.env, APP_URL: origin };
974
+ warned = false;
975
+ } catch (error) {
976
+ // Once per isolate: this condition is a property of the deployment, not
977
+ // of the request, so it would otherwise repeat on every single one.
978
+ if (!warned) {
979
+ warned = true;
980
+ log.warn("Could not establish this instance's public origin", {
981
+ event: "runtime.public_origin.unestablished",
982
+ error,
983
+ });
984
+ }
985
+ }
986
+ return next();
987
+ };
988
+ }
989
+
928
990
  export function createYurucommuBackendApp(
929
991
  options: CreateYurucommuBackendAppOptionsV1 = {},
930
992
  ): YurucommuApp {
@@ -941,6 +1003,10 @@ export function createYurucommuBackendApp(
941
1003
  }
942
1004
  }
943
1005
 
1006
+ // Before the readiness probes, because they report on APP_URL and the
1007
+ // `.well-known` discovery documents publish it. Reads no body and consults no
1008
+ // route, so it does not weaken the body-cap ordering below.
1009
+ app.use("*", publicOriginMiddleware());
944
1010
  mountReadinessRoutes(app, options.discovery);
945
1011
  // Body-size cap must run BEFORE any handler reads the body or executes
946
1012
  // expensive auth / rate-limit logic. Mounted after readiness probes so
@@ -1056,9 +1122,15 @@ export default {
1056
1122
  bindings: WorkerBindings,
1057
1123
  ): Promise<void> {
1058
1124
  const lane = resolveRuntimeLane(bindings.YURUCOMMU_RUNTIME_LANE);
1125
+ // Federation delivery signs and addresses from this instance's own actor
1126
+ // ids, and a queue invocation has no request to learn the origin from. When
1127
+ // `APP_URL` is unset and no request has pinned one yet, this THROWS: the
1128
+ // batch is retried later, after traffic has established the origin, instead
1129
+ // of being delivered under `undefined/ap/users/…` to peers that would cache
1130
+ // it. See runtime/public-origin.ts.
1059
1131
  return handleYurucommuQueueBatch(
1060
1132
  wrapRuntimeMessageBatch(batch, lane),
1061
- wrapRuntimeBindings(bindings),
1133
+ await withRequiredBackgroundPublicOrigin(wrapRuntimeBindings(bindings)),
1062
1134
  );
1063
1135
  },
1064
1136
 
@@ -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,
@@ -0,0 +1,279 @@
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 Host-assigned endpoint it cannot
12
+ * always be one. A Takoform `WorkerEndpoint` allocates the Worker's public
13
+ * origin AFTER the `WorkerVersion` that would have carried the variable is
14
+ * already immutable, so the deployer does not know the value at apply time and
15
+ * there is no second apply that could inject it. The origin exists, but only
16
+ * the Host knows it, and the only place it is ever spoken is on the requests
17
+ * the Host routes here.
18
+ *
19
+ * This has nothing to do with the runtime lane. The lane names the SHAPE OF THE
20
+ * BINDINGS; a Takoform-hosted Worker on the production Takoserver runs on raw
21
+ * Cloudflare bindings — the `cloudflare` lane — and is in exactly the same
22
+ * position, because the Takoform module passes no `APP_URL` there either. So on
23
+ * EVERY lane an unset `APP_URL` is answered by OBSERVING one request and
24
+ * PINNING what it observed:
25
+ *
26
+ * 1. `APP_URL` is authoritative whenever it is set. It is used exactly as the
27
+ * operator wrote it and is never validated, cached, or persisted here —
28
+ * an operator who sets it has already decided, and this module has no
29
+ * standing to refuse a value the previous release accepted.
30
+ * 2. Otherwise the origin PINNED IN KV wins, for every request and for
31
+ * background work alike. First writer wins: once a value is stored, no
32
+ * later request replaces it, whatever `Host` that request carried.
33
+ * 3. Otherwise a request may establish it, from the request URL's own origin
34
+ * and from nothing else.
35
+ * 4. Otherwise there is no origin, and background work refuses rather than
36
+ * minting `undefined/ap/users/alice`.
37
+ *
38
+ * WHAT IS TRUSTED. The request URL as the runtime delivers it, and only that.
39
+ * Not `X-Forwarded-Host`, not `X-Forwarded-Proto`, not `Host` read out of the
40
+ * headers — nothing a client can write. That URL is the endpoint the request
41
+ * genuinely arrived on, under every host this bundle runs on. Cloudflare
42
+ * Workers build `request.url` from the connection the edge terminated. Both
43
+ * wrapper hosts route by hostname and hand over the request they received on
44
+ * the Worker's own public endpoint: Takoserver's managed Workers-for-Platforms
45
+ * gateway looks up a host route for `new URL(request.url).hostname` and
46
+ * dispatches the SAME `Request` object, and the self-host workerd router picks
47
+ * a service from a table keyed by hostname and forwards unchanged. A hostname
48
+ * nobody published for this Worker is a 404 before any of this code runs, so
49
+ * the origin on the request is one the Host assigned — which is exactly the
50
+ * value that could not be delivered as a var.
51
+ *
52
+ * WHAT THIS DOES NOT DECIDE. A Worker published straight to Cloudflare by an
53
+ * operator who holds several hostnames answers on all of them, and first writer
54
+ * wins means the first one to arrive names the instance. That operator knows
55
+ * their hostnames and sets `APP_URL`, which always wins and is never pinned.
56
+ * Inference is the answer for the deployment that CANNOT be told its origin,
57
+ * not a preference over being told.
58
+ *
59
+ * WHY HTTPS. A public fediverse origin is https; Cloudflare terminates TLS in
60
+ * front of a Worker on a public hostname, and Takoserver's own `WorkerEndpoint`
61
+ * can only ever assign an https origin. Requiring it here means an http request
62
+ * cannot pin an origin that would then sign deliveries and mint actor ids.
63
+ * Loopback http is the one exception, because `localhost` is not routable and
64
+ * is the origin a developer actually serves on — `wrangler dev` included.
65
+ *
66
+ * A host that terminates TLS in FRONT of the runtime and speaks plain http to
67
+ * it therefore establishes nothing: `request.url` is `http://…` on a routable
68
+ * name and the derivation refuses. That deployment must set `APP_URL`, which it
69
+ * can, because an operator who terminates TLS chose the hostname themselves.
70
+ * Refusing is the point — the alternative is trusting a forwarded-proto header
71
+ * that the same proxy may or may not be the only writer of.
72
+ */
73
+
74
+ import type { Env, EnvVars } from "../types.ts";
75
+ import type { IKeyValueStore } from "./types.ts";
76
+
77
+ /**
78
+ * Where the observed origin is pinned.
79
+ *
80
+ * The key is shared with the origin pin Yurucommu's own generated Worker entry
81
+ * writes, so a deployment that pinned an origin under the product's
82
+ * implementation keeps it when the product delegates to this one.
83
+ */
84
+ export const CANONICAL_ORIGIN_KV_KEY =
85
+ "__yurucommu/runtime/canonical-origin/v1";
86
+
87
+ /** No usable public origin, or a candidate that may not become one. */
88
+ export class PublicOriginError extends Error {
89
+ constructor(message: string) {
90
+ super(message);
91
+ this.name = "PublicOriginError";
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Hostnames whose http origin is still trustworthy, because they are not
97
+ * routable off the machine. `*.localhost` is included: RFC 6761 reserves the
98
+ * whole tree for loopback, and a self-host Worker endpoint on a local Takoserver
99
+ * is `<script>.localhost`.
100
+ */
101
+ function isLoopbackHostname(hostname: string): boolean {
102
+ return (
103
+ hostname === "localhost" ||
104
+ hostname === "127.0.0.1" ||
105
+ hostname === "[::1]" ||
106
+ hostname.endsWith(".localhost")
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Reduce a candidate to a bare origin, or refuse it.
112
+ *
113
+ * Refuses anything that is not just an origin — a path, a query, a fragment,
114
+ * embedded credentials — because the value is concatenated with `/ap/users/…`
115
+ * at hundreds of call sites and a stray path would silently produce a second,
116
+ * parallel set of actor ids.
117
+ */
118
+ export function canonicalPublicOrigin(value: string): string {
119
+ let url: URL;
120
+ try {
121
+ url = new URL(value);
122
+ } catch {
123
+ throw new PublicOriginError(
124
+ `"${value}" is not a URL and cannot be this instance's public origin.`,
125
+ );
126
+ }
127
+ if (
128
+ url.username !== "" ||
129
+ url.password !== "" ||
130
+ url.search !== "" ||
131
+ url.hash !== "" ||
132
+ url.pathname !== "/"
133
+ ) {
134
+ throw new PublicOriginError(
135
+ `"${value}" is not a bare origin; this instance's public origin must ` +
136
+ `carry no path, query, fragment, or credentials.`,
137
+ );
138
+ }
139
+ if (
140
+ url.protocol !== "https:" &&
141
+ !(url.protocol === "http:" && isLoopbackHostname(url.hostname))
142
+ ) {
143
+ throw new PublicOriginError(
144
+ `"${value}" is not an https origin. A public origin observed from a ` +
145
+ `request must be https (loopback http is the only exception); set ` +
146
+ `APP_URL explicitly when this Worker is served over plain http.`,
147
+ );
148
+ }
149
+ return url.origin;
150
+ }
151
+
152
+ /** `APP_URL` exactly as the operator set it, or null when it is not set. */
153
+ export function configuredAppUrl(env: Partial<EnvVars>): string | null {
154
+ const raw = typeof env.APP_URL === "string" ? env.APP_URL.trim() : "";
155
+ return raw.length > 0 ? raw : null;
156
+ }
157
+
158
+ /**
159
+ * The origin this isolate has already established.
160
+ *
161
+ * Cached because the alternative is a KV read on the hot path of every single
162
+ * request, and because the value cannot legitimately change: first writer wins,
163
+ * so a second read can only ever return what the first one did. An operator who
164
+ * deliberately re-pins a different origin (see {@link resetObservedPublicOrigin})
165
+ * is served the new value by isolates started after the change.
166
+ */
167
+ let observedPublicOrigin: string | null = null;
168
+
169
+ /** Forget this isolate's observation. Tests, and an operator-driven re-pin. */
170
+ export function resetObservedPublicOrigin(): void {
171
+ observedPublicOrigin = null;
172
+ }
173
+
174
+ /** What this isolate has observed so far, without touching KV. */
175
+ export function peekObservedPublicOrigin(): string | null {
176
+ return observedPublicOrigin;
177
+ }
178
+
179
+ type PublicOriginEnv = Partial<EnvVars> & { KV?: IKeyValueStore };
180
+
181
+ function requireKv(env: PublicOriginEnv): IKeyValueStore {
182
+ if (!env.KV) {
183
+ throw new PublicOriginError(
184
+ "KV is not bound, so this instance's public origin can be neither read " +
185
+ "nor pinned. Bind KV, or set APP_URL.",
186
+ );
187
+ }
188
+ return env.KV;
189
+ }
190
+
191
+ async function readPinnedOrigin(kv: IKeyValueStore): Promise<string | null> {
192
+ const stored = await kv.get(CANONICAL_ORIGIN_KV_KEY);
193
+ if (stored === null) return null;
194
+ // A stored value that no longer canonicalizes is a refusal, never a silent
195
+ // fallback to the current request: it means somebody wrote the key by hand.
196
+ return canonicalPublicOrigin(stored);
197
+ }
198
+
199
+ /**
200
+ * Establish this instance's public origin from one request, once.
201
+ *
202
+ * CONSISTENCY. The pin lives in KV, the one store both lanes always have (`DB`
203
+ * is equally present, but the origin is needed by the readiness probe and by
204
+ * queue work that must not open a transaction to learn its own name). KV is
205
+ * eventually consistent and has no compare-and-swap, so "first writer wins" is
206
+ * enforced by reading before writing and then READING BACK: an isolate that
207
+ * finds a different origin on the read-back lost the race and refuses this
208
+ * request rather than serving two identities. The next request reads the
209
+ * winner's value through the ordinary stored-value path. A read-back that has
210
+ * not converged yet (null) is treated as our own write, because we wrote it.
211
+ */
212
+ export async function establishRequestPublicOrigin(
213
+ env: PublicOriginEnv,
214
+ request: Request,
215
+ ): Promise<string> {
216
+ if (observedPublicOrigin !== null) return observedPublicOrigin;
217
+
218
+ const kv = requireKv(env);
219
+ const pinned = await readPinnedOrigin(kv);
220
+ if (pinned !== null) {
221
+ observedPublicOrigin = pinned;
222
+ return pinned;
223
+ }
224
+
225
+ const requestOrigin = canonicalPublicOrigin(new URL(request.url).origin);
226
+ await kv.put(CANONICAL_ORIGIN_KV_KEY, requestOrigin);
227
+ const readback = await kv.get(CANONICAL_ORIGIN_KV_KEY);
228
+ if (readback !== null && canonicalPublicOrigin(readback) !== requestOrigin) {
229
+ throw new PublicOriginError(
230
+ `this instance's public origin was concurrently pinned to ` +
231
+ `"${readback}" while this request was establishing ` +
232
+ `"${requestOrigin}". The pinned origin stands; retry.`,
233
+ );
234
+ }
235
+ observedPublicOrigin = requestOrigin;
236
+ return requestOrigin;
237
+ }
238
+
239
+ /**
240
+ * The public origin for work that has no request to read it from.
241
+ *
242
+ * Queue consumers sign federation deliveries and address them from this
243
+ * instance's actor ids; there is no request in scope and nothing to derive one
244
+ * from. `APP_URL` first, then the pinned origin, then a refusal — never a
245
+ * guess, and never `undefined` concatenated into an actor id.
246
+ */
247
+ export async function requireBackgroundPublicOrigin(
248
+ env: PublicOriginEnv,
249
+ ): Promise<string> {
250
+ const configured = configuredAppUrl(env);
251
+ if (configured !== null) return configured;
252
+ if (observedPublicOrigin !== null) return observedPublicOrigin;
253
+
254
+ const pinned = await readPinnedOrigin(requireKv(env));
255
+ if (pinned === null) {
256
+ throw new PublicOriginError(
257
+ "this instance's public origin has not been observed yet: APP_URL is " +
258
+ "unset and no request has pinned an origin. Serve one request on the " +
259
+ "Worker's public endpoint before background delivery can address " +
260
+ "anything.",
261
+ );
262
+ }
263
+ observedPublicOrigin = pinned;
264
+ return pinned;
265
+ }
266
+
267
+ /**
268
+ * The env background work should run with: `APP_URL` present, or a refusal.
269
+ *
270
+ * Returned as a copy rather than by mutating the caller's bindings, so the same
271
+ * `env` may be handed to several handlers without one of them rewriting what
272
+ * the others read.
273
+ */
274
+ export async function withRequiredBackgroundPublicOrigin(
275
+ env: Env,
276
+ ): Promise<Env> {
277
+ if (configuredAppUrl(env) !== null) return env;
278
+ return { ...env, APP_URL: await requireBackgroundPublicOrigin(env) };
279
+ }