@ccmsg/cli 0.2.0 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.4.0"
23
+ "@ccmsg/protocol": "1.6.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
package/src/auth/admin.ts CHANGED
@@ -18,7 +18,6 @@ export type AdminRequest =
18
18
  readonly admin: "passkey_add";
19
19
  readonly request_id: string;
20
20
  readonly endpoint?: Endpoint;
21
- readonly rp_id?: string;
22
21
  readonly name?: string;
23
22
  readonly sub?: Subject;
24
23
  }
@@ -45,7 +44,6 @@ export function handleAdmin(auth: Auth, request: AdminRequest): DispatchResult {
45
44
  request.request_id,
46
45
  auth.issue({
47
46
  ...(request.endpoint === undefined ? {} : { endpoint: request.endpoint }),
48
- ...(request.rp_id === undefined ? {} : { rpId: request.rp_id }),
49
47
  ...(request.name === undefined ? {} : { label: request.name }),
50
48
  ...(request.sub === undefined ? {} : { sub: request.sub }),
51
49
  }),
package/src/auth/auth.ts CHANGED
@@ -21,7 +21,12 @@ import type {
21
21
  Timestamp,
22
22
  TokenFamily,
23
23
  } from "@ccmsg/protocol";
24
- import { AUTH_CHALLENGE_TTL_MS, REGISTER_TTL_MS } from "@ccmsg/protocol";
24
+ import {
25
+ AUTH_CHALLENGE_TTL_MS,
26
+ Endpoint as EndpointSchema,
27
+ REGISTER_TTL_MS,
28
+ validationErrors,
29
+ } from "@ccmsg/protocol";
25
30
  import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
26
31
  import { AuthRecords, credentialKey, familyKey } from "./records.ts";
27
32
  import {
@@ -100,10 +105,7 @@ export interface AuthorizedConn {
100
105
  export interface AuthDeps {
101
106
  readonly self: InstanceId;
102
107
  readonly records: AuthRecords;
103
- /** The pages allowed to run these exchanges: `clientDataJSON.origin` is
104
- * compared against this, and so is the CORS answer (§2.3). */
105
- readonly origins: () => readonly string[];
106
- /** Where this instance is dialed, which a registration URL is issued against
108
+ /** Where this instance is reached, which a registration URL is issued against
107
109
  * when the operator names none. */
108
110
  readonly endpoint: () => Endpoint | undefined;
109
111
  /** The instance's name as a person operates it, carried in the URL for
@@ -175,7 +177,6 @@ export class Auth {
175
177
  * terminal this ran on. Somebody holding the URL alone cannot register. */
176
178
  issue(options: {
177
179
  readonly endpoint?: Endpoint;
178
- readonly rpId?: string;
179
180
  readonly label?: string;
180
181
  readonly sub?: Subject;
181
182
  }): IssuedRegistration {
@@ -186,14 +187,21 @@ export class Auth {
186
187
  "この instance には endpoint が無いので、登録先の URL を引数で渡してください",
187
188
  );
188
189
  }
189
- const host = hostOf(endpoint);
190
- const rpId = options.rpId ?? host;
191
- // The relying party is a domain the endpoint's host belongs to, and nothing
192
- // wider: a credential made for a suffix this instance does not sit under
193
- // would be usable at every other host under it (§2.3).
194
- if (!isRegistrableSuffix(rpId, host)) {
195
- throw new OpError("invalid_args", `${rpId} は ${host} の登録可能なドメインではありません`);
190
+ // An operator types this one, so it is read here rather than trusted: an
191
+ // address that is not a base URL would be written into the record and be
192
+ // compared, forever after, against a request that can never match it.
193
+ const problems = validationErrors(EndpointSchema, endpoint);
194
+ if (problems.length > 0) {
195
+ throw new OpError(
196
+ "invalid_args",
197
+ `endpoint は末尾が / の http(s) base URL です (${endpoint}): ${problems.join("; ")}`,
198
+ );
196
199
  }
200
+ // The relying party is the endpoint's own host and nothing wider. A
201
+ // registrable suffix of it would make the credential usable at every other
202
+ // host under that suffix, and there is no deployment this instance supports
203
+ // where that is what was wanted (DR-0001 §2.3).
204
+ const rpId = hostOf(endpoint);
197
205
  const sub = options.sub ?? this.#nextSubject();
198
206
  if (this.deps.records.removed(sub)) {
199
207
  throw new OpError("forbidden", `${sub} は削除済みなので、この名前では登録できません`);
@@ -215,7 +223,7 @@ export class Auth {
215
223
  this.#pending.set(claims.jti, { claims, secret, code, attempts: 0 });
216
224
  return {
217
225
  sub,
218
- url: `${webOrigin(endpoint)}/#register=${sign(claims, secret)}`,
226
+ url: `${endpoint}#register=${sign(claims, secret)}`,
219
227
  code,
220
228
  user_id: claims.user_id,
221
229
  expires_at: claims.expires_at,
@@ -377,13 +385,17 @@ export class Auth {
377
385
  * here, by whoever the browser reached (§2.6). */
378
386
  async register(
379
387
  args: AuthRegisterArgs,
380
- from: { ip?: string; userAgent?: string } = {},
388
+ from: { ip?: string; userAgent?: string; path?: string } = {},
381
389
  ): Promise<MintedSession> {
382
390
  // What the URL says about itself, before anything has vouched for it. It is
383
391
  // read to know which relying party the credential should have been made
384
392
  // under; nothing is decided by it, because the same fields come back
385
393
  // authenticated below and the two are held to each other.
386
394
  const stated = claimsOf(args.token);
395
+ // The endpoint this URL was issued for is where it may be spent: a
396
+ // registration posted to a neighbour sharing the host is a registration at
397
+ // an instance the URL never named (contract, `CredentialRecord.endpoint`).
398
+ this.#servedHere(stated.endpoint, from.path);
387
399
  // What the page answered, verified before anything is spent: a challenge is
388
400
  // good once, so consuming it for a message that then fails to verify would
389
401
  // let a caller burn challenges without ever holding a credential (m9).
@@ -391,7 +403,7 @@ export class Auth {
391
403
  const verified = refusable(() =>
392
404
  verifyRegistration(args.credential, {
393
405
  challenge,
394
- origins: this.deps.origins(),
406
+ origin: originOf(stated.endpoint),
395
407
  rpId: stated.rp_id,
396
408
  }),
397
409
  );
@@ -427,6 +439,7 @@ export class Auth {
427
439
  credential_id: verified.credentialId,
428
440
  public_key: verified.publicKey,
429
441
  user_handle: claims.user_id,
442
+ endpoint: claims.endpoint,
430
443
  rp_id: claims.rp_id,
431
444
  sign_count: verified.signCount,
432
445
  ...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
@@ -504,12 +517,16 @@ export class Auth {
504
517
 
505
518
  async assert(
506
519
  args: AuthAssertArgs,
507
- from: { ip?: string; userAgent?: string } = {},
520
+ from: { ip?: string; userAgent?: string; path?: string } = {},
508
521
  ): Promise<MintedSession> {
509
522
  const record = this.deps.records.credential(args.credential.raw_id);
510
523
  if (record === undefined) {
511
524
  throw new OpError("auth_invalid", "この credential は登録されていません");
512
525
  }
526
+ // The endpoint the credential was registered for, and no other: two
527
+ // instances may share a host, and this is what keeps one's credential from
528
+ // being a way into the other (contract, `CredentialRecord.endpoint`).
529
+ this.#servedHere(record.endpoint, from.path);
513
530
  // A resident credential answers with the handle it was created against,
514
531
  // which is how a person is found without having named an account. It is
515
532
  // held to what the registration settled: a handle naming somebody else is
@@ -534,7 +551,7 @@ export class Auth {
534
551
  },
535
552
  {
536
553
  challenge: args.challenge.challenge,
537
- origins: this.deps.origins(),
554
+ origin: originOf(record.endpoint),
538
555
  rpIds: this.#rpIdFor(record),
539
556
  },
540
557
  ),
@@ -555,33 +572,52 @@ export class Auth {
555
572
  return this.mint(record.sub);
556
573
  }
557
574
 
575
+ /** Refuse an exchange that arrived somewhere other than the endpoint it is
576
+ * about.
577
+ *
578
+ * The path the request came in on is the carrier's observation, so a caller
579
+ * cannot state it. A carrier that does not observe one — the mesh, where the
580
+ * issuer is asked about a URL rather than posted to — states nothing and is
581
+ * not held to a path it never had. */
582
+ #servedHere(endpoint: Endpoint, path: string | undefined): void {
583
+ if (path !== undefined && !servesPath(endpoint, path)) {
584
+ throw new OpError("auth_invalid", `この要求は ${endpoint} 宛ではありません`);
585
+ }
586
+ }
587
+
558
588
  /** The relying party an assertion is checked against.
559
589
  *
560
590
  * The one the credential was registered under, which the record carries: a
561
591
  * passkey only ever answers for the domain it was made under, and the
562
- * endpoint being reached says nothing about that (§2.3). Only a record
563
- * written before the field existed falls back to the names this instance was
564
- * configured to be, and nothing is widened to a suffix. */
592
+ * endpoint being reached says nothing about that (§2.3). Nothing is widened
593
+ * to a suffix, and a record from before the field existed names the host of
594
+ * the endpoint it was registered for. */
565
595
  #rpIdFor(record: CredentialRecord): string[] {
566
- if (record.rp_id !== undefined) return [record.rp_id];
567
- return this.#configuredNames();
596
+ return [record.rp_id ?? hostOf(record.endpoint)];
568
597
  }
569
598
 
570
- #configuredNames(): string[] {
571
- const names = new Set<string>();
572
- for (const origin of this.deps.origins()) {
573
- try {
574
- names.add(new URL(origin).hostname);
575
- } catch {
576
- // A configured value that is not a URL names no host.
577
- }
599
+ /** The origins whose pages may read these answers: this instance's own, the
600
+ * ones its credentials were registered at, and the ones its outstanding
601
+ * registration URLs were issued for.
602
+ *
603
+ * Its own is there because a browser may land here holding a URL another
604
+ * instance issued — the page it runs the exchange from is then this
605
+ * instance's, and the issuer is only asked to spend the URL (§2.6).
606
+ *
607
+ * Read by the HTTP carrier, which compares them whole (§2.3). Not the relying
608
+ * party: an RP ID is a domain, so a page at any host under it would be let in
609
+ * — and `/auth/refresh` answers a cookie the browser attaches by domain, so a
610
+ * sibling subdomain admitted here would read a person's access token. What
611
+ * this instance serves is its endpoints, so its endpoints are the answer. */
612
+ knownOrigins(): string[] {
613
+ const origins = new Set<string>();
614
+ for (const record of this.deps.records.credentials()) {
615
+ origins.add(originOf(record.endpoint));
578
616
  }
617
+ for (const held of this.#pending.values()) origins.add(originOf(held.claims.endpoint));
579
618
  const endpoint = this.deps.endpoint();
580
- if (endpoint !== undefined) names.add(hostOf(endpoint));
581
- if (names.size === 0) {
582
- throw new OpError("auth_invalid", "この instance には relying party がありません");
583
- }
584
- return [...names];
619
+ if (endpoint !== undefined) origins.add(originOf(endpoint));
620
+ return [...origins];
585
621
  }
586
622
 
587
623
  // --- tokens (§2.4) ---
@@ -963,23 +999,22 @@ export function hostOf(endpoint: Endpoint): string {
963
999
  return new URL(endpoint).hostname;
964
1000
  }
965
1001
 
966
- /** Where the page that runs the registration is served from: the endpoint's
967
- * origin over HTTP, which is where the web UI sits in the ordinary
968
- * configuration (§2.3). */
969
- export function webOrigin(endpoint: Endpoint): string {
970
- const url = new URL(endpoint);
971
- url.protocol = url.protocol === "wss:" ? "https:" : "http:";
972
- const origin = url.origin;
973
- const path = url.pathname.replace(/\/$/, "");
974
- return `${origin}${path}`;
1002
+ /** Whether a request that arrived at this path was made to this endpoint.
1003
+ *
1004
+ * The endpoint's own path, compared exactly: `https://h/` and
1005
+ * `https://h/personal/` are two instances that may share a host, so a
1006
+ * credential registered for one is not a way into the other (contract,
1007
+ * `CredentialRecord.endpoint`). The prefix a proxy leaves on the front is what
1008
+ * the carrier already stripped down to when it found the route.
1009
+ *
1010
+ * Whether the origin matches is asked separately: the two together are what
1011
+ * bind a credential to one instance. */
1012
+ export function servesPath(endpoint: Endpoint, path: string): boolean {
1013
+ return new URL(endpoint).pathname === path;
975
1014
  }
976
1015
 
977
- /** Whether a relying party id is the host or a domain the host sits under.
978
- *
979
- * The WebAuthn rule as far as this instance can check it: a credential made
980
- * for a suffix the endpoint does not sit under would be usable at hosts this
981
- * instance has nothing to do with. Whether the suffix is one a registrar hands
982
- * out is the browser's to refuse, and it does. */
983
- export function isRegistrableSuffix(rpId: string, host: string): boolean {
984
- return host === rpId || host.endsWith(`.${rpId}`);
1016
+ /** The origin an endpoint is served from, which is what a browser writes into
1017
+ * `clientDataJSON.origin`. */
1018
+ export function originOf(endpoint: Endpoint): string {
1019
+ return new URL(endpoint).origin;
985
1020
  }
package/src/auth/http.ts CHANGED
@@ -46,6 +46,16 @@ export function authRouteOf(pathname: string): Route | undefined {
46
46
  return (ROUTES as readonly string[]).includes(name) ? (name as Route) : undefined;
47
47
  }
48
48
 
49
+ /** The endpoint path a request arrived under: everything before its `auth/`.
50
+ *
51
+ * What a credential's endpoint is compared against, so that `https://h/` and
52
+ * `https://h/personal/` are two instances rather than two spellings of one
53
+ * (contract, `CredentialRecord.endpoint`). */
54
+ export function endpointPath(pathname: string): string {
55
+ const at = pathname.lastIndexOf("/auth/");
56
+ return at === -1 ? "/" : pathname.slice(0, at + 1);
57
+ }
58
+
49
59
  /** The cookie path for a request: everything up to and including its `/auth/`.
50
60
  *
51
61
  * It narrows what the browser sends where, and nothing more — same-origin
@@ -83,11 +93,11 @@ export function cookieValue(header: string | null, name: string): string | undef
83
93
  export interface AuthRoutesDeps {
84
94
  readonly auth: Auth;
85
95
  readonly self: InstanceId;
86
- readonly origins: () => readonly string[];
87
96
  readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
88
97
  }
89
98
 
90
- /** Serve `/auth/*`, or answer nothing when the request is for something else.
99
+ /** Serve `<endpoint>auth/*`, or answer nothing when the request is for
100
+ * something else.
91
101
  *
92
102
  * Everything unauthenticated shares one rate limit and one origin check: these
93
103
  * routes are reachable before anything is proven, and the work behind them is a
@@ -101,10 +111,12 @@ export async function handleAuth(
101
111
  const route = authRouteOf(url.pathname);
102
112
  if (route === undefined) return undefined;
103
113
  const origin = request.headers.get("origin");
104
- const allowed = deps.origins();
105
- // A page from an origin this instance does not serve is refused before
106
- // anything else, including the preflight that would tell it to try.
107
- if (origin !== null && !allowed.includes(origin)) {
114
+ // A page from an origin this instance is not one of is refused before
115
+ // anything else, including the preflight that would tell it to try. Compared
116
+ // whole rather than by domain: `/auth/refresh` answers with a person's access
117
+ // token, and a browser attaches the cookie it is asked for by domain, so a
118
+ // sibling subdomain let in here could read that token (§2.3).
119
+ if (origin !== null && !deps.auth.knownOrigins().includes(origin)) {
108
120
  return new Response("Forbidden", { status: 403 });
109
121
  }
110
122
  // These routes change state and are reachable before anything is proven, so
@@ -162,6 +174,10 @@ export async function handleAuth(
162
174
  const seen = {
163
175
  ...(from.ip === undefined ? {} : { ip: from.ip }),
164
176
  ...(userAgent === undefined ? {} : { userAgent }),
177
+ // Where the request actually arrived, which is what an endpoint's path is
178
+ // compared against. Observed here rather than taken from the body: a caller
179
+ // stating which instance it reached would be stating the answer.
180
+ path: endpointPath(url.pathname),
165
181
  };
166
182
  try {
167
183
  switch (route) {
@@ -79,13 +79,13 @@ export function parseAuthenticatorData(bytes: Uint8Array): AuthenticatorData {
79
79
  * 11-15): what the browser was doing, which challenge it answered, which page
80
80
  * asked, and that the answer belongs to one page rather than an embedded one.
81
81
  *
82
- * The origin is compared against the set the operator configured for the web
83
- * UI rather than against the endpoint: the endpoint is where the instance is
84
- * dialed, and the page may be served from another name under the same
85
- * registrable domain (DR-0001 §2.3). */
82
+ * The origin is compared against the endpoint the credential is registered
83
+ * for, which is where the page that runs these exchanges is served: an
84
+ * endpoint is the base URL of the instance itself, so the page and the
85
+ * instance are one origin by construction (DR-0001 §2.3). */
86
86
  export function checkClientData(
87
87
  clientDataJson: Uint8Array,
88
- expected: { type: string; challenge: string; origins: readonly string[] },
88
+ expected: { type: string; challenge: string; origin: string },
89
89
  ): void {
90
90
  let parsed: ClientData;
91
91
  try {
@@ -99,8 +99,8 @@ export function checkClientData(
99
99
  if (typeof parsed.challenge !== "string" || !equalStrings(parsed.challenge, expected.challenge)) {
100
100
  throw new WebAuthnError("the client data answers another challenge");
101
101
  }
102
- if (typeof parsed.origin !== "string" || !expected.origins.includes(parsed.origin)) {
103
- throw new WebAuthnError(`${String(parsed.origin)} is not an origin this instance serves`);
102
+ if (typeof parsed.origin !== "string" || parsed.origin !== expected.origin) {
103
+ throw new WebAuthnError(`${String(parsed.origin)} is not ${expected.origin}`);
104
104
  }
105
105
  // What is refused is an exchange an embedding page ran, which is what either
106
106
  // of these says when it is there to say it. `crossOrigin: false` is not that:
@@ -141,12 +141,12 @@ export interface VerifiedRegistration {
141
141
  * page asked for something other than what this instance asked it to. */
142
142
  export function verifyRegistration(
143
143
  credential: RegistrationCredential,
144
- expected: { challenge: string; origins: readonly string[]; rpId: string },
144
+ expected: { challenge: string; origin: string; rpId: string },
145
145
  ): VerifiedRegistration {
146
146
  checkClientData(base64UrlDecode(credential.client_data_json), {
147
147
  type: "webauthn.create",
148
148
  challenge: expected.challenge,
149
- origins: expected.origins,
149
+ origin: expected.origin,
150
150
  });
151
151
  let attestation: CborValue;
152
152
  try {
@@ -187,13 +187,13 @@ export function verifyRegistration(
187
187
  export async function verifyAssertion(
188
188
  credential: AssertionCredential,
189
189
  known: { publicKey: Base64Url; signCount?: number },
190
- expected: { challenge: string; origins: readonly string[]; rpIds: readonly string[] },
190
+ expected: { challenge: string; origin: string; rpIds: readonly string[] },
191
191
  ): Promise<{ signCount: number }> {
192
192
  const clientDataJson = base64UrlDecode(credential.client_data_json);
193
193
  checkClientData(clientDataJson, {
194
194
  type: "webauthn.get",
195
195
  challenge: expected.challenge,
196
- origins: expected.origins,
196
+ origin: expected.origin,
197
197
  });
198
198
  const authData = base64UrlDecode(credential.authenticator_data);
199
199
  const data = parseAuthenticatorData(authData);
package/src/cli.ts CHANGED
@@ -166,10 +166,12 @@ const ROOT: Command = {
166
166
  {
167
167
  name: "add",
168
168
  summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
169
- usage:
170
- "ccmsg daemon passkey add <unit> [endpoint] [--rp-id <domain>] [--name <ラベル>]",
169
+ usage: "ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
171
170
  options: [
172
- ["--rp-id <domain>", "WebAuthn の relying party。既定は endpoint のホスト"],
171
+ [
172
+ "[endpoint]",
173
+ "登録先の公開 base URL (末尾 /)。既定はこの instance が確定した endpoint",
174
+ ],
173
175
  ["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
174
176
  ],
175
177
  run: (args) => passkeyAdd(args),
@@ -620,20 +622,18 @@ async function passkeyAsk(unit: string | undefined, request: Record<string, unkn
620
622
  * anything the instance hands out — so that holding the URL is not enough to
621
623
  * register (DR-0001 §2.2). */
622
624
  async function passkeyAdd(args: readonly string[]): Promise<unknown> {
623
- const parsed = options(args, ["rp-id", "name"]);
625
+ const parsed = options(args, ["name"]);
624
626
  const [unit, endpoint] = parsed.rest;
625
627
  if (unit === undefined) {
626
628
  throw new CommandError(
627
629
  "invalid_args",
628
- "使い方: ccmsg daemon passkey add <unit> [endpoint] [--rp-id <domain>] [--name <ラベル>]",
630
+ "使い方: ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
629
631
  );
630
632
  }
631
- const rpId = parsed.named.get("rp-id");
632
633
  const name = parsed.named.get("name");
633
634
  return await passkeyAsk(unit, {
634
635
  admin: "passkey_add",
635
636
  ...(endpoint === undefined ? {} : { endpoint }),
636
- ...(rpId === undefined ? {} : { rp_id: rpId }),
637
637
  ...(name === undefined ? {} : { name }),
638
638
  });
639
639
  }
@@ -14,12 +14,6 @@ export interface EntryConfig {
14
14
  /** Source addresses allowed to connect. Empty means every address the bind
15
15
  * itself already permits, which for the default loopback bind is this host. */
16
16
  readonly source_ips: readonly string[];
17
- /** `Origin` values a browser connection may present. Empty admits no
18
- * browser at all: a request carrying no `Origin` is not a browser's and is
19
- * judged on the address and the token alone, so the list only ever widens
20
- * what reaches the socket, and an unlisted webui is refused rather than
21
- * let in by default. */
22
- readonly origins: readonly string[];
23
17
  }
24
18
 
25
19
  /** One value a launch recipe's command reads, as the operator declares it. */
@@ -87,18 +81,10 @@ export interface UpstreamConfig {
87
81
  }
88
82
 
89
83
  export interface InstanceConfig {
90
- /** Where other instances reach this one (§7.1). Stated rather than
91
- * discovered: an instance may be reached through a proxy or an alias, so the
92
- * URL a peer is to dial is not a thing the process can read off its own
93
- * socket. Required of an instance that has a mesh.
94
- *
95
- * Its `ws(s)://` form is what the mesh handshake compares as `aud`; the
96
- * `http(s)://` URL with the same host and path is where the mesh's own
97
- * routes hang. */
98
- readonly self?: Endpoint;
99
- /** Mesh endpoints to dial (§7.2). The same list goes to every instance, and
100
- * it may name this one: an instance takes itself out of the list it dials,
101
- * so one file can be copied to every host unchanged (§8.2). */
84
+ /** Every mesh endpoint, this instance's own among them (§7.1). The same list
85
+ * goes to every instance and names none of them in particular: which entry is
86
+ * this one is settled at startup by the probe, so one file can be copied to
87
+ * every host unchanged (§8.2). */
102
88
  readonly peers: readonly Endpoint[];
103
89
  /** Absent when this instance serves the unix socket only. */
104
90
  readonly entry?: EntryConfig;
@@ -236,8 +222,7 @@ export function settingsFor(shared: SharedConfig, dir: string): Record<string, u
236
222
 
237
223
  /** One instance's settings, read at the shape the instance uses them. */
238
224
  export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
239
- const config = {
240
- ...(fields["self"] === undefined ? {} : { self: endpointOf(file, "self", fields["self"]) }),
225
+ return {
241
226
  peers: peersOf(file, fields["peers"]),
242
227
  ...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
243
228
  upstream: upstreamOf(file, fields["upstream"]),
@@ -249,13 +234,6 @@ export function parseConfig(file: string, fields: Record<string, unknown>): Inst
249
234
  ),
250
235
  fork_origin: flagOf(file, "fork_origin", fields["fork_origin"], DEFAULT_CONFIG.fork_origin),
251
236
  };
252
- // A mesh instance is dialled by its peers, and where they dial it is the one
253
- // thing it cannot work out for itself. Refused here rather than at the first
254
- // handshake, for the reason any broken setting is (DV-Q9).
255
- if (config.self === undefined && config.peers.length > 0 && config.entry !== undefined) {
256
- throw new ConfigError(file, "self must name this instance's endpoint URL when peers are set");
257
- }
258
- return config;
259
237
  }
260
238
 
261
239
  function flagOf(file: string, at: string, raw: unknown, fallback: boolean): boolean {
@@ -264,13 +242,20 @@ function flagOf(file: string, at: string, raw: unknown, fallback: boolean): bool
264
242
  return raw;
265
243
  }
266
244
 
267
- const ENDPOINT = /^wss?:\/\/[^\s?#]+$/;
245
+ /** An endpoint as the contract spells it: the instance's public base URL, with
246
+ * the trailing slash and no route of its own. What hangs below it — `ws`,
247
+ * `mesh/*`, `auth/*`, `webhook/*` — is a route rather than part of the address
248
+ * (contract, `Endpoint`). */
249
+ const ENDPOINT = /^https?:\/\/[^\s?#]*\/$/;
268
250
 
269
251
  function endpointOf(file: string, at: string, raw: unknown): Endpoint {
270
252
  if (typeof raw !== "string" || !ENDPOINT.test(raw)) {
271
- throw new ConfigError(file, `${at} must be a ws:// or wss:// URL, got ${String(raw)}`);
253
+ throw new ConfigError(
254
+ file,
255
+ `${at} must be an http:// or https:// base URL ending in /, got ${String(raw)}`,
256
+ );
272
257
  }
273
- return raw;
258
+ return raw as Endpoint;
274
259
  }
275
260
 
276
261
  function peersOf(file: string, raw: unknown): readonly Endpoint[] {
@@ -293,7 +278,6 @@ function entryOf(file: string, raw: unknown): EntryConfig {
293
278
  host,
294
279
  port,
295
280
  source_ips: stringsOf(file, "entry.source_ips", fields["source_ips"]),
296
- origins: stringsOf(file, "entry.origins", fields["origins"]),
297
281
  };
298
282
  }
299
283
 
@@ -3,7 +3,6 @@ import { join } from "node:path";
3
3
  import {
4
4
  type AuthRecord,
5
5
  type Capability,
6
- type Endpoint,
7
6
  type InstanceId,
8
7
  type InstancePingResult,
9
8
  type NetOnlineEvent,
@@ -182,10 +181,10 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
182
181
  const id = instanceIdentity(paths.instanceIdFile);
183
182
  // 5. the endpoint list, for an instance that has a mesh.
184
183
  //
185
- // `self` is configured, so nothing has to be settled; what the probe does
186
- // is check it, and it has to arrive at a listener — so the WebSocket is
187
- // bound here and handed to the instance. A `self` that answers as somebody
188
- // else ends the start.
184
+ // Which entry of it is this instance is settled by the probe, and the probe
185
+ // has to arrive at a listener — so the WebSocket is bound here and handed
186
+ // to the instance. A list that names this instance no times, or twice, ends
187
+ // the start.
189
188
  const mesh = meshFor(id, config, log, options.meshTiming);
190
189
  const wiring = mesh === undefined ? undefined : await bindForMesh(config, mesh);
191
190
  // 6-8 are the instance's own construction and listen.
@@ -222,12 +221,9 @@ function meshFor(
222
221
  log: Log,
223
222
  timing?: MeshTiming,
224
223
  ): Mesh | undefined {
225
- if (config.peers.length === 0 || config.entry === undefined || config.self === undefined) {
226
- return undefined;
227
- }
224
+ if (config.peers.length === 0 || config.entry === undefined) return undefined;
228
225
  return new Mesh({
229
226
  id,
230
- self: config.self,
231
227
  peers: config.peers,
232
228
  conns: new ConnRegistry(),
233
229
  log: (msg, fields) => {
@@ -250,7 +246,7 @@ export interface MeshWiring {
250
246
  attach(instance: Instance): void;
251
247
  }
252
248
 
253
- /** Bind the WebSocket, settle `self` against the peer list, and hand both on.
249
+ /** Bind the WebSocket, settle which endpoint this instance is, and hand both on.
254
250
  *
255
251
  * The listener answers the two pre-authentication routes from the moment it is
256
252
  * up — the probe of self-identification and the key of mesh-peer-auth §6 — and
@@ -275,7 +271,7 @@ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiri
275
271
  },
276
272
  });
277
273
  try {
278
- await mesh.verify();
274
+ await mesh.identify();
279
275
  } catch (cause) {
280
276
  // The listener is bound before the endpoint list is checked, so it is this
281
277
  // function's to release when the check refuses — nothing else holds it yet,
@@ -424,7 +420,7 @@ export class Instance {
424
420
  // 6. `last_live` and the inbox, read as the domains are constructed.
425
421
  this.#sessions = new Sessions({
426
422
  self: this.self,
427
- endpoint: selfEndpoint(config),
423
+ ...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self }),
428
424
  authExpiresAt: (conn) => this.#auth.expiresAt(conn),
429
425
  configHome: paths.configHome,
430
426
  stateDir: paths.stateDir,
@@ -524,8 +520,7 @@ export class Instance {
524
520
  this.#auth = new Auth({
525
521
  self: this.self,
526
522
  records,
527
- origins: () => config.entry?.origins ?? [],
528
- endpoint: () => selfEndpoint(config),
523
+ endpoint: () => this.#mesh?.self,
529
524
  unit: paths.key,
530
525
  ...(this.#mesh === undefined
531
526
  ? {}
@@ -674,11 +669,7 @@ export class Instance {
674
669
  // The person's authentication comes first: it is the one route reached
675
670
  // before anything is proven, and the gateway's webhook carries its own
676
671
  // secret and cannot be confused with it (DR-0001 §2.7).
677
- const authorized = await handleAuth(
678
- request,
679
- { auth: this.#auth, self: this.self, origins: () => this.config.entry?.origins ?? [] },
680
- {},
681
- );
672
+ const authorized = await handleAuth(request, { auth: this.#auth, self: this.self }, {});
682
673
  if (authorized !== undefined) return authorized;
683
674
  return await this.#gateway.route(request);
684
675
  }
@@ -918,34 +909,18 @@ export class Instance {
918
909
  }
919
910
  }
920
911
 
921
- /** Where this instance says it is reached, or nothing when it is reached by
922
- * no URL at all.
923
- *
924
- * The config's `self` when there is one, which is the answer for anything with
925
- * a mesh. Without one the bound address stands in, and an instance serving only
926
- * the unix socket has neither — so it states no endpoint rather than a URL that
927
- * reaches nothing (DR-0001 §2.1). A client on the unix socket already has the
928
- * instance it is talking to. */
929
- export function selfEndpoint(config: InstanceConfig): Endpoint | undefined {
930
- if (config.self !== undefined) return config.self;
931
- const entry = config.entry;
932
- if (entry === undefined || entry.port === 0) return undefined;
933
- return `ws://${entry.host}:${entry.port}`;
934
- }
935
-
936
- /** Who may reach the WebSocket at all (§3.1): an Origin the operator named and
937
- * an address the operator named.
912
+ /** Who may reach the WebSocket at all (§3.1): an address the operator named.
938
913
  *
939
- * The two config lists are read as allowlists in both directions. An empty
940
- * `origins` admits no browser: a permission that was never granted is not a
941
- * permission, and the one deployment that would want "any page may connect" is
942
- * the one that must say so. An empty `source_ips` leaves the addresses to the
943
- * bind, which for the default loopback host is this machine.
914
+ * An empty `source_ips` leaves the addresses to the bind, which for the default
915
+ * loopback host is this machine. The `Origin` a request carries is not read:
916
+ * a WebSocket is authorized by the access token it presents (DR-0001 §2.5), and
917
+ * an allowlist of pages would be a second answer to a question the token has
918
+ * already answered one the operator has to keep in step with every URL the
919
+ * instance is reached through.
944
920
  *
945
- * Neither of them says who came, which is what the access token on the
946
- * handshake answers (DR-0001 §2.5): every connection that is not a peer's
947
- * presents one, and a handshake without one is refused rather than let in as an
948
- * anonymous person. */
921
+ * The address does not say who came either. Every connection that is not a
922
+ * peer's presents a token, and a handshake without one is refused rather than
923
+ * let in as an anonymous person. */
949
924
  function entryPolicy(
950
925
  config: InstanceConfig,
951
926
  mesh: boolean,
@@ -954,11 +929,7 @@ function entryPolicy(
954
929
  const entry = config.entry;
955
930
  if (entry === undefined) return {};
956
931
  return {
957
- allowRequest(request: Request, source: string | undefined): boolean {
958
- const origin = request.headers.get("origin");
959
- // A request carrying no `Origin` is not a browser's, and there is nothing
960
- // to compare: it stands or falls on the address and the token below.
961
- if (origin !== null && !entry.origins.includes(origin)) return false;
932
+ allowRequest(_request: Request, source: string | undefined): boolean {
962
933
  if (entry.source_ips.length === 0) return true;
963
934
  // The address the server observed, not one a header claims: a forwarding
964
935
  // header is written by whoever is in front of us, and anyone who can
package/src/mesh/mesh.ts CHANGED
@@ -135,8 +135,8 @@ export interface MeshHost {
135
135
  export interface MeshDeps {
136
136
  /** This instance's id, which is what it is called on the wire. */
137
137
  readonly id: InstanceId;
138
- /** Where peers reach this instance, from config (DR-0001 §2.7). */
139
- readonly self: Endpoint;
138
+ /** Every mesh endpoint, this instance's own among them. Which one that is is
139
+ * settled by `identify`, not configured (DR-0001 §2.7). */
140
140
  readonly peers: readonly Endpoint[];
141
141
  readonly conns: ConnRegistry;
142
142
  readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
@@ -260,10 +260,9 @@ export class Mesh {
260
260
  readonly #retries = new Map<Endpoint, ReturnType<typeof setTimeout>>();
261
261
  readonly #backoff = new Map<Endpoint, number>();
262
262
  readonly #probe = new PeerProbe();
263
- /** The configured endpoints that turned out to be this instance, filled in by
264
- * `verify`. `self` is always among them; an alias of it that a peer list also
265
- * names joins it there, and none of them is dialled. */
266
- readonly #ours = new Set<Endpoint>();
263
+ /** Which of the configured endpoints is this instance, settled by `identify`
264
+ * before anything is dialled and fixed from then on (§5.5). */
265
+ #self: Endpoint | undefined;
267
266
  /** The authenticated endpoint-to-id mapping (DR-0001 §2.1), in both
268
267
  * directions: a handshake writes it, `to_instance` reads it to find the link
269
268
  * to dial down, and a disconnection leaves it standing so a peer that is out
@@ -294,11 +293,6 @@ export class Mesh {
294
293
  readonly relay: Relay;
295
294
 
296
295
  constructor(private readonly deps: MeshDeps) {
297
- // The table opens with the one binding this instance did not have to learn:
298
- // its own. That is what makes "an id already answering elsewhere" cover the
299
- // case of a peer claiming to be us — which is what the instance at a moved
300
- // instance's old URL looks like from the new one.
301
- this.#bind(deps.self, deps.id);
302
296
  this.relay = new Relay({
303
297
  publish: (topic, data, instance) => {
304
298
  this.#host?.publish(topic, data, instance);
@@ -335,15 +329,23 @@ export class Mesh {
335
329
  /** The peers this instance dials: the configured list without itself.
336
330
  *
337
331
  * The list is the same on every instance, which is what lets one file be
338
- * distributed to all of them (§8.2) — and it may name this instance, so
339
- * removing ourselves is the reader's job rather than the writer's. `verify`
340
- * is what found which entries are ours, the configured `self` among them. */
332
+ * distributed to all of them (§8.2) — and it names this instance too, so
333
+ * removing ourselves is the reader's job rather than the writer's. `identify`
334
+ * is what found which entry that is. */
341
335
  get peers(): Endpoint[] {
342
- return this.deps.peers.filter((peer) => !this.#ours.has(peer));
336
+ const self = this.self;
337
+ return this.deps.peers.filter((peer) => peer !== self);
343
338
  }
344
339
 
340
+ /** Where peers reach this instance, as the probe settled it (§7.1).
341
+ *
342
+ * Asked only after `identify`: everything that reads it — the handshake's
343
+ * `aud`, the mesh's own routes, what `hello` reports — happens on an instance
344
+ * that has already started, and a start where the probe did not settle ends
345
+ * instead. */
345
346
  get self(): Endpoint {
346
- return this.deps.self;
347
+ if (this.#self === undefined) throw new Error("this mesh has not identified itself yet");
348
+ return this.#self;
347
349
  }
348
350
 
349
351
  get id(): InstanceId {
@@ -364,7 +366,7 @@ export class Mesh {
364
366
  * peer whose link is down, which is the one a reader is looking for. */
365
367
  instances(): InstanceInfo[] {
366
368
  return [
367
- { id: this.deps.id, endpoint: this.deps.self, host: hostname(), reachable: true },
369
+ { id: this.deps.id, endpoint: this.self, host: hostname(), reachable: true },
368
370
  ...this.peers.map((peer) => {
369
371
  const id = this.#idOf.get(peer);
370
372
  return {
@@ -578,15 +580,21 @@ export class Mesh {
578
580
  else conn.send(frame);
579
581
  }
580
582
 
581
- /** Check the endpoint list against reality before anything is dialled (§7.1).
583
+ /** Settle which configured endpoint is this instance, before anything is
584
+ * dialled (§7.1).
582
585
  *
583
- * Run once the listener is up, because the probe this instance sends to its
584
- * own `self` has to arrive somewhere and that probe is the whole test: a
585
- * `self` that answers as somebody else ends the start, while a peer that is
586
- * merely asleep is recorded and dialled later. */
587
- async verify(): Promise<PeerReport> {
588
- const report = await this.#probe.verify(this.deps.self, this.deps.peers);
589
- for (const ours of report.ours) this.#ours.add(ours);
586
+ * Run once the listener is up, because the probe this instance sends itself
587
+ * has to arrive somewhere. A list that reaches this instance no times or
588
+ * more than once ends the start; a peer that is merely asleep is recorded
589
+ * and dialled later. */
590
+ async identify(): Promise<PeerReport> {
591
+ const report = await this.#probe.identify(this.deps.peers);
592
+ this.#self = report.self;
593
+ // The table opens with the one binding this instance did not have to learn:
594
+ // its own. That is what makes "an id already answering elsewhere" cover the
595
+ // case of a peer claiming to be us — which is what the instance at a moved
596
+ // instance's old URL looks like from the new one.
597
+ this.#bind(report.self, this.deps.id);
590
598
  return report;
591
599
  }
592
600
 
@@ -606,7 +614,7 @@ export class Mesh {
606
614
  * that fails any step throws, which is what leaves the connection anonymous
607
615
  * (§3.2 step 7). */
608
616
  async greet(conn: Requester, claim: MeshClaim): Promise<void> {
609
- const self = this.deps.self;
617
+ const self = this.self;
610
618
  // 1-3 of §5.7, asked before the key is fetched: the cheap comparisons come
611
619
  // first because the fetch reaches out to another host.
612
620
  if (claim.ver !== MESH_VER) {
@@ -770,15 +778,19 @@ export class Mesh {
770
778
  /** Answer the two requests that are served before anything is proven, or
771
779
  * nothing when the request is not one of them. */
772
780
  async route(request: Request): Promise<Response | undefined> {
773
- // Below `self` and nowhere else. The mesh's routes are the one part of the
774
- // surface that stays tied to the configured endpoint, because that tie is
775
- // what keeps two instances on one origin from answering for each other's
776
- // keys (mesh-peer-auth §6.3). The person's entry is matched by the end of
777
- // the path instead (DR-0001 §2.7).
778
781
  const pathname = new URL(request.url).pathname;
779
- const base = this.deps.self;
780
- if (isProbePath(pathname, base)) return await this.#answerProbe(request);
781
- const kid = kidOfPath(pathname, base);
782
+ // The probe is matched by the end of the path, because it is what settles
783
+ // which endpoint this instance is: while one is arriving there is no
784
+ // endpoint to hang it under.
785
+ if (isProbePath(pathname)) return await this.#answerProbe(request);
786
+ // The key is below this instance's own endpoint and nowhere else, which is
787
+ // what keeps two instances on one origin from answering for each other's
788
+ // keys (mesh-peer-auth §6.3); the person's entry is matched by the end of
789
+ // the path instead (DR-0001 §2.7). The probe has settled that endpoint by
790
+ // the time any key is asked for: a request arriving before then belongs to
791
+ // no handshake, since nothing has been dialled yet.
792
+ if (this.#self === undefined) return undefined;
793
+ const kid = kidOfPath(pathname, this.#self);
782
794
  if (kid !== undefined) return await this.#serveKey(kid, request);
783
795
  return undefined;
784
796
  }
@@ -824,7 +836,7 @@ export class Mesh {
824
836
  // one. The two meet at the `kid`.
825
837
  const claim = {
826
838
  ver: MESH_VER,
827
- iss: this.deps.self,
839
+ iss: this.self,
828
840
  aud: minted.aud,
829
841
  challenge: asked.challenge,
830
842
  exp: Math.floor((Date.now() + PROOF_LIFETIME_MS) / 1000),
@@ -853,7 +865,7 @@ export class Mesh {
853
865
 
854
866
  async #dial(peer: Endpoint): Promise<void> {
855
867
  if (this.#stopping || this.#links.has(peer)) return;
856
- const self = this.deps.self;
868
+ const self = this.self;
857
869
  const key = new EphemeralKey();
858
870
  const minted: Minted = { key, aud: peer };
859
871
  this.#minted.set(key.kid, minted);
@@ -968,7 +980,7 @@ export class Mesh {
968
980
  * Both connections are verified before either is dropped, so whichever
969
981
  * survives is one that was proven (§8.1). */
970
982
  #hold(peer: Endpoint, conn: Requester, dialledByUs: boolean): void {
971
- const self = this.deps.self;
983
+ const self = this.self;
972
984
  const held = this.#links.get(peer);
973
985
  if (held !== undefined) {
974
986
  if (held.conn === conn) return;
package/src/mesh/probe.ts CHANGED
@@ -6,15 +6,14 @@ import { probeEndpoint, type ProbeBody } from "./wire.ts";
6
6
  *
7
7
  * An endpoint that does not answer is either asleep or misconfigured, and
8
8
  * waiting longer tells the two apart no better. It bounds startup rather than
9
- * deciding correctness: only the probe this instance sends itself decides
10
- * anything. */
9
+ * deciding correctness: only the probe that lands back here decides anything. */
11
10
  export const PROBE_TIMEOUT_MS = 3_000;
12
11
 
13
- /** The configured endpoints do not describe this instance.
12
+ /** The configured endpoints do not say which instance this is.
14
13
  *
15
14
  * Its own class so startup can refuse the same way a broken config does (§8.3,
16
- * DV-Q9): `self` is a setting, and one that does not reach this process is a
17
- * setting that is wrong. */
15
+ * DV-Q9): a list that names this instance zero times, or twice, is a list that
16
+ * cannot be acted on. */
18
17
  export class SelfEndpointError extends Error {
19
18
  constructor(msg: string) {
20
19
  super(msg);
@@ -22,12 +21,10 @@ export class SelfEndpointError extends Error {
22
21
  }
23
22
  }
24
23
 
25
- /** What one round of probes found. */
24
+ /** What one round of probes settled. */
26
25
  export interface PeerReport {
27
- /** The configured endpoints that turned out to be this instance — `self`,
28
- * and any alias of it a peer list happens to name. They are not dialled: a
29
- * link to ourselves is not a link. */
30
- readonly ours: readonly Endpoint[];
26
+ /** The one configured endpoint that turned out to be this instance. */
27
+ readonly self: Endpoint;
31
28
  /** The endpoints that did not answer. Recorded and not refused: a peer that
32
29
  * is asleep is the normal state of this mesh (§7.1, DV-Q11). */
33
30
  readonly unreachable: readonly Endpoint[];
@@ -35,13 +32,17 @@ export interface PeerReport {
35
32
 
36
33
  /** The probes in flight, and the endpoint each was sent to.
37
34
  *
38
- * Since `self` is configured (DR-0001 §2.7), this no longer settles an identity:
39
- * it checks the setting. The probe to `self` has to come back here, which is
40
- * what catches a `self` that names somebody else, and the rest of the list is
41
- * probed to record what can be reached before anything is dialled.
35
+ * This is where an instance learns which of the configured endpoints it is
36
+ * (mesh-self-identification §5). A `token` is minted per endpoint and sent
37
+ * there; the one that arrives back at this process was sent to this process,
38
+ * and the endpoint it was addressed to is therefore this instance's own.
39
+ *
40
+ * Every endpoint is probed, this instance's own included: the probe to
41
+ * ourselves is the one that always lands, which is what makes a peer echoing
42
+ * a stolen token show up as two matches rather than as a wrong answer (§4.2).
42
43
  *
43
44
  * The table is destroyed when the run finishes: what the exercise leaves behind
44
- * is the report and nothing else (§7.3). */
45
+ * is the settled endpoint and nothing else (§7.3). */
45
46
  export class PeerProbe {
46
47
  #sent = new Map<string, Endpoint>();
47
48
  readonly #matched = new Set<Endpoint>();
@@ -54,10 +55,14 @@ export class PeerProbe {
54
55
  if (sentTo !== undefined) this.#matched.add(sentTo);
55
56
  }
56
57
 
57
- /** Ask `self` and every peer who answers there, and refuse to start if the
58
- * endpoint this instance calls its own is somebody else's. */
59
- async verify(self: Endpoint, peers: readonly Endpoint[]): Promise<PeerReport> {
60
- const targets = [...new Set([self, ...peers])];
58
+ /** Probe every configured endpoint and settle which one is this instance.
59
+ *
60
+ * An endpoint that did not answer is left out of the count rather than
61
+ * refused, so a mesh whose other host is asleep still starts (DV-Q11): the
62
+ * count only ever decides on endpoints that answered, and the probe to
63
+ * ourselves always does. */
64
+ async identify(peers: readonly Endpoint[]): Promise<PeerReport> {
65
+ const targets = [...new Set(peers)];
61
66
  this.#sent = new Map(targets.map((target) => [randomId(), target]));
62
67
  const unreachable: Endpoint[] = [];
63
68
  await Promise.all(
@@ -65,17 +70,17 @@ export class PeerProbe {
65
70
  if (!(await this.#probe(target, token))) unreachable.push(target);
66
71
  }),
67
72
  );
68
- const ours = [...this.#matched];
73
+ const matched = [...this.#matched];
69
74
  this.#sent = new Map();
70
75
  this.#matched.clear();
71
- if (!ours.includes(self)) {
76
+ if (matched.length !== 1) {
72
77
  throw new SelfEndpointError(
73
- unreachable.includes(self)
74
- ? `self is ${self}, which did not answer this instance's probe`
75
- : `self is ${self}, which answers as another instance`,
78
+ matched.length === 0
79
+ ? `none of the configured endpoints reached this instance: ${targets.join(", ")}`
80
+ : `several configured endpoints reach this instance: ${matched.join(", ")}`,
76
81
  );
77
82
  }
78
- return { ours, unreachable };
83
+ return { self: matched[0] as Endpoint, unreachable };
79
84
  }
80
85
 
81
86
  /** Whether the endpoint answered. What it answered does not matter: the
package/src/mesh/wire.ts CHANGED
@@ -1,54 +1,63 @@
1
1
  import type { Endpoint } from "@ccmsg/protocol";
2
2
  import type { MeshJwk } from "./keys.ts";
3
3
 
4
- /** The paths an instance's endpoint URL stands in front of.
4
+ /** The routes an instance's endpoint stands in front of.
5
5
  *
6
- * An `Endpoint` is compared whole, path included (contract, `Endpoint`), so
7
- * everything an instance serves hangs below it. That is also what keeps two
8
- * instances sharing one origin apart: the key of `wss://h/a` is only ever
9
- * fetched from below `/a`, so `wss://h/b` cannot answer for it and a proof made
10
- * with b's key cannot pass as a's (mesh-peer-auth §6.3). The separation is the
11
- * shape of the URLs rather than a rule written somewhere. */
12
- const WS_PATH = "/ws";
13
- const JWK_PATH = "/mesh/jwk/";
14
- const PROBE_PATH = "/mesh/probe";
6
+ * An `Endpoint` is the instance's public base URL, ending in a slash and
7
+ * naming no route of its own (contract, `Endpoint`), so everything an instance
8
+ * serves is reached by appending to it. That is also what keeps two instances
9
+ * sharing one origin apart: the key of `https://h/a/` is only ever fetched from
10
+ * below `/a/`, so `https://h/b/` cannot answer for it and a proof made with b's
11
+ * key cannot pass as a's (mesh-peer-auth §6.3). The separation is the shape of
12
+ * the URLs rather than a rule written somewhere.
13
+ *
14
+ * The probe is the exception, and has to be: it is what tells an instance which
15
+ * endpoint it is, so while one is arriving there is nothing yet to hang it
16
+ * under. */
17
+ const WS_ROUTE = "ws";
18
+ const JWK_ROUTE = "mesh/jwk/";
19
+ const PROBE_ROUTE = "mesh/probe";
20
+ const PROBE_PATH = `/${PROBE_ROUTE}`;
15
21
 
16
- /** Where a peer's mesh link is dialled. */
22
+ /** Where a peer's mesh link is dialled.
23
+ *
24
+ * The endpoint's own scheme, kept: the link is an HTTP connection upgraded in
25
+ * place, so there is no second scheme to rewrite it into (DR-0001 §2.7). */
17
26
  export function wsEndpoint(endpoint: Endpoint): string {
18
- return `${endpoint}${WS_PATH}`;
27
+ return `${endpoint}${WS_ROUTE}`;
19
28
  }
20
29
 
21
30
  /** Where one connection's key is fetched, and the challenge for it left.
22
31
  *
23
- * `http` rather than `ws` because this is the second connection of §6, which
24
- * carries one request and closes: the protocol asks that it be a connection of
25
- * its own outside the one being authenticated, not that it be a WebSocket. */
32
+ * A plain request rather than a frame on the link, because this is the second
33
+ * connection of §6: the protocol asks that the key be fetched outside the
34
+ * connection being authenticated. */
26
35
  export function jwkEndpoint(endpoint: Endpoint, kid: string): string {
27
- return `${httpBase(endpoint)}${JWK_PATH}${encodeURIComponent(kid)}`;
36
+ return `${endpoint}${JWK_ROUTE}${encodeURIComponent(kid)}`;
28
37
  }
29
38
 
30
39
  export function probeEndpoint(endpoint: Endpoint): string {
31
- return `${httpBase(endpoint)}${PROBE_PATH}`;
40
+ return `${endpoint}${PROBE_ROUTE}`;
32
41
  }
33
42
 
34
43
  /** The `kid` a request names, or nothing when the path is not a key request. */
35
44
  export function kidOfPath(pathname: string, self: Endpoint): string | undefined {
36
- const prefix = `${new URL(self).pathname.replace(/\/$/, "")}${JWK_PATH}`;
45
+ const prefix = `${new URL(self).pathname}${JWK_ROUTE}`;
37
46
  if (!pathname.startsWith(prefix)) return undefined;
38
47
  const kid = decodeURIComponent(pathname.slice(prefix.length));
39
48
  return kid === "" ? undefined : kid;
40
49
  }
41
50
 
42
- export function isProbePath(pathname: string, self: Endpoint): boolean {
43
- return pathname === `${new URL(self).pathname.replace(/\/$/, "")}${PROBE_PATH}`;
44
- }
45
-
46
- /** The same authority and path, reached over HTTP. A `ws` URL and the `http`
47
- * one beside it are one server; the scheme differs and nothing else does. */
48
- function httpBase(endpoint: Endpoint): string {
49
- const url = new URL(endpoint);
50
- url.protocol = url.protocol === "wss:" ? "https:" : "http:";
51
- return url.href.replace(/\/$/, "");
51
+ /** Whether this request is a probe.
52
+ *
53
+ * Matched by the end of the path and not below an endpoint, because a probe is
54
+ * what settles which endpoint this instance is: at the moment one arrives there
55
+ * is no endpoint to hang it under, and the prefix it came in on is whatever the
56
+ * sender's list or a proxy in front of it says. Nothing is decided here anyway
57
+ * the receiver only echoes acceptance, and the comparison belongs to whoever
58
+ * minted the token (§5.1). */
59
+ export function isProbePath(pathname: string): boolean {
60
+ return pathname.endsWith(PROBE_PATH);
52
61
  }
53
62
 
54
63
  /** The subprotocol a dialling instance offers.
@@ -51,7 +51,7 @@ export interface WsOptions {
51
51
  * is either buffered whole by Bun or dropped whole — and the queue absorbs
52
52
  * that difference here (§3.1). */
53
53
  export function serveWs(options: WsOptions): Listener {
54
- const path = options.path ?? "/ws";
54
+ const path = options.path ?? ENTRY_PATH;
55
55
  const entry = options.entry ?? OPEN;
56
56
  // The per-connection state is made in `open`, where the socket to write to
57
57
  // exists, so the upgrade carries nothing and the socket keeps no data of its
@@ -158,6 +158,12 @@ export function serveWs(options: WsOptions): Listener {
158
158
  };
159
159
  }
160
160
 
161
+ /** Where a person's WebSocket is answered, on whatever prefix a proxy puts the
162
+ * instance under. Named here because it is one door, and more than one place
163
+ * has to know what it is called: the entry match below, and the registration
164
+ * URL, which is the same address without it (DR-0001 §2.2). */
165
+ export const ENTRY_PATH = "/ws";
166
+
161
167
  /** Whether a request's path is this listener's entry.
162
168
  *
163
169
  * Matched at the end rather than whole, so a proxy that puts the instance under