@ccmsg/cli 0.2.1 → 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.1",
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 {
@@ -35,7 +40,6 @@ import {
35
40
  WebAuthnError,
36
41
  } from "./webauthn.ts";
37
42
  import { CborError } from "./cbor.ts";
38
- import { ENTRY_PATH } from "../transport/index.ts";
39
43
 
40
44
  /** How long an access token is accepted, and how long a refresh token is.
41
45
  *
@@ -101,10 +105,7 @@ export interface AuthorizedConn {
101
105
  export interface AuthDeps {
102
106
  readonly self: InstanceId;
103
107
  readonly records: AuthRecords;
104
- /** The pages allowed to run these exchanges: `clientDataJSON.origin` is
105
- * compared against this, and so is the CORS answer (§2.3). */
106
- readonly origins: () => readonly string[];
107
- /** 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
108
109
  * when the operator names none. */
109
110
  readonly endpoint: () => Endpoint | undefined;
110
111
  /** The instance's name as a person operates it, carried in the URL for
@@ -176,7 +177,6 @@ export class Auth {
176
177
  * terminal this ran on. Somebody holding the URL alone cannot register. */
177
178
  issue(options: {
178
179
  readonly endpoint?: Endpoint;
179
- readonly rpId?: string;
180
180
  readonly label?: string;
181
181
  readonly sub?: Subject;
182
182
  }): IssuedRegistration {
@@ -187,14 +187,21 @@ export class Auth {
187
187
  "この instance には endpoint が無いので、登録先の URL を引数で渡してください",
188
188
  );
189
189
  }
190
- const host = hostOf(endpoint);
191
- const rpId = options.rpId ?? host;
192
- // The relying party is a domain the endpoint's host belongs to, and nothing
193
- // wider: a credential made for a suffix this instance does not sit under
194
- // would be usable at every other host under it (§2.3).
195
- if (!isRegistrableSuffix(rpId, host)) {
196
- 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
+ );
197
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);
198
205
  const sub = options.sub ?? this.#nextSubject();
