@ccmsg/cli 0.1.0 → 0.2.0
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 +2 -2
- package/src/auth/admin.ts +64 -0
- package/src/auth/auth.ts +985 -0
- package/src/auth/cbor.ts +150 -0
- package/src/auth/http.ts +280 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/records.ts +394 -0
- package/src/auth/topic.ts +30 -0
- package/src/auth/webauthn.ts +389 -0
- package/src/cli.ts +91 -0
- package/src/dispatch/dispatch.ts +9 -0
- package/src/instance/instance.ts +128 -14
- package/src/mesh/mesh.ts +53 -4
- package/src/mesh/relay.ts +7 -0
- package/src/sessions/registry.ts +18 -16
- package/src/transport/entry.ts +10 -0
- package/src/transport/ws.ts +15 -4
package/src/instance/instance.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
851
|
-
* (DR-0001):
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
function 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
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/sessions/registry.ts
CHANGED
|
@@ -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
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
instances:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
?
|
|
258
|
-
:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
package/src/transport/entry.ts
CHANGED
|
@@ -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 = {};
|
package/src/transport/ws.ts
CHANGED
|
@@ -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?: (
|
|
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
|
|
@@ -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: {
|
|
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, {
|
|
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);
|