@ccmsg/cli 0.1.0 → 0.2.1

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.
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
+ type AuthRecord,
4
5
  type Capability,
5
6
  type Endpoint,
6
7
  type InstanceId,
@@ -52,6 +53,7 @@ import {
52
53
  import { topicHandlers, Topics } from "../topics/index.ts";
53
54
  import { TranscriptFiles, Transcripts } from "../transcript/index.ts";
54
55
  import {
56
+ type AuthorizedUpgrade,
55
57
  ConnRegistry,
56
58
  type EntryPolicy,
57
59
  type Listener,
@@ -76,6 +78,16 @@ import {
76
78
  translateHandlers,
77
79
  translateSetup,
78
80
  } from "../translate/index.ts";
81
+ import {
82
+ adminRequestOf,
83
+ Auth,
84
+ AuthRecords,
85
+ AuthTopic,
86
+ authHandlers,
87
+ handleAdmin,
88
+ handleAuth,
89
+ recordsDir,
90
+ } from "../auth/index.ts";
79
91
  import { type EntryConfig, type InstanceConfig, loadConfig } from "./config.ts";
80
92
  import { completeHandlers } from "./handlers.ts";
81
93
  import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
@@ -94,6 +106,14 @@ export interface StartOptions {
94
106
  /** Overrides the mesh's own intervals, for a test that cannot wait out a
95
107
  * heartbeat or a reconnection backoff. */
96
108
  readonly meshTiming?: MeshTiming;
109
+ /** The clock the person's authentication judges expiry against.
110
+ *
111
+ * An access token lasts hours, so a test that wanted to watch a connection
112
+ * reach its deadline would have to wait them out. Moving this instead lets
113
+ * the deadline arrive on the real path — the timer the connection was held
114
+ * with, and the close at the end of it — rather than through a second way of
115
+ * closing that only a test ever takes. */
116
+ readonly now?: () => Timestamp;
97
117
  }
98
118
 
99
119
  /** The mesh intervals a caller may shorten. The values themselves, and why they
@@ -179,6 +199,7 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
179
199
  gateway,
180
200
  helper,
181
201
  wiring,
202
+ options.now,
182
203
  );
183
204
  wiring?.attach(instance);
184
205
  await instance.listen();
@@ -246,10 +267,11 @@ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiri
246
267
  instance === undefined
247
268
  ? Promise.resolve(failure(undefined, "internal_error", "this instance is still starting"))
248
269
  : instance.handle(frame, conn),
249
- entry: entryPolicy(config, true),
270
+ entry: entryPolicy(config, true, () => instance?.auth),
250
271
  route: async (request) => (await mesh.route(request)) ?? (await instance?.route(request)),
251
272
  onConn: (conn, info) => {
252
273
  mesh.accept(conn, info);
274
+ instance?.accepted(conn, info);
253
275
  },
254
276
  });
255
277
  try {
@@ -292,6 +314,9 @@ export class Instance {
292
314
  readonly #direct: DirectRoute;
293
315
  readonly #notify: Notify;
294
316
  readonly #translate: Translate | undefined;
317
+ /** The person's authentication: who may open a connection, and the records
318
+ * that say so (DR-0001). */
319
+ readonly #auth: Auth;
295
320
  readonly #handlers: Handlers;
296
321
  readonly #capabilities: ReadonlySet<Capability>;
297
322
  /** Set the moment shutdown starts, which is the re-entry guard of §8.5 step
@@ -318,6 +343,8 @@ export class Instance {
318
343
  setup: GatewaySetup = {},
319
344
  helper?: string,
320
345
  wiring?: MeshWiring,
346
+ /** The clock the person's authentication reads (`StartOptions.now`). */
347
+ now?: () => Timestamp,
321
348
  ) {
322
349
  this.#conns = wiring?.conns ?? new ConnRegistry();
323
350
  this.#mesh = wiring?.mesh;
@@ -346,6 +373,14 @@ export class Instance {
346
373
  publish: (topic, data, instance) => {
347
374
  this.#topics.publish(topic, data, instance);
348
375
  },
376
+ // What a peer wrote on `auth_records`, folded into the set this instance
377
+ // holds. It is not relayed onward: every instance subscribes to every
378
+ // peer, so a record reaches all of them without anyone repeating it, and
379
+ // what this instance writes travels as its own (DR-0001 §2.6).
380
+ element: (_topic, _instance, data) => {
381
+ const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
382
+ if (Array.isArray(stated)) this.#auth.merge(stated);
383
+ },
349
384
  // What `peers` says about the instances is this instance's own view, so
350
385
  // it is restated when that view moves (§7.5).
351
386
  changed: () => {
@@ -390,6 +425,7 @@ export class Instance {
390
425
  this.#sessions = new Sessions({
391
426
  self: this.self,
392
427
  endpoint: selfEndpoint(config),
428
+ authExpiresAt: (conn) => this.#auth.expiresAt(conn),
393
429
  configHome: paths.configHome,
394
430
  stateDir: paths.stateDir,
395
431
  capabilities: [...this.#capabilities],
@@ -474,6 +510,33 @@ export class Instance {
474
510
  });
475
511
  this.#topics.attach("kv", kv);
476
512
 
513
+ // The credentials, tokens and removals the cluster shares (DR-0001 §2.6).
514
+ // Written down beside the store and for the same reason: none of it is
515
+ // derived from anything else this instance holds (§3.6).
516
+ const records = new AuthRecords({
517
+ dir: recordsDir(paths.stateDir),
518
+ self: this.self,
519
+ ...(now === undefined ? {} : { now }),
520
+ publish: (written) => {
521
+ this.#topics.publish("auth_records", { records: written });
522
+ },
523
+ });
524
+ this.#auth = new Auth({
525
+ self: this.self,
526
+ records,
527
+ origins: () => config.entry?.origins ?? [],
528
+ endpoint: () => selfEndpoint(config),
529
+ unit: paths.key,
530
+ ...(this.#mesh === undefined
531
+ ? {}
532
+ : { ask: (to, op, args) => (this.#mesh as Mesh).ask(to, op, args) }),
533
+ ...(now === undefined ? {} : { now }),
534
+ log: (msg, fields) => {
535
+ this.log.write(msg, fields);
536
+ },
537
+ });
538
+ this.#topics.attach("auth_records", new AuthTopic(this.self, records));
539
+
477
540
  // The upstreams that answer a question rather than hold a value. Each is
478
541
  // built only where its config named one, and dispatch has already refused
479
542
  // the ops for the capability this instance then does not have.
@@ -532,6 +595,7 @@ export class Instance {
532
595
  ...(this.#translate === undefined ? {} : translateHandlers(this.#translate)),
533
596
  ...gatewayHandlers(setup),
534
597
  ...kvHandlers(kv),
598
+ ...authHandlers(this.#auth),
535
599
  instance_ping: (): InstancePingResult => this.ping(),
536
600
  instance_shutdown: () => {
537
601
  // The reply goes out when this handler's value reaches the driver, so
@@ -557,7 +621,14 @@ export class Instance {
557
621
  listenUds({
558
622
  path: this.paths.socketReal,
559
623
  conns: this.#conns,
560
- handle: (frame, conn) => this.handle(frame, conn),
624
+ // The one door, plus the administrative frames that may be asked only
625
+ // here: registering a passkey is local by design, and reaching this
626
+ // address is what says the caller is local (DR-0001 §2.2).
627
+ handle: (frame, conn) => {
628
+ const admin = adminRequestOf(frame);
629
+ if (admin !== undefined) return Promise.resolve(handleAdmin(this.#auth, admin));
630
+ return this.handle(frame, conn);
631
+ },
561
632
  }),
562
633
  );
563
634
  // The address clients use, moved onto this process once it is accepting.
@@ -572,7 +643,10 @@ export class Instance {
572
643
  port: this.config.entry.port,
573
644
  conns: this.#conns,
574
645
  handle: (frame, conn) => this.handle(frame, conn),
575
- entry: entryPolicy(this.config, false),
646
+ entry: entryPolicy(this.config, false, () => this.#auth),
647
+ onConn: (conn, info) => {
648
+ this.accepted(conn, info);
649
+ },
576
650
  // The gateway posts to the address this instance already serves,
577
651
  // behind the same entry check (§3.1).
578
652
  route: (request) => this.route(request),
@@ -596,8 +670,29 @@ export class Instance {
596
670
  * gateway's webhook is the one such route this instance answers itself; the
597
671
  * mesh's two are answered before this is asked, because they are served
598
672
  * before anything is proven and this instance's own routes are not. */
599
- route(request: Request): Promise<Response | undefined> {
600
- return this.#gateway.route(request);
673
+ async route(request: Request): Promise<Response | undefined> {
674
+ // The person's authentication comes first: it is the one route reached
675
+ // before anything is proven, and the gateway's webhook carries its own
676
+ // 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
+ );
682
+ if (authorized !== undefined) return authorized;
683
+ return await this.#gateway.route(request);
684
+ }
685
+
686
+ /** The person's authentication, for the entry policy and for a test. */
687
+ get auth(): Auth {
688
+ return this.#auth;
689
+ }
690
+
691
+ /** A connection the listener accepted. One that an access token opened is
692
+ * held until that token runs out (DR-0001 §2.5); a peer's is the mesh's. */
693
+ accepted(conn: Requester, info: { readonly auth?: AuthorizedUpgrade }): void {
694
+ if (info.auth === undefined) return;
695
+ this.#auth.hold(conn, { sub: info.auth.sub, expiresAt: info.auth.expiresAt });
601
696
  }
602
697
 
603
698
  /** Every bound WebSocket address, as `host:port`. */
@@ -847,11 +942,15 @@ export function selfEndpoint(config: InstanceConfig): Endpoint | undefined {
847
942
  * the one that must say so. An empty `source_ips` leaves the addresses to the
848
943
  * bind, which for the default loopback host is this machine.
849
944
  *
850
- * These two are the whole of it until the person's own authentication lands
851
- * (DR-0001): the entry token they replaced only ever restated the uid boundary,
852
- * which on a tailnet nothing here can cross anyway, and a passkey is what will
853
- * answer "who came" rather than "could they read a file". */
854
- function entryPolicy(config: InstanceConfig, mesh: boolean): EntryPolicy {
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. */
949
+ function entryPolicy(
950
+ config: InstanceConfig,
951
+ mesh: boolean,
952
+ auth: () => Auth | undefined,
953
+ ): EntryPolicy {
855
954
  const entry = config.entry;
856
955
  if (entry === undefined) return {};
857
956
  return {
@@ -873,14 +972,29 @@ function entryPolicy(config: InstanceConfig, mesh: boolean): EntryPolicy {
873
972
  if (mesh && offered.includes(MESH_PROTOCOL)) {
874
973
  return { ok: true, protocol: MESH_PROTOCOL, mesh: true };
875
974
  }
876
- // The handshake echoes a subprotocol only when one was offered: a browser
877
- // fails a connection whose reply names none of what it asked for.
878
- const selected = offered[0];
879
- return selected === undefined ? { ok: true } : { ok: true, protocol: selected };
975
+ // Everyone else is a person, and a person presents the access token they
976
+ // were given when they authenticated. It rides in a subprotocol because
977
+ // that is the only field a browser lets a WebSocket handshake carry.
978
+ const presented = offered.find((name) => name.startsWith(TOKEN_PROTOCOL));
979
+ if (presented === undefined) {
980
+ return { ok: false, reason: "this connection presents no access token" };
981
+ }
982
+ const held = auth();
983
+ const admitted = held?.admits(presented.slice(TOKEN_PROTOCOL.length));
984
+ if (admitted === undefined) {
985
+ return { ok: false, reason: "this access token is not accepted" };
986
+ }
987
+ // The handshake echoes the subprotocol it selected: a browser fails a
988
+ // connection whose reply names none of what it asked for.
989
+ return { ok: true, protocol: presented, auth: admitted };
880
990
  },
881
991
  };
882
992
  }
883
993
 
994
+ /** How an access token reaches a WebSocket handshake (DR-0001 §2.4). The proxy
995
+ * in front of an instance has to pass `Sec-WebSocket-Protocol` through. */
996
+ export const TOKEN_PROTOCOL = "ccmsg.token.";
997
+
884
998
  function protocolsOf(request: Request): string[] {
885
999
  const header = request.headers.get("sec-websocket-protocol");
886
1000
  if (header === null) return [];
package/src/mesh/mesh.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type SettledIdentity,
19
19
  } from "../dispatch/index.ts";
20
20
  import type { TopicValue } from "../topics/index.ts";
21
- import { isClusterTopic, Relay } from "./relay.ts";
21
+ import { AUTH_TOPIC, isClusterTopic, Relay } from "./relay.ts";
22
22
  import {
23
23
  ALLOWED_ALGS,
24
24
  EphemeralKey,
@@ -120,6 +120,13 @@ export interface MeshHost {
120
120
  handle(frame: unknown, conn: Requester): Promise<DispatchResult>;
121
121
  /** Hand a relayed frame to this instance's own subscribers (§7.4). */
122
122
  publish(topic: string, data: unknown, instance: InstanceId): void;
123
+ /** Take a frame on a topic the relay does not carry.
124
+ *
125
+ * `auth_records` is the one: its granularity is `element`, so a frame states
126
+ * the entries that moved rather than a whole value per instance, and there is
127
+ * nothing for the relay's last-value-per-instance table to hold. What
128
+ * receives it is the set itself, which merges by key (DR-0001 §2.6). */
129
+ element(topic: string, instance: InstanceId, data: unknown): void;
123
130
  /** Which instances can be reached has changed, which is part of what this
124
131
  * instance states on `peers` (§7.5). */
125
132
  changed(): void;
@@ -280,7 +287,7 @@ export class Mesh {
280
287
  * always among them: it is the routing table of §7.3, and a question about
281
288
  * where a session lives is answered whether or not anyone is subscribed
282
289
  * (§6.3, "reading the current value is not what subscription drives"). */
283
- readonly #demanded = new Set<string>(["peers"]);
290
+ readonly #demanded = new Set<string>(["peers", AUTH_TOPIC]);
284
291
  #host: MeshHost | undefined;
285
292
 
286
293
  /** What the peers said, kept across a disconnection (§7.5). */
@@ -458,6 +465,35 @@ export class Mesh {
458
465
  return await settled.promise;
459
466
  }
460
467
 
468
+ /** Ask another instance one op, as this instance rather than for anybody.
469
+ *
470
+ * What the person's authentication needs of a peer (`auth_resolve`,
471
+ * `auth_rotate`) is a fact only its issuer holds, asked for by the instance
472
+ * that needs it — so the `caller` is this instance's own role, and the
473
+ * request travels the ordinary forwarding path (§7.3, DR-0001 §2.6).
474
+ *
475
+ * The body of the reply is answered, and a refusal is thrown as the error the
476
+ * far end named, so a caller reads one outcome rather than a result kind. */
477
+ async ask(
478
+ to: InstanceId,
479
+ op: string,
480
+ args: Record<string, unknown>,
481
+ ): Promise<Record<string, unknown>> {
482
+ const result = await this.forward(
483
+ to,
484
+ { op, request_id: `mesh-ask-${randomId()}`, ...args },
485
+ { role: "instance" },
486
+ );
487
+ if (result.kind === "reply") {
488
+ const { ok: _ok, request_id: _id, ...body } = result.response;
489
+ return body;
490
+ }
491
+ if (result.kind === "error") {
492
+ throw new OpError(result.response.error.code, result.response.error.msg);
493
+ }
494
+ throw new OpError("instance_unreachable", `${to} did not answer ${op}`);
495
+ }
496
+
461
497
  /** Whether this connection is an established link to a peer. */
462
498
  isLink(conn: Requester): boolean {
463
499
  return this.#linkOf.has(conn);
@@ -499,7 +535,10 @@ export class Mesh {
499
535
  * instance asks of every peer, and the frames come back unchanged (§7.4).
500
536
  * `peers` is never given up, because it is also the routing table. */
501
537
  demand(topic: string, wanted: boolean): void {
502
- if (!isClusterTopic(topic)) return;
538
+ // `auth_records` is never given up and never asked for on demand: every
539
+ // instance holds the whole set whether or not anything local is watching
540
+ // it, the way `peers` is also the routing table (§7.4, DR-0001 §2.6).
541
+ if (topic === AUTH_TOPIC || !isClusterTopic(topic)) return;
503
542
  if (wanted) {
504
543
  if (this.#demanded.has(topic)) return;
505
544
  this.#demanded.add(topic);
@@ -527,7 +566,13 @@ export class Mesh {
527
566
  // have in common is that they are this deployment's people rather than
528
567
  // any one session: a cluster topic is the same value for all of them
529
568
  // (§6.2), so there is nothing narrower to name.
530
- caller: { role: "user" } satisfies CallerIdentity,
569
+ //
570
+ // `auth_records` is the exception, and the one topic no person may hear:
571
+ // it carries the tokens that authenticate them, so the instance asks for
572
+ // it as itself (DR-0001 §2.6).
573
+ caller: (topic === AUTH_TOPIC
574
+ ? { role: "instance" }
575
+ : { role: "user" }) satisfies CallerIdentity,
531
576
  };
532
577
  if (afterAck) conn.deferSend(frame);
533
578
  else conn.send(frame);
@@ -706,6 +751,10 @@ export class Mesh {
706
751
  // Our own value, come back around a triangle. Relaying it again would put
707
752
  // this instance's value on the wire as something it received.
708
753
  if (instance === this.deps.id) return true;
754
+ if (topic === AUTH_TOPIC) {
755
+ this.#host?.element(topic, instance as InstanceId, fields["data"]);
756
+ return true;
757
+ }
709
758
  this.relay.accept(instance as InstanceId, topic, fields["data"]);
710
759
  return true;
711
760
  }
package/src/mesh/relay.ts CHANGED
@@ -22,6 +22,13 @@ export const CLUSTER_TOPICS: readonly string[] = PLAIN_TOPICS.filter(
22
22
  (topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole",
23
23
  );
24
24
 
25
+ /** The one topic the mesh carries that the relay does not.
26
+ *
27
+ * It is `element`-granular, so what travels is the entries that changed and the
28
+ * receiver merges them by key; and it is the instances' own, so it is asked for
29
+ * as the instance rather than on a person's behalf (DR-0001 §2.6). */
30
+ export const AUTH_TOPIC = "auth_records";
31
+
25
32
  export function isClusterTopic(topic: string): boolean {
26
33
  return CLUSTER_TOPICS.includes(topic);
27
34
  }
@@ -35,6 +35,10 @@ export interface SessionsDeps {
35
35
  * what a peer is to dial is neither that nor derivable from the id. Absent on
36
36
  * an instance reached by the unix socket alone, which has no URL to state. */
37
37
  readonly endpoint?: Endpoint;
38
+ /** When the connection's authorization runs out, on one an access token
39
+ * opened (DR-0001 §2.5). Absent on the unix socket, where reaching the
40
+ * instance is itself the permission, and on a mesh link. */
41
+ readonly authExpiresAt?: (conn: Requester) => Timestamp | undefined;
38
42
  /** The one config home this instance answers for (§8.2). Its `sessions/` is
39
43
  * the only directory read, and no other config home is ever looked for (M6). */
40
44
  readonly configHome: string;
@@ -238,6 +242,7 @@ export class Sessions implements UpstreamResource {
238
242
 
239
243
  /** What every greeting answers, once whatever had to be settled has been. */
240
244
  #greeted(args: HelloArgs, input: HandlerInput): HelloResult {
245
+ const expiresAt = this.deps.authExpiresAt?.(input.conn);
241
246
  const sid = requiredSid(args);
242
247
  if (sid !== undefined) {
243
248
  this.register(sid, args, this.deps.configHome);
@@ -247,25 +252,22 @@ export class Sessions implements UpstreamResource {
247
252
  protocol_version: PROTOCOL_VERSION,
248
253
  instance: this.deps.self,
249
254
  ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
250
- // Without a mesh the cluster is this instance alone. It appears in the
251
- // list only where it has a URL to be named by: an instance serving the
252
- // unix socket alone is reached by nothing that could dial an endpoint,
253
- // and `instance` above has already said who is answering.
254
- instances:
255
- this.deps.mesh?.instances() ??
256
- (this.deps.endpoint === undefined
257
- ? []
258
- : [
259
- {
260
- id: this.deps.self,
261
- endpoint: this.deps.endpoint,
262
- host: hostname(),
263
- reachable: true,
264
- },
265
- ]),
255
+ // Without a mesh the cluster is this instance alone, and it says so:
256
+ // an instance serving the unix socket alone has no URL to be dialed at,
257
+ // which is a row without an endpoint rather than no row (contract,
258
+ // `InstanceInfo`).
259
+ instances: this.deps.mesh?.instances() ?? [
260
+ {
261
+ id: this.deps.self,
262
+ ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
263
+ host: hostname(),
264
+ reachable: true,
265
+ },
266
+ ],
266
267
  capabilities: [...this.deps.capabilities],
267
268
  version: this.deps.version,
268
269
  started_at: this.deps.startedAt,
270
+ ...(expiresAt === undefined ? {} : { auth_expires_at: expiresAt }),
269
271
  };
270
272
  }
271
273
 
@@ -31,9 +31,19 @@ export type UpgradeDecision =
31
31
  * gets decided by the mesh handshake, so until that finishes it may do
32
32
  * only the one thing that can decide it. */
33
33
  readonly mesh?: boolean;
34
+ /** Who the access token on the handshake admitted, and until when
35
+ * (DR-0001 §2.5). The connection lives to that instant unless it is
36
+ * extended on itself. */
37
+ readonly auth?: AuthorizedUpgrade;
34
38
  }
35
39
  | { readonly ok: false; readonly reason: string };
36
40
 
41
+ /** What a person's connection was let in as. */
42
+ export interface AuthorizedUpgrade {
43
+ readonly sub: string;
44
+ readonly expiresAt: number;
45
+ }
46
+
37
47
  /** Accepts everything. A listener given no policy is open to whatever can reach
38
48
  * the address it is bound to. */
39
49
  export const OPEN: EntryPolicy = {};
@@ -2,12 +2,14 @@ import { MAX_FRAME_BYTES } from "@ccmsg/protocol";
2
2
  import { BaseConn, type Conn, type ConnRegistry } from "./conn.ts";
3
3
  import { createDriver, type FrameHandler } from "./driver.ts";
4
4
  import { LineReader, WriteQueue } from "./framing.ts";
5
- import { type EntryPolicy, OPEN } from "./entry.ts";
5
+ import { type AuthorizedUpgrade, type EntryPolicy, OPEN } from "./entry.ts";
6
6
  import type { Listener } from "./listener.ts";
7
7
 
8
8
  /** What the upgrade hands the socket: whether it was let in as a peer. */
9
9
  interface UpgradeData {
10
10
  readonly mesh: boolean;
11
+ /** Who the handshake's access token admitted, on a person's connection. */
12
+ readonly auth?: AuthorizedUpgrade;
11
13
  }
12
14
 
13
15
  interface WsState {
@@ -27,7 +29,10 @@ export interface WsOptions {
27
29
  /** `mesh` is set when the handshake was let in as a peer rather than on the
28
30
  * entry token, so whoever holds the connection can keep it to the one
29
31
  * exchange that can prove what it is. */
30
- readonly onConn?: (conn: Conn, info: { readonly mesh: boolean }) => void;
32
+ readonly onConn?: (
33
+ conn: Conn,
34
+ info: { readonly mesh: boolean; readonly auth?: AuthorizedUpgrade },
35
+ ) => void;
31
36
  /** An HTTP request that is not the upgrade, answered by whoever wants it.
32
37
  *
33
38
  * It shares this listener rather than opening a second one: a producer that
@@ -46,7 +51,7 @@ export interface WsOptions {
46
51
  * is either buffered whole by Bun or dropped whole — and the queue absorbs
47
52
  * that difference here (§3.1). */
48
53
  export function serveWs(options: WsOptions): Listener {
49
- const path = options.path ?? "/ws";
54
+ const path = options.path ?? ENTRY_PATH;
50
55
  const entry = options.entry ?? OPEN;
51
56
  // The per-connection state is made in `open`, where the socket to write to
52
57
  // exists, so the upgrade carries nothing and the socket keeps no data of its
@@ -71,7 +76,10 @@ export function serveWs(options: WsOptions): Listener {
71
76
  const selected = decision.protocol;
72
77
  if (
73
78
  srv.upgrade(request, {
74
- data: { mesh: decision.mesh === true },
79
+ data: {
80
+ mesh: decision.mesh === true,
81
+ ...(decision.auth === undefined ? {} : { auth: decision.auth }),
82
+ },
75
83
  ...(selected === undefined
76
84
  ? {}
77
85
  : { headers: { "sec-websocket-protocol": selected } satisfies Record<string, string> }),
@@ -108,7 +116,10 @@ export function serveWs(options: WsOptions): Listener {
108
116
  states.set(ws, { conn, queue, reader: new LineReader(driver) });
109
117
  options.conns.add(conn);
110
118
  const data = ws.data as UpgradeData | undefined;
111
- options.onConn?.(conn, { mesh: data?.mesh === true });
119
+ options.onConn?.(conn, {
120
+ mesh: data?.mesh === true,
121
+ ...(data?.auth === undefined ? {} : { auth: data.auth }),
122
+ });
112
123
  },
113
124
  message(ws, message) {
114
125
  const state = states.get(ws);
@@ -147,6 +158,12 @@ export function serveWs(options: WsOptions): Listener {
147
158
  };
148
159
  }
149
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
+
150
167
  /** Whether a request's path is this listener's entry.
151
168
  *
152
169
  * Matched at the end rather than whole, so a proxy that puts the instance under