199
206
  if (this.deps.records.removed(sub)) {
200
207
  throw new OpError("forbidden", `${sub} は削除済みなので、この名前では登録できません`);
@@ -216,7 +223,7 @@ export class Auth {
216
223
  this.#pending.set(claims.jti, { claims, secret, code, attempts: 0 });
217
224
  return {
218
225
  sub,
219
- url: `${webOrigin(endpoint)}/#register=${sign(claims, secret)}`,
226
+ url: `${endpoint}#register=${sign(claims, secret)}`,
220
227
  code,
221
228
  user_id: claims.user_id,
222
229
  expires_at: claims.expires_at,
@@ -378,13 +385,17 @@ export class Auth {
378
385
  * here, by whoever the browser reached (§2.6). */
379
386
  async register(
380
387
  args: AuthRegisterArgs,
381
- from: { ip?: string; userAgent?: string } = {},
388
+ from: { ip?: string; userAgent?: string; path?: string } = {},
382
389
  ): Promise<MintedSession> {
383
390
  // What the URL says about itself, before anything has vouched for it. It is
384
391
  // read to know which relying party the credential should have been made
385
392
  // under; nothing is decided by it, because the same fields come back
386
393
  // authenticated below and the two are held to each other.
387
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);
388
399
  // What the page answered, verified before anything is spent: a challenge is
389
400
  // good once, so consuming it for a message that then fails to verify would
390
401
  // let a caller burn challenges without ever holding a credential (m9).
@@ -392,7 +403,7 @@ export class Auth {
392
403
  const verified = refusable(() =>
393
404
  verifyRegistration(args.credential, {
394
405
  challenge,
395
- origins: this.deps.origins(),
406
+ origin: originOf(stated.endpoint),
396
407
  rpId: stated.rp_id,
397
408
  }),
398
409
  );
@@ -428,6 +439,7 @@ export class Auth {
428
439
  credential_id: verified.credentialId,
429
440
  public_key: verified.publicKey,
430
441
  user_handle: claims.user_id,
442
+ endpoint: claims.endpoint,
431
443
  rp_id: claims.rp_id,
432
444
  sign_count: verified.signCount,
433
445
  ...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
@@ -505,12 +517,16 @@ export class Auth {
505
517
 
506
518
  async assert(
507
519
  args: AuthAssertArgs,
508
- from: { ip?: string; userAgent?: string } = {},
520
+ from: { ip?: string; userAgent?: string; path?: string } = {},
509
521
  ): Promise<MintedSession> {
510
522
  const record = this.deps.records.credential(args.credential.raw_id);
511
523
  if (record === undefined) {
512
524
  throw new OpError("auth_invalid", "この credential は登録されていません");
513
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);
514
530
  // A resident credential answers with the handle it was created against,
515
531
  // which is how a person is found without having named an account. It is
516
532
  // held to what the registration settled: a handle naming somebody else is
@@ -535,7 +551,7 @@ export class Auth {
535
551
  },
536
552
  {
537
553
  challenge: args.challenge.challenge,
538
- origins: this.deps.origins(),
554
+ origin: originOf(record.endpoint),
539
555
  rpIds: this.#rpIdFor(record),
540
556
  },
541
557
  ),
@@ -556,33 +572,52 @@ export class Auth {
556
572
  return this.mint(record.sub);
557
573
  }
558
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
+
559
588
  /** The relying party an assertion is checked against.
560
589
  *
561
590
  * The one the credential was registered under, which the record carries: a
562
591
  * passkey only ever answers for the domain it was made under, and the
563
- * endpoint being reached says nothing about that (§2.3). Only a record
564
- * written before the field existed falls back to the names this instance was
565
- * 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. */
566
595
  #rpIdFor(record: CredentialRecord): string[] {
567
- if (record.rp_id !== undefined) return [record.rp_id];
568
- return this.#configuredNames();
596
+ return [record.rp_id ?? hostOf(record.endpoint)];
569
597
  }
570
598
 
571
- #configuredNames(): string[] {
572
- const names = new Set<string>();
573
- for (const origin of this.deps.origins()) {
574
- try {
575
- names.add(new URL(origin).hostname);
576
- } catch {
577
- // A configured value that is not a URL names no host.
578
- }
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));
579
616
  }
617
+ for (const held of this.#pending.values()) origins.add(originOf(held.claims.endpoint));
580
618
  const endpoint = this.deps.endpoint();
581
- if (endpoint !== undefined) names.add(hostOf(endpoint));
582
- if (names.size === 0) {
583
- throw new OpError("auth_invalid", "この instance には relying party がありません");
584
- }
585
- return [...names];
619
+ if (endpoint !== undefined) origins.add(originOf(endpoint));
620
+ return [...origins];
586
621
  }
587
622
 
588
623
  // --- tokens (§2.4) ---
@@ -964,28 +999,22 @@ export function hostOf(endpoint: Endpoint): string {
964
999
  return new URL(endpoint).hostname;
965
1000
  }
966
1001
 
967
- /** Where the page that runs the registration is served from.
1002
+ /** Whether a request that arrived at this path was made to this endpoint.
968
1003
  *
969
- * The endpoint over HTTP, with the WebSocket's own path segment taken off: an
970
- * endpoint is the address of a door (`wss://h/personal/ws`), and the web UI is
971
- * what is served where that door is (`https://h/personal/`). Leaving the
972
- * segment on would send the person to the WebSocket rather than to the page
973
- * (DR-0001 §2.2). An endpoint that names no path is an instance whose UI is at
974
- * the root. */
975
- export function webOrigin(endpoint: Endpoint): string {
976
- const url = new URL(endpoint);
977
- url.protocol = url.protocol === "wss:" ? "https:" : "http:";
978
- const path = url.pathname.replace(/\/$/, "");
979
- const prefix = path.endsWith(ENTRY_PATH) ? path.slice(0, -ENTRY_PATH.length) : path;
980
- return `${url.origin}${prefix}`;
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;
981
1014
  }
982
1015
 
983
- /** Whether a relying party id is the host or a domain the host sits under.
984
- *
985
- * The WebAuthn rule as far as this instance can check it: a credential made
986
- * for a suffix the endpoint does not sit under would be usable at hosts this
987
- * instance has nothing to do with. Whether the suffix is one a registrar hands
988
- * out is the browser's to refuse, and it does. */
989
- export function isRegistrableSuffix(rpId: string, host: string): boolean {
990
- 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;
991
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.