@ccmsg/cli 0.2.4 → 0.2.7

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.4",
3
+ "version": "0.2.7",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/auth/auth.ts CHANGED
@@ -59,6 +59,19 @@ export const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
59
59
  * short to be a second usable token. */
60
60
  export const PREVIOUS_GRACE_MS = 60_000;
61
61
 
62
+ /** How much of an access token's life must be left for a rotation to keep it.
63
+ *
64
+ * A family has one access token and every page a person has open presents it,
65
+ * so minting a new one on each rotation would take the token out from under the
66
+ * pages that are already holding it. Rotation therefore keeps the standing
67
+ * access token while it has this much life left, and mints only when it is
68
+ * running out — the pages share one token and renew it together.
69
+ *
70
+ * Half, because it is the largest share that still leaves a full half of the
71
+ * token's life to notice the new value in: the window is what bounds how long a
72
+ * page may go on holding a token whose family has already moved on. */
73
+ export const ACCESS_KEEP_MS = ACCESS_TTL_MS / 2;
74
+
62
75
  /** How many times a six-digit code may be got wrong before the registration URL
63
76
  * is spent.
64
77
  *
@@ -706,11 +719,18 @@ export class Auth {
706
719
  return { sub: held.body.sub, access: held.body.access, refresh: held.body.refresh };
707
720
  }
708
721
  const at = this.#now();
722
+ // The refresh token rotates every time; the access token is the family's
723
+ // one token and is shared by every page the person has open, so it is kept
724
+ // until it is close enough to running out to be worth replacing.
725
+ const access =
726
+ held.body.access.expires_at - at >= ACCESS_KEEP_MS
727
+ ? held.body.access
728
+ : { value: token(), expires_at: at + ACCESS_TTL_MS };
709
729
  const rotated: TokenFamily = {
710
730
  kind: "token_family",
711
731
  sub: held.body.sub,
712
732
  iss: this.deps.self,
713
- access: { value: token(), expires_at: at + ACCESS_TTL_MS },
733
+ access,
714
734
  refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
715
735
  previous_refresh: { value: held.body.refresh.value, expires_at: at + PREVIOUS_GRACE_MS },
716
736
  // The value going out of service is remembered as a digest for as long as
package/src/mesh/relay.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
+ type AgentInfo,
2
3
  type InstanceId,
3
4
  LAST_LIVE_RETENTION_MS,
5
+ type LastLiveSession,
4
6
  type PeerInfo,
5
7
  PLAIN_TOPICS,
6
8
  type Sid,
@@ -116,12 +118,16 @@ export class Relay {
116
118
  return values;
117
119
  }
118
120
 
119
- /** Which instance a session belongs to, read from the `peers` values the
120
- * cluster stated (§7.3).
121
+ /** Which instance a session belongs to, read from the cluster values the
122
+ * peers stated (§7.3).
121
123
  *
122
- * The row names its own instance rather than the one that relayed it, so a
123
- * value that travelled through a third instance still points at the session's
124
- * own. */
124
+ * `peers` names every session an instance currently holds connected or in
125
+ * `last_live` and is checked first. A session hello has not reached yet
126
+ * has no row there but the harness may already know of it, so `agents` is
127
+ * checked next; `last_live` is the last resort for one whose instance has
128
+ * not stated `agents` at all. Every row names its own instance rather than
129
+ * the one that relayed it, so a value that travelled through a third
130
+ * instance still points at the session's own. */
125
131
  owner(sid: Sid): InstanceId | undefined {
126
132
  this.#sweep();
127
133
  for (const held of this.#held.values()) {
@@ -129,6 +135,16 @@ export class Relay {
129
135
  const row = value?.peers?.find((peer) => peer.sid === sid);
130
136
  if (row !== undefined) return row.instance;
131
137
  }
138
+ for (const held of this.#held.values()) {
139
+ const value = held.get("agents") as { agents?: AgentInfo[] } | undefined;
140
+ const row = value?.agents?.find((agent) => agent.sid === sid);
141
+ if (row !== undefined) return row.instance;
142
+ }
143
+ for (const held of this.#held.values()) {
144
+ const value = held.get("peers") as { last_live?: LastLiveSession[] } | undefined;
145
+ const row = value?.last_live?.find((session) => session.sid === sid);
146
+ if (row !== undefined) return row.instance;
147
+ }
132
148
  return undefined;
133
149
  }
134
150
 
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import {
4
4
  LAST_LIVE_RETENTION_MS,
5
+ type InstanceId,
5
6
  type LastLiveSession,
6
7
  type Sid,
7
8
  type Timestamp,
@@ -35,7 +36,14 @@ const VERSION = 1;
35
36
  export class LastLiveStore {
36
37
  #entries = new Map<Sid, StoredEntry>();
37
38
 
38
- constructor(private readonly file: string) {}
39
+ /** `id` is this instance's own: every entry this store holds is by
40
+ * definition an observation *this* instance made, so `instance` is forced
41
+ * to it on both ends (load and record) rather than trusted from whatever
42
+ * the field on disk happens to say. */
43
+ constructor(
44
+ private readonly file: string,
45
+ private readonly id: InstanceId,
46
+ ) {}
39
47
 
40
48
  /** Read at startup (§8.3 step 4), before anything can ask for the list. A
41
49
  * file that is missing or unreadable starts an empty list: the daemon has no
@@ -50,7 +58,9 @@ export class LastLiveStore {
50
58
  const sessions = (document as Document | null)?.sessions;
51
59
  if (!Array.isArray(sessions)) return;
52
60
  for (const entry of sessions as StoredEntry[]) {
53
- if (typeof entry?.sid === "string") this.#entries.set(entry.sid, entry);
61
+ if (typeof entry?.sid === "string") {
62
+ this.#entries.set(entry.sid, { ...entry, instance: this.id });
63
+ }
54
64
  }
55
65
  this.#prune(now);
56
66
  }
@@ -71,7 +81,11 @@ export class LastLiveStore {
71
81
  * Paused rather than Disappeared (§5.2). */
72
82
  record(entry: StoredEntry): void {
73
83
  const stopped = this.#entries.get(entry.sid)?.stopped_at ?? entry.stopped_at;
74
- this.#entries.set(entry.sid, stopped === undefined ? entry : { ...entry, stopped_at: stopped });
84
+ this.#entries.set(entry.sid, {
85
+ ...entry,
86
+ instance: this.id,
87
+ ...(stopped === undefined ? {} : { stopped_at: stopped }),
88
+ });
75
89
  this.#save();
76
90
  }
77
91
 
@@ -190,7 +190,7 @@ export class Sessions implements UpstreamResource {
190
190
  () => this.changed(),
191
191
  deps.pollMs,
192
192
  );
193
- this.#lastLive = new LastLiveStore(join(deps.stateDir, "last-live.json"));
193
+ this.#lastLive = new LastLiveStore(join(deps.stateDir, "last-live.json"), deps.self);
194
194
  this.#lastLive.load();
195
195
  this.#terminals =
196
196
  deps.terminals === undefined