viber-channel 0.8.7 → 0.8.9

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/lib/auth.ts CHANGED
@@ -41,6 +41,11 @@ export interface AuthJson {
41
41
  * pull dev behavior into staging. Derived from `VIBER_BASE_URL`, which the
42
42
  * launcher always sets per env.
43
43
  */
44
+ // #480: this raw `VIBER_BASE_URL` read is DELIBERATE — do not "finish the
45
+ // refactor" by swapping in identityBaseUrl(). This is env CLASSIFICATION, and the
46
+ // `""` default is meaningful: it distinguishes "unset" (⇒ not dev) from a real
47
+ // value, whereas identityBaseUrl() substitutes the production host. It must also
48
+ // never see the API transport host, which carries no "viber-dev" marker.
44
49
  export function isDevBackend(
45
50
  baseUrl: string = process.env.VIBER_BASE_URL ?? "",
46
51
  ): boolean {
@@ -0,0 +1,250 @@
1
+ /**
2
+ * base_urls.ts — the TWO base URLs a Viber client deals with (#480).
3
+ *
4
+ * Before #480 there was one value, `VIBER_BASE_URL`, playing two incompatible
5
+ * roles: a network address AND an identity key. #480 points agent REST traffic
6
+ * at a different host (`viber-api-staging.dgypx.dev`) so it stops going through
7
+ * the billed Pages Function — which is only safe once the two roles are split.
8
+ *
9
+ * identityBaseUrl — the LOGICAL backend identity. Feeds the session-file and
10
+ * lock-file hashes (#269), user-facing messages, and the
11
+ * env classification. Must stay stable across the switch.
12
+ *
13
+ * apiBaseUrl — the host actually contacted over HTTP. May differ from
14
+ * the identity, and may CHANGE at runtime once the server
15
+ * announces `api_base_url`.
16
+ * NOT always normalized: an explicit `VIBER_API_BASE_URL`
17
+ * is, but the FALLBACK returns the identity **verbatim**
18
+ * (normalizing it would break a self-host served under a
19
+ * path prefix, and would make this module's introduction a
20
+ * behaviour change instead of a refactor). So callers must
21
+ * never assume a canonical form — compare ORIGINS, not
22
+ * strings.
23
+ *
24
+ * ⚠️ NEVER normalize `identityBaseUrl`.
25
+ * The session/lock hashes are taken over the RAW string (see `lockFilePath`,
26
+ * `sessionFilePath`), not over a parsed origin. Trimming a trailing slash or
27
+ * lower-casing the host would move EVERY hash: live agents would lose their
28
+ * conversation handles (re-minting a fresh conversation) and release their
29
+ * locks (re-opening the #311/#293 duplicate window). Normalization belongs to
30
+ * `apiBaseUrl` alone — hence the two distinct functions below, deliberately not
31
+ * factored into one.
32
+ */
33
+
34
+ /** The canonical public backend, used when nothing is configured. */
35
+ export const DEFAULT_BASE_URL = "https://viber.dgypx.dev";
36
+
37
+ /**
38
+ * The logical backend identity — returned VERBATIM, never normalized.
39
+ *
40
+ * This is byte-for-byte what the code used before #480
41
+ * (`process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev"`), so every existing
42
+ * session-file and lock-file path keeps hashing to the same value.
43
+ */
44
+ export function identityBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
45
+ return env.VIBER_BASE_URL ?? DEFAULT_BASE_URL;
46
+ }
47
+
48
+ /**
49
+ * Normalize a host meant for HTTP transport: origin only, no trailing slash.
50
+ *
51
+ * Applied to `apiBaseUrl` ONLY. Throws on anything that isn't a plain absolute
52
+ * origin, so a malformed override fails loudly at startup instead of producing
53
+ * `fetch()` calls against a nonsense address.
54
+ *
55
+ * This checks the **form**, and one safety property: `http:` is allowed only on
56
+ * localhost, mirroring `isTrustedViberOrigin`. Plain-text to an arbitrary host
57
+ * would expose the bearer tokens the client sends. WHICH hosts are trusted is a
58
+ * separate decision made by `isTrustedViberOrigin` (step-04) — passing this
59
+ * function is necessary, never sufficient.
60
+ */
61
+ export function normalizeApiBaseUrl(raw: string): string {
62
+ let u: URL;
63
+ try {
64
+ u = new URL(raw);
65
+ } catch {
66
+ throw new Error(`Invalid API base URL (not an absolute URL): ${raw}`);
67
+ }
68
+ const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
69
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocalhost)) {
70
+ throw new Error(
71
+ `Invalid API base URL (must be https, or http on localhost): ${raw}`,
72
+ );
73
+ }
74
+ if (u.username || u.password) {
75
+ throw new Error(`Invalid API base URL (must not carry credentials): ${raw}`);
76
+ }
77
+ if (u.search || u.hash) {
78
+ throw new Error(`Invalid API base URL (must not carry query or fragment): ${raw}`);
79
+ }
80
+ if (u.pathname !== "/" && u.pathname !== "") {
81
+ throw new Error(`Invalid API base URL (must be an origin, no path): ${raw}`);
82
+ }
83
+ return u.origin;
84
+ }
85
+
86
+ /**
87
+ * The host to contact over HTTP, at BOOTSTRAP.
88
+ *
89
+ * Precedence: `VIBER_API_BASE_URL` (dev / self-host escape hatch) > the identity.
90
+ * The server-announced value is NOT here — it cannot be: it only arrives with the
91
+ * first authenticated response. See `ApiBaseUrlResolver`.
92
+ */
93
+ export function apiBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
94
+ const override = env.VIBER_API_BASE_URL;
95
+ if (override) return normalizeApiBaseUrl(override);
96
+ return identityBaseUrl(env);
97
+ }
98
+
99
+ /** Outcome of offering an announced host to the resolver — for logs and tests. */
100
+ export type AdoptionOutcome =
101
+ | { adopted: true; value: string }
102
+ | { adopted: false; reason: string };
103
+
104
+ /**
105
+ * The API host as a piece of STATE, not a constant (#480 step-04).
106
+ *
107
+ * `api_base_url` is announced by the server, so it is unknown until the first
108
+ * authenticated response (mint / join / resume) comes back. The first call
109
+ * therefore ALWAYS goes to the bootstrap host; adoption happens afterwards.
110
+ * Modelling this as a resolver object rather than a mutable module global keeps
111
+ * the ownership explicit and makes it testable without touching `process.env`.
112
+ *
113
+ * Security rules, deliberately enforced HERE rather than at each call site —
114
+ * the client sends `project_token`, `instance_token`, `conversation_token` and
115
+ * (for runners) `runner_token` to whatever host this returns:
116
+ *
117
+ * 1. An announced host must pass `isTrustedViberOrigin` BEFORE adoption. That
118
+ * allowlist is STATIC — hard-coded hosts plus the LOCAL env overrides. It is
119
+ * never fed the announced value: doing so would be circular and the
120
+ * allowlist would protect nothing.
121
+ * 2. A rejected announcement is IGNORED with a warning — never a hard failure.
122
+ * The bootstrap host works, so refusing to start would turn a server-side
123
+ * misconfiguration into a dead agent (mirror of the server's own choice in
124
+ * step-03).
125
+ * 3. An explicit `VIBER_API_BASE_URL` WINS over any announcement: an operator
126
+ * debugging against a specific host must not be silently redirected.
127
+ */
128
+ export class ApiBaseUrlResolver {
129
+ private readonly bootstrap: string;
130
+ private readonly pinnedByEnv: boolean;
131
+ private adopted: string | null = null;
132
+
133
+ constructor(
134
+ private readonly isTrusted: (origin: string) => boolean,
135
+ env: NodeJS.ProcessEnv = process.env,
136
+ private readonly warn: (msg: string) => void = (msg) => process.stderr.write(msg),
137
+ ) {
138
+ this.bootstrap = apiBaseUrl(env);
139
+ this.pinnedByEnv = (env.VIBER_API_BASE_URL ?? "") !== "";
140
+ }
141
+
142
+ /** The host to use right now. */
143
+ current(): string {
144
+ return this.adopted ?? this.bootstrap;
145
+ }
146
+
147
+ /**
148
+ * True once the agent has actually MOVED off its bootstrap host.
149
+ *
150
+ * Deliberately not "an announcement was accepted": an announcement equal to the
151
+ * bootstrap host leaves this false, so the flag can be used as-is to measure
152
+ * real adoption across the fleet (step-07).
153
+ */
154
+ hasAdopted(): boolean {
155
+ return this.adopted !== null;
156
+ }
157
+
158
+ /**
159
+ * Offer a server-announced host. Safe to call repeatedly — every response
160
+ * carries the field, so this runs on a hot-ish path and must be idempotent.
161
+ */
162
+ offer(announced: string | undefined | null): AdoptionOutcome {
163
+ if (announced === undefined || announced === null || announced.trim() === "") {
164
+ return { adopted: false, reason: "not announced" };
165
+ }
166
+ if (this.pinnedByEnv) {
167
+ return { adopted: false, reason: "pinned by VIBER_API_BASE_URL" };
168
+ }
169
+
170
+ let normalized: string;
171
+ try {
172
+ normalized = normalizeApiBaseUrl(announced);
173
+ } catch (err) {
174
+ return this.refuse(announced, err instanceof Error ? err.message : String(err));
175
+ }
176
+
177
+ // Rule 1. Note this is the ONLY gate that decides trust; the form check above
178
+ // is necessary but never sufficient.
179
+ if (!this.isTrusted(normalized)) {
180
+ return this.refuse(announced, "origin is not in the static allowlist");
181
+ }
182
+
183
+ // An announcement EQUAL to the host we already use is a no-op, and must not
184
+ // count as an adoption: step-07 measures the share of the fleet that actually
185
+ // moved off the web host, and a dev server announcing its own bootstrap host
186
+ // would otherwise inflate that number with agents that never moved.
187
+ //
188
+ // Compare ORIGINS, not strings: the bootstrap value may be un-normalized (see
189
+ // the module header), so `https://h` vs `https://h/` is the SAME host and a
190
+ // string comparison would report a spurious move.
191
+ if (sameOrigin(normalized, this.bootstrap)) {
192
+ return { adopted: false, reason: "same origin as the bootstrap host" };
193
+ }
194
+ // Already on this host: the state is right either way, but re-reporting an
195
+ // adoption would inflate any COUNT of adoptions — and step-07 counts.
196
+ if (this.adopted !== null && sameOrigin(normalized, this.adopted)) {
197
+ return { adopted: false, reason: "already adopted" };
198
+ }
199
+
200
+ this.adopted = normalized;
201
+ // Logged, not silent: step-06's single-agent test needs an OBSERVABLE proof
202
+ // that this agent moved. Without this line the discriminant was written but
203
+ // not executable — `hasAdopted()` existed only in memory and in tests
204
+ // (found in review). One line per agent per run, at adoption only.
205
+ this.warn(
206
+ `[viber-channel] api host adopted: ${normalized} (was ${this.bootstrap}) — #480
207
+ `,
208
+ );
209
+ return { adopted: true, value: normalized };
210
+ }
211
+
212
+ private refuse(announced: string, reason: string): AdoptionOutcome {
213
+ // Rule 2: warn, keep working. Logged once per offer — callers only offer on
214
+ // a mint/join, not per request.
215
+ this.warn(
216
+ `[viber-channel] ignoring announced api_base_url (${reason}): ${announced} — staying on ${this.current()}\n`,
217
+ );
218
+ return { adopted: false, reason };
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Make a refused announcement safe to log — mirror of the server's `redactForLog`.
224
+ *
225
+ * Keeps the host (so the operator can recognize the offending value) and drops
226
+ * userinfo. An unparseable value is never echoed: it is not a URL, so it could be
227
+ * anything, and a length-based cut would still print a short secret in full.
228
+ */
229
+ export function redactAnnounced(raw: string): string {
230
+ try {
231
+ const u = new URL(raw);
232
+ if (u.username || u.password) {
233
+ u.username = "";
234
+ u.password = "";
235
+ return `${u.toString()} (credentials redacted)`;
236
+ }
237
+ return u.toString();
238
+ } catch {
239
+ return `<unparseable, ${raw.length} chars>`;
240
+ }
241
+ }
242
+
243
+ /** True if both strings denote the same origin. Tolerates un-normalized input. */
244
+ export function sameOrigin(a: string, b: string): boolean {
245
+ try {
246
+ return new URL(a).origin === new URL(b).origin;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }