ytstats 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/CHANGELOG.md CHANGED
@@ -6,7 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.1.0] - 2026-07-27
9
+ ## [0.2.0] - 2026-07-28
10
+
11
+ ### Added
12
+
13
+ - Add `YTSTATS_CREDENTIALS_FILE` — supply the OAuth client as a path to the JSON Google
14
+ issued, rather than as two extracted environment variables. Resolved after the
15
+ `YTSTATS_CLIENT_ID`/`YTSTATS_CLIENT_SECRET` pair and before stored credentials, so
16
+ existing setups are unaffected. Intended for CI, where secrets already arrive as
17
+ mounted files, and for per-directory configuration with direnv.
18
+ - Add `AUTH_CLIENT_MISMATCH`, raised when a channel's stored token was issued by a
19
+ different OAuth client than the one currently resolving. Previously this reached
20
+ Google as `invalid_grant` and surfaced as `AUTH_TOKEN_EXPIRED`, which blames the
21
+ consent screen and sends the caller to fix the wrong thing.
22
+ - Add a `clientId` field to `ytstats status` and to each account it lists, recording
23
+ which OAuth client resolved and which one authorized each channel. A client ID is
24
+ public by OAuth design, so neither is redacted.
25
+ - Add a `clientId` field to `tokens.json` account records, binding each refresh token
26
+ to the client that issued it. Accounts written before this field existed read as
27
+ `null` and are treated as unknown rather than mismatched, so upgrading logs nobody
28
+ out.
29
+ - Add documentation for running several OAuth clients side by side, one
30
+ `YTSTATS_CONFIG_DIR` per client, which moves credentials and tokens together.
31
+
32
+ ## [0.1.0] - 2026-07-28
10
33
 
11
34
  Initial release.
12
35
 
@@ -36,5 +59,6 @@ Initial release.
36
59
  - Client ID pre-flight validation, so a malformed OAuth client fails immediately
37
60
  instead of hanging until the browser callback times out.
38
61
 
39
- [Unreleased]: https://github.com/nicolasdao/ytstats/compare/v0.1.0...HEAD
62
+ [Unreleased]: https://github.com/nicolasdao/ytstats/compare/v0.2.0...HEAD
63
+ [0.2.0]: https://github.com/nicolasdao/ytstats/compare/v0.1.0...v0.2.0
40
64
  [0.1.0]: https://github.com/nicolasdao/ytstats/releases/tag/v0.1.0
package/README.md CHANGED
@@ -106,7 +106,13 @@ Both the OAuth client and your tokens are written to a per-user directory, `0600
106
106
  | Linux | `$XDG_CONFIG_HOME/ytstats/` (default `~/.config/ytstats/`) |
107
107
  | Windows | `%APPDATA%\ytstats\` |
108
108
 
109
- Override with `YTSTATS_CONFIG_DIR`. For CI, set `YTSTATS_CLIENT_ID` and `YTSTATS_CLIENT_SECRET` instead of a file. These are plaintext files, like `gcloud`, `gh`, and `aws` use. `ytstats logout` revokes the token with Google and deletes them.
109
+ Override with `YTSTATS_CONFIG_DIR`. For CI, point `YTSTATS_CREDENTIALS_FILE` at the JSON Google issued, or set `YTSTATS_CLIENT_ID` and `YTSTATS_CLIENT_SECRET` where the secret arrives as two variables. These are plaintext files, like `gcloud`, `gh`, and `aws` use. `ytstats logout` revokes the token with Google and deletes them.
110
+
111
+ One config directory holds one OAuth client and any number of channels. To manage channels that live under *different* Google Cloud projects, give each its own directory — `YTSTATS_CONFIG_DIR` moves credentials and tokens together:
112
+
113
+ ```bash
114
+ alias yt-acme='YTSTATS_CONFIG_DIR=~/.ytstats/acme ytstats'
115
+ ```
110
116
 
111
117
  ## Commands
112
118
 
@@ -227,7 +233,7 @@ src/
227
233
  diagnostics.js the failure catalog
228
234
  errors.js YtStatsError, Google error classification, redaction
229
235
  dates.js reporting window resolution and validation
230
- test/ 316 tests, none requiring network access
236
+ test/ 332 tests, none requiring network access
231
237
  docs/ topic documentation, indexed below
232
238
  ```
233
239
 
@@ -31,7 +31,7 @@ bin/ytstats.js thin shim; last-resort guard against stdout pollution
31
31
 
32
32
  ## Design principles
33
33
 
34
- **Everything I/O is injected.** API clients, the OAuth2 constructor, the loopback server, the browser opener, the identity lookup, the output sinks, and even `now()` are parameters with real defaults. This is why 316 tests run without a network connection and without opening a browser.
34
+ **Everything I/O is injected.** API clients, the OAuth2 constructor, the loopback server, the browser opener, the identity lookup, the output sinks, and even `now()` are parameters with real defaults. This is why 332 tests run without a network connection and without opening a browser.
35
35
 
36
36
  **Pure logic is separated from effects.** `api/transforms.js`, `dates.js`, `config/paths.js` and `diagnostics.js` are pure and directly tested. Everything awkward to test is pushed to the edges.
37
37
 
@@ -49,7 +49,7 @@ bin/ytstats.js thin shim; last-resort guard against stdout pollution
49
49
  | `config/store.js` | Atomic `0600` JSON read/write, traversal-safe filenames. |
50
50
  | `auth/credentials.js` | BYO credential resolution and validation. Rejects service accounts and malformed client IDs before they cost a browser round trip. |
51
51
  | `auth/oauth.js` | PKCE pair, CSRF state, loopback callback server, auth URL builder, scope list. |
52
- | `auth/tokens.js` | Multi-account token store keyed by channel; legacy import. |
52
+ | `auth/tokens.js` | Multi-account token store keyed by channel; records which OAuth client issued each account's token; legacy import. |
53
53
  | `auth/session.js` | Ties the above together: `login`, `logout`, `getAuthenticatedClient` with refresh persistence. |
54
54
  | `api/client.js` | Builds the three API surfaces plus an authenticated CSV downloader; wraps calls in error mapping. |
55
55
  | `api/transforms.js` | Pure shaping: duration parsing, content classification, CSV, date normalization, row zipping. |
package/docs/auth.md CHANGED
@@ -35,18 +35,21 @@ There is no code path that writes to a channel.
35
35
 
36
36
  ## Credential resolution
37
37
 
38
- `resolveCredentials()` returns `{ clientId, clientSecret, source }`, checking four sources in order and taking the first that yields a complete pair:
38
+ `resolveCredentials()` returns `{ clientId, clientSecret, source }`, checking five sources in order and taking the first that yields a complete pair:
39
39
 
40
40
  | Order | Source | `source` value |
41
41
  |---|---|---|
42
42
  | 1 | `--client-secret <file>` | the path given |
43
43
  | 2 | `YTSTATS_CLIENT_ID` **and** `YTSTATS_CLIENT_SECRET` (both required) | `environment` |
44
- | 3 | `credentials.json` saved by a previous `ytstats login` | `stored` |
45
- | 4 | `client_secret*.json` auto-discovered in the working directory | the discovered path |
44
+ | 3 | `YTSTATS_CREDENTIALS_FILE` a path to the JSON Google issued | the path given |
45
+ | 4 | `credentials.json` saved by a previous `ytstats login` | `stored` |
46
+ | 5 | `client_secret*.json` auto-discovered in the working directory | the discovered path |
47
+
48
+ The env pair is checked before the env path deliberately: adding the path form must not change resolution for anyone already exporting the pair.
46
49
 
47
50
  If none match, it throws `AUTH_NO_CREDENTIALS` with a `detail` naming everything it searched.
48
51
 
49
- `source` is for display only and never contains the secret — it is what `ytstats status` reports as `credentialSource` and what `login` prints to stderr.
52
+ `source` is for display only and never contains the secret — it is what `ytstats status` reports as `credentialSource` and what `login` prints to stderr. `status` also reports the resolved `clientId`, which `source` alone cannot tell you once five sources can supply one; a client ID is public by OAuth design, so printing it is safe.
50
53
 
51
54
  ### File shapes accepted
52
55
 
@@ -110,6 +113,7 @@ Tokens live in `tokens.json` inside the per-user config directory (see [configur
110
113
  "channelId": "UC...",
111
114
  "channelTitle": "…",
112
115
  "customUrl": "@…",
116
+ "clientId": "123456789012-abc.apps.googleusercontent.com",
113
117
  "tokens": { "access_token": "…", "refresh_token": "…", "expiry_date": 0 },
114
118
  "savedAt": "2026-07-27T10:00:00.000Z"
115
119
  }
@@ -119,7 +123,17 @@ Tokens live in `tokens.json` inside the per-user config directory (see [configur
119
123
 
120
124
  Keyed by channel, so one machine can hold several. The first account logged in wins `default`; `ytstats use` changes it.
121
125
 
122
- **Saving merges rather than replaces.** A refresh response from Google contains a new `access_token` but no `refresh_token`, so `saveAccount()` spreads the existing tokens under the new ones. Overwriting would destroy the long-lived credential.
126
+ **Saving merges rather than replaces.** A refresh response from Google contains a new `access_token` but no `refresh_token`, so `saveAccount()` spreads the existing tokens under the new ones. Overwriting would destroy the long-lived credential. `clientId` falls back the same way, because the refresh write-back path calls `saveAccount()` without one.
127
+
128
+ ### The client binding
129
+
130
+ `clientId` records which OAuth client issued this account's refresh token. Google binds a refresh token to the client that issued it, so the pairing matters — but `credentials.json` holds one client for the whole config directory while `tokens.json` holds many channels, and the two can drift apart when a second `login` with a different `client_secret` overwrites the stored client.
131
+
132
+ `getAuthenticatedClient()` compares the two after loading the account and throws `AUTH_CLIENT_MISMATCH` when they disagree. Without that check the run proceeds to a refresh Google rejects as `invalid_grant`, which maps to `AUTH_TOKEN_EXPIRED` and blames the consent screen — an accurate-sounding diagnosis of the wrong problem.
133
+
134
+ The check is skipped when `account.clientId` is absent, so accounts stored before the field existed keep working: an unknown binding is not a mismatch. The field is not a secret and is never used to authenticate; `listAccounts()` includes it, and `ytstats status` prints it per account.
135
+
136
+ To hold several clients on one machine, give each its own `YTSTATS_CONFIG_DIR` — see [configuration.md](configuration.md#running-several-oauth-clients-side-by-side).
123
137
 
124
138
  ### Refresh persistence
125
139
 
@@ -156,6 +170,7 @@ An unrecognised selector returns `null` — never a silent fallback to the defau
156
170
  | No OAuth client anywhere | `AUTH_NO_CREDENTIALS` |
157
171
  | Client exists, never logged in | `AUTH_NO_TOKENS` |
158
172
  | `--account` names an unknown channel | `AUTH_ACCOUNT_UNKNOWN` |
173
+ | Stored token was issued by a different OAuth client | `AUTH_CLIENT_MISMATCH` |
159
174
  | Refresh token rejected (`invalid_grant`) | `AUTH_TOKEN_EXPIRED` |
160
175
  | Access explicitly revoked | `AUTH_TOKEN_REVOKED` |
161
176
  | Consent screen dismissed | `AUTH_CONSENT_DECLINED` |
package/docs/cli.md CHANGED
@@ -76,7 +76,7 @@ Runs the loopback OAuth flow: opens the browser, captures the callback on `127.0
76
76
  | `--no-browser` | off | Print the URL and read the pasted redirect back — for SSH and headless machines |
77
77
  | `--timeout <seconds>` | `300` | How long to wait for the browser callback, so an automated caller is never blocked indefinitely |
78
78
 
79
- Credentials are resolved in this order: `--client-secret` → `YTSTATS_CLIENT_ID`/`YTSTATS_CLIENT_SECRET` → stored `credentials.json` → `client_secret*.json` in the working directory. See [configuration.md](configuration.md).
79
+ Credentials are resolved in this order: `--client-secret` → `YTSTATS_CLIENT_ID`/`YTSTATS_CLIENT_SECRET` → `YTSTATS_CREDENTIALS_FILE` → stored `credentials.json` → `client_secret*.json` in the working directory. See [configuration.md](configuration.md).
80
80
 
81
81
  Returns `{ channelId, channelTitle, customUrl, configDir }`.
82
82
 
@@ -106,9 +106,18 @@ Local removal happens even when revocation fails — being offline should not le
106
106
  ytstats status
107
107
  ```
108
108
 
109
- Reports who is signed in and where configuration lives. Takes no flags and needs no authentication.
109
+ Reports who is signed in and where configuration lives. Takes no flags and needs no authentication. This is the "who am I" command.
110
110
 
111
- Returns `{ authenticated, configDir, credentialSource, accounts[], setupGuide? }`. Each account carries `channelId`, `channelTitle`, `customUrl`, `savedAt`, and `isDefault` — never token material. `setupGuide` appears only when no account is signed in.
111
+ Returns `{ authenticated, configDir, credentialSource, clientId, accounts[], setupGuide? }`. Each account carries `channelId`, `channelTitle`, `customUrl`, `clientId`, `savedAt`, and `isDefault` — never token material. `setupGuide` appears only when no account is signed in.
112
+
113
+ `credentialSource` says *where* the OAuth client came from (a path, `environment`, or `stored`); `clientId` says *which* client that turned out to be. Both are `null` when no credentials resolve. Client IDs are public by OAuth design — only the secret is sensitive — so neither field is redacted.
114
+
115
+ Comparing the top-level `clientId` against an account's `clientId` tells you whether that channel's stored token was issued by the client currently resolving. A disagreement is what [`AUTH_CLIENT_MISMATCH`](output-contract.md#diagnostic-catalog) reports on the next authenticated command.
116
+
117
+ ```bash
118
+ ytstats status 2>/dev/null | jq '{clientId, credentialSource}'
119
+ ytstats status 2>/dev/null | jq -r '.data.accounts[] | select(.isDefault) | .channelTitle'
120
+ ```
112
121
 
113
122
  ### doctor
114
123
 
@@ -17,6 +17,7 @@ source:
17
17
  | `YTSTATS_CONFIG_DIR` | `resolveConfigDir()` | Overrides the config directory entirely. Relative values are resolved to absolute against the working directory |
18
18
  | `YTSTATS_CLIENT_ID` | `resolveCredentials()` | OAuth client id. **Both** this and the secret must be set for the pair to be used |
19
19
  | `YTSTATS_CLIENT_SECRET` | `resolveCredentials()` | OAuth client secret |
20
+ | `YTSTATS_CREDENTIALS_FILE` | `resolveCredentials()` | Path to the `client_secret` JSON Google issued. Same effect as `--client-secret`, without repeating the flag |
20
21
  | `XDG_CONFIG_HOME` | `resolveConfigDir()` | Linux/BSD config base. **Ignored when relative**, per the XDG spec |
21
22
  | `APPDATA` | `resolveConfigDir()` | Windows config base; falls back to `%USERPROFILE%\AppData\Roaming` |
22
23
  | `HTTPS_PROXY` | `googleapis` / Node | Standard proxy variable, named in the `NETWORK_UNREACHABLE` remediation |
@@ -70,6 +71,7 @@ The multi-account token store, keyed by channel id:
70
71
  "channelId": "UC…",
71
72
  "channelTitle": "…",
72
73
  "customUrl": "@…",
74
+ "clientId": "123456789012-abc.apps.googleusercontent.com",
73
75
  "tokens": { "access_token": "…", "refresh_token": "…", "expiry_date": 0 },
74
76
  "savedAt": "2026-07-27T10:00:00.000Z"
75
77
  }
@@ -77,6 +79,8 @@ The multi-account token store, keyed by channel id:
77
79
  }
78
80
  ```
79
81
 
82
+ `clientId` records which OAuth client issued this account's refresh token. It is not a secret and is never used to authenticate — it exists so a later run can tell whether the credentials it resolved are the ones the token belongs to. Accounts written before the field existed have `null` and are treated as unknown, not as a mismatch.
83
+
80
84
  Deleted entirely when the last account is removed. See [auth.md](auth.md#token-storage) for how it is read and merged.
81
85
 
82
86
  ## Permissions and write discipline
@@ -94,16 +98,24 @@ These are plaintext files, the same approach `gcloud`, `gh`, and `aws` take. `yt
94
98
 
95
99
  ## CI and headless machines
96
100
 
97
- Supply the OAuth client through the environment instead of a file:
101
+ Point at a mounted secret file:
98
102
 
99
103
  ```bash
100
- export YTSTATS_CLIENT_ID=123456789012-abc.apps.googleusercontent.com
101
- export YTSTATS_CLIENT_SECRET=GOCSPX-…
104
+ export YTSTATS_CREDENTIALS_FILE=/run/secrets/ytstats-client.json
102
105
  export YTSTATS_CONFIG_DIR=$PWD/.ytstats # if $HOME is not writable
103
106
  ytstats login --no-browser
104
107
  ```
105
108
 
106
- Both `YTSTATS_CLIENT_ID` and `YTSTATS_CLIENT_SECRET` must be set one alone is ignored and resolution falls through to the next source.
109
+ This is usually the cleanest option in CI, where the runner already mounts secrets as files: it takes the JSON Google issued as-is, with no step that extracts two fields out of it, and a path in the environment leaks less than a secret in the environment.
110
+
111
+ The pair still works where the secret arrives as two variables:
112
+
113
+ ```bash
114
+ export YTSTATS_CLIENT_ID=123456789012-abc.apps.googleusercontent.com
115
+ export YTSTATS_CLIENT_SECRET=GOCSPX-…
116
+ ```
117
+
118
+ Both must be set — one alone is ignored and resolution falls through to the next source.
107
119
 
108
120
  `--no-browser` prints the authorization URL and reads the pasted redirect back, so a machine with no browser can still complete the flow. The refresh token it produces is then reusable, provided the consent screen is published to Production — in Testing mode Google expires it after 7 days.
109
121
 
@@ -115,11 +127,35 @@ Ordered in `resolveCredentials()`; the first complete pair wins:
115
127
 
116
128
  1. `--client-secret <file>`
117
129
  2. `YTSTATS_CLIENT_ID` + `YTSTATS_CLIENT_SECRET` (both required)
118
- 3. Stored `credentials.json`
119
- 4. `client_secret*.json` auto-discovered in the working directory
130
+ 3. `YTSTATS_CREDENTIALS_FILE`
131
+ 4. Stored `credentials.json`
132
+ 5. `client_secret*.json` auto-discovered in the working directory
133
+
134
+ The env pair is checked before the env path so that the path form changes nothing for anyone already exporting the pair. If both are set, the pair wins and `ytstats status` reports `credentialSource: environment` — which is how you catch a stale `YTSTATS_CLIENT_ID` shadowing the file you meant to use.
120
135
 
121
136
  Auto-discovery prefers an exact `client_secret.json`, otherwise takes the alphabetically first match so the same directory always resolves the same way. Full detail in [auth.md](auth.md#credential-resolution).
122
137
 
138
+ ## Running several OAuth clients side by side
139
+
140
+ One config directory holds **one** OAuth client — `credentials.json` is a single record, not one per channel. `tokens.json` holds many channels, so the two only stay consistent if every channel in a directory was authorized with the same client.
141
+
142
+ That is fine for the common case (one Google Cloud project, several of your own channels). It breaks when channels live under *different* projects — managing a client's channel with a client-issued OAuth client, for instance. Give each client its own directory:
143
+
144
+ ```bash
145
+ alias yt-acme='YTSTATS_CONFIG_DIR=~/.ytstats/acme YTSTATS_CREDENTIALS_FILE=~/secrets/acme.json ytstats'
146
+ alias yt-mine='YTSTATS_CONFIG_DIR=~/.ytstats/mine ytstats'
147
+ ```
148
+
149
+ `YTSTATS_CONFIG_DIR` is what does the real work: it moves the credentials *and* the tokens together, so the two halves cannot drift apart. Setting only `YTSTATS_CREDENTIALS_FILE` while leaving the config directory shared pairs one client's id with another's tokens, which is exactly the state [`AUTH_CLIENT_MISMATCH`](output-contract.md#diagnostic-catalog) exists to catch.
150
+
151
+ Per-directory rather than per-shell, with [direnv](https://direnv.net/):
152
+
153
+ ```bash
154
+ # .envrc
155
+ export YTSTATS_CONFIG_DIR="$PWD/.ytstats"
156
+ export YTSTATS_CREDENTIALS_FILE="$HOME/secrets/acme.json"
157
+ ```
158
+
123
159
  ## Not committing credentials
124
160
 
125
161
  The repository's `.gitignore` excludes `client_secret*.json`, `credentials.json`, `tokens.json`, and `.env` — because a downloaded client secret can easily land in the working directory during testing, and auto-discovery means it will be picked up from there.
@@ -82,6 +82,30 @@ The caller turns that `null` into `AUTH_ACCOUNT_UNKNOWN`, listing the channels t
82
82
 
83
83
  **Where handled:** `loadAccount()` in `src/auth/tokens.js`; the `AUTH_ACCOUNT_UNKNOWN` branch in `getAuthenticatedClient()`.
84
84
 
85
+ ## One config directory holds one OAuth client, but many channels
86
+
87
+ `tokens.json` is keyed by channel and holds as many as you log into. `credentials.json` is a **single** record for the whole directory. The two structures disagree, and a second `ytstats login` with a different `client_secret` file silently overwrites the stored client while the first channel's tokens stay put.
88
+
89
+ Google binds a refresh token to the client that issued it, so the next command for that first channel refreshes with the wrong client and Google answers `invalid_grant`. That maps to `AUTH_TOKEN_EXPIRED`, whose cause names a Testing-mode consent screen — so the user publishes their consent screen, which changes nothing, and is now further from the answer than when they started. A misdiagnosis is more expensive than an error.
90
+
91
+ Only reachable with two Google Cloud projects; the ordinary case of several channels under one project is consistent. The fix for the multi-project case is a config directory per client, since `YTSTATS_CONFIG_DIR` moves credentials and tokens together.
92
+
93
+ **Where handled:** `saveAccount()` in `src/auth/tokens.js` records `clientId` at login; the comparison in `getAuthenticatedClient()` (`src/auth/session.js`) throws `AUTH_CLIENT_MISMATCH` before the refresh can happen.
94
+
95
+ ## An absent client binding is unknown, not a mismatch
96
+
97
+ The mismatch check runs only when `account.clientId` is set. Accounts written before that field existed have none, and treating absent as "does not match" would reject every pre-existing account on upgrade — logging out every user to protect against a problem most of them do not have.
98
+
99
+ The same reasoning applies to the refresh write-back path: `client.on('tokens', …)` calls `saveAccount()` with a partial payload and no `clientId`, so `saveAccount()` falls back to the existing value rather than nulling it. Dropping it there would disarm the check after the first refresh, which is precisely when it is still needed.
100
+
101
+ **Where handled:** the `account.clientId &&` guard in `getAuthenticatedClient()`; the `clientId ?? existing?.clientId ?? null` fallback in `saveAccount()`.
102
+
103
+ ## The account selector is checked before the client binding
104
+
105
+ `getAuthenticatedClient()` resolves credentials, then the account, then the binding. An unknown `--account` is the more specific complaint, and reporting a client mismatch for a channel that is not signed in at all would misdirect exactly the way the ordering elsewhere in this file exists to prevent.
106
+
107
+ **Where handled:** the order of the `loadAccount()` / `AUTH_ACCOUNT_UNKNOWN` block and the `AUTH_CLIENT_MISMATCH` block in `src/auth/session.js`.
108
+
85
109
  ## The loopback server binds 127.0.0.1, never 0.0.0.0
86
110
 
87
111
  Binding `0.0.0.0` would expose the callback listener — and therefore the authorization code — to anything on the local network. The server binds `127.0.0.1` on an ephemeral port (`server.listen(0, '127.0.0.1')`).
@@ -115,11 +115,12 @@ ytstats fetch 2>/dev/null | jq -r 'if .ok then "fine" else .nextSteps[0] end'
115
115
 
116
116
  | Code | Recoverable | Retryable | Meaning |
117
117
  |---|---|---|---|
118
- | `AUTH_NO_CREDENTIALS` | yes | no | No OAuth client found in any of the four sources |
118
+ | `AUTH_NO_CREDENTIALS` | yes | no | No OAuth client found in any of the five sources |
119
119
  | `AUTH_NO_TOKENS` | yes | no | Client exists, but no channel has been authorized here |
120
120
  | `AUTH_TOKEN_EXPIRED` | yes | no | Refresh token rejected (`invalid_grant`) — usually the 7-day Testing trap |
121
121
  | `AUTH_TOKEN_REVOKED` | yes | no | Access explicitly revoked, by logout elsewhere or in Google account settings |
122
122
  | `AUTH_ACCOUNT_UNKNOWN` | yes | no | `--account` matched no signed-in channel |
123
+ | `AUTH_CLIENT_MISMATCH` | yes | no | The channel's stored token was issued by a different OAuth client than the one resolved |
123
124
  | `AUTH_CONSENT_DECLINED` | yes | **yes** | Google returned `access_denied` — consent dismissed or a scope refused |
124
125
  | `AUTH_TIMEOUT` | yes | no | Callback never arrived; usually "Access blocked" in the browser |
125
126
  | `AUTH_CLIENT_ID_INVALID` | yes | no | Client ID does not end in `.apps.googleusercontent.com` |
package/docs/testing.md CHANGED
@@ -8,7 +8,7 @@ source:
8
8
 
9
9
  # Testing
10
10
 
11
- 316 tests across 13 files. **None of them requires network access**, and none opens a browser.
11
+ 332 tests across 13 files. **None of them requires network access**, and none opens a browser.
12
12
 
13
13
  ## Running
14
14
 
@@ -32,14 +32,14 @@ Nothing is mocked at the module level. Tests hand in plain objects, which means
32
32
 
33
33
  | File | Tests | Covers |
34
34
  |---|---|---|
35
- | `test/envelope.test.js` | 96 | Envelope shape, the diagnostic catalog, severity routing, `nextSteps`, redaction |
35
+ | `test/envelope.test.js` | 98 | Envelope shape, the diagnostic catalog, severity routing, `nextSteps`, redaction |
36
36
  | `test/api/transforms.test.js` | 33 | Duration parsing, content classification, CSV, date normalization, row zipping |
37
37
  | `test/cli.e2e.test.js` | 30 | The real binary, spawned as a subprocess |
38
38
  | `test/api/fetchers.test.js` | 27 | Exact query parameters sent by every Data, Analytics, and Reporting fetcher |
39
- | `test/auth/credentials.test.js` | 20 | Resolution precedence, file shapes, service-account rejection, discovery |
39
+ | `test/auth/credentials.test.js` | 25 | Resolution precedence across all five sources, file shapes, service-account rejection, discovery |
40
+ | `test/auth/tokens.test.js` | 23 | Multi-account store, merging, the client binding, default promotion, legacy import |
41
+ | `test/auth/session.test.js` | 22 | `login`, `logout`, `getAuthenticatedClient`, refresh persistence, client-mismatch detection |
40
42
  | `test/auth/oauth.test.js` | 18 | PKCE, auth URL construction, and the loopback server over real HTTP |
41
- | `test/auth/session.test.js` | 18 | `login`, `logout`, `getAuthenticatedClient`, refresh persistence |
42
- | `test/auth/tokens.test.js` | 18 | Multi-account store, merging, default promotion, legacy import |
43
43
  | `test/config/store.test.js` | 14 | Atomic writes, permissions, traversal rejection |
44
44
  | `test/fetch-all.test.js` | 13 | Orchestration, per-step degradation, fatal codes, retention capping |
45
45
  | `test/dates.test.js` | 11 | Window resolution, calendar validation, bounds |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ytstats",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pull your YouTube channel stats and analytics as JSON. Bring your own Google Cloud credentials, no server, no data leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,12 +32,12 @@ export function parseClientSecret(raw) {
32
32
  return { clientId, clientSecret };
33
33
  }
34
34
 
35
- function readClientSecretFile(file) {
35
+ function readClientSecretFile(file, flag = '--client-secret') {
36
36
  let raw;
37
37
  try {
38
38
  raw = fs.readFileSync(file, 'utf-8');
39
39
  } catch {
40
- throw fail(DIAGNOSTICS.AUTH_CREDENTIALS_NOT_FOUND, { value: file, flag: '--client-secret' });
40
+ throw fail(DIAGNOSTICS.AUTH_CREDENTIALS_NOT_FOUND, { value: file, flag });
41
41
  }
42
42
 
43
43
  let parsed;
@@ -137,8 +137,12 @@ export function clearCredentials() {
137
137
  * Precedence:
138
138
  * 1. --client-secret <file>
139
139
  * 2. YTSTATS_CLIENT_ID + YTSTATS_CLIENT_SECRET (both required)
140
- * 3. credentials.json saved by a previous `ytstats login`
141
- * 4. client_secret*.json auto-discovered in the working directory
140
+ * 3. YTSTATS_CREDENTIALS_FILE a path to the JSON Google issued
141
+ * 4. credentials.json saved by a previous `ytstats login`
142
+ * 5. client_secret*.json auto-discovered in the working directory
143
+ *
144
+ * The env-var pair is checked before the env-var path so that adding the path
145
+ * form changes nothing for anyone already exporting the pair.
142
146
  *
143
147
  * Returns { clientId, clientSecret, source }. `source` is for display only and
144
148
  * never contains the secret.
@@ -156,6 +160,15 @@ export function resolveCredentials({ clientSecretPath, env = process.env, cwd =
156
160
  };
157
161
  }
158
162
 
163
+ // A path in the environment leaks less than a secret in the environment, and
164
+ // it points straight at the file Google hands out — no extracting two fields.
165
+ if (env.YTSTATS_CREDENTIALS_FILE) {
166
+ return {
167
+ ...readClientSecretFile(env.YTSTATS_CREDENTIALS_FILE, 'YTSTATS_CREDENTIALS_FILE'),
168
+ source: env.YTSTATS_CREDENTIALS_FILE,
169
+ };
170
+ }
171
+
159
172
  const stored = loadStoredCredentials();
160
173
  if (stored) {
161
174
  return { clientId: stored.clientId, clientSecret: stored.clientSecret, source: 'stored' };
@@ -167,6 +180,8 @@ export function resolveCredentials({ clientSecretPath, env = process.env, cwd =
167
180
  }
168
181
 
169
182
  throw fail(DIAGNOSTICS.AUTH_NO_CREDENTIALS, {
170
- detail: `Searched: --client-secret, YTSTATS_CLIENT_ID/SECRET env vars, stored credentials, and client_secret*.json in ${cwd}`,
183
+ detail:
184
+ 'Searched: --client-secret, YTSTATS_CLIENT_ID/SECRET env vars, YTSTATS_CREDENTIALS_FILE, ' +
185
+ `stored credentials, and client_secret*.json in ${cwd}`,
171
186
  });
172
187
  }
@@ -76,6 +76,21 @@ export function getAuthenticatedClient({ account: selector, clientSecretPath, en
76
76
  detail: `OAuth client resolved from: ${credentials.source}`,
77
77
  });
78
78
  }
79
+
80
+ // Google binds a refresh token to the client that issued it, so a mismatch
81
+ // here fails at refresh time as invalid_grant — which maps to
82
+ // AUTH_TOKEN_EXPIRED and blames the consent screen. Diagnose it precisely
83
+ // instead. Accounts stored before clientId was recorded have none, and must
84
+ // keep working: only an actual disagreement is an error.
85
+ if (account.clientId && account.clientId !== credentials.clientId) {
86
+ throw fail(DIAGNOSTICS.AUTH_CLIENT_MISMATCH, {
87
+ account: account.channelTitle ?? account.channelId,
88
+ expected: account.clientId,
89
+ value: credentials.clientId,
90
+ detail: `Resolved from: ${credentials.source}`,
91
+ });
92
+ }
93
+
79
94
  const client = newClient(deps, credentials, 'http://127.0.0.1');
80
95
  client.setCredentials(account.tokens);
81
96
 
@@ -87,6 +102,7 @@ export function getAuthenticatedClient({ account: selector, clientSecretPath, en
87
102
  channelId: account.channelId,
88
103
  channelTitle: account.channelTitle,
89
104
  customUrl: account.customUrl,
105
+ clientId: account.clientId,
90
106
  tokens,
91
107
  });
92
108
  } catch {
@@ -153,6 +169,9 @@ export async function login({
153
169
  channelId: identity.channelId,
154
170
  channelTitle: identity.channelTitle,
155
171
  customUrl: identity.customUrl,
172
+ // Record which client issued this token, so a later run with different
173
+ // credentials resolved can say so precisely rather than failing at refresh.
174
+ clientId: credentials.clientId,
156
175
  tokens,
157
176
  });
158
177
 
@@ -8,11 +8,16 @@ import { YtStatsError, ERROR_CODES } from '../errors.js';
8
8
  * {
9
9
  * version: 1,
10
10
  * default: "UC...",
11
- * accounts: { "UC...": { channelId, channelTitle, customUrl, tokens, savedAt } }
11
+ * accounts: { "UC...": { channelId, channelTitle, customUrl, clientId, tokens, savedAt } }
12
12
  * }
13
13
  *
14
14
  * Keyed by channel so one machine can hold several channels; `default` is what
15
15
  * commands use when --account is not given.
16
+ *
17
+ * `clientId` records which OAuth client issued this account's refresh token. It
18
+ * is not a secret, and it is not used to authenticate — it exists so that a
19
+ * later run can tell whether the credentials it resolved are the ones the token
20
+ * actually belongs to. Absent on accounts saved before this field existed.
16
21
  */
17
22
  function emptyStore() {
18
23
  return { version: 1, default: null, accounts: {} };
@@ -30,7 +35,7 @@ function write(store) {
30
35
  }
31
36
 
32
37
  /** Persist (or update) one channel's tokens. First account logged in wins the default. */
33
- export function saveAccount({ channelId, channelTitle, customUrl, tokens }) {
38
+ export function saveAccount({ channelId, channelTitle, customUrl, clientId, tokens }) {
34
39
  if (!channelId) {
35
40
  throw new YtStatsError('Cannot save credentials without a channel id.', {
36
41
  code: ERROR_CODES.AUTH_FAILED,
@@ -44,6 +49,9 @@ export function saveAccount({ channelId, channelTitle, customUrl, tokens }) {
44
49
  channelId,
45
50
  channelTitle: channelTitle ?? existing?.channelTitle ?? null,
46
51
  customUrl: customUrl ?? existing?.customUrl ?? null,
52
+ // Falls back like the fields above: the refresh-token write-back path calls
53
+ // this without a clientId, and must not erase the binding recorded at login.
54
+ clientId: clientId ?? existing?.clientId ?? null,
47
55
  // A refresh happens without a new refresh_token; keep the one we already hold.
48
56
  tokens: { ...(existing?.tokens ?? {}), ...tokens },
49
57
  savedAt: new Date().toISOString(),
@@ -75,13 +83,14 @@ export function loadAccount(selector) {
75
83
  return match ?? null;
76
84
  }
77
85
 
78
- /** Accounts without token material — safe to print. */
86
+ /** Accounts without token material — safe to print. A client ID is not a secret. */
79
87
  export function listAccounts() {
80
88
  const store = read();
81
89
  return Object.values(store.accounts).map(a => ({
82
90
  channelId: a.channelId,
83
91
  channelTitle: a.channelTitle,
84
92
  customUrl: a.customUrl,
93
+ clientId: a.clientId ?? null,
85
94
  savedAt: a.savedAt,
86
95
  isDefault: a.channelId === store.default,
87
96
  }));
package/src/cli.js CHANGED
@@ -196,8 +196,14 @@ export function buildProgram(deps = {}) {
196
196
  .action(run('status', async () => {
197
197
  const accounts = listAccounts();
198
198
  let credentialSource = null;
199
+ let clientId = null;
199
200
  try {
200
- credentialSource = resolveCredentials().source;
201
+ // A client ID is public by OAuth design — only the secret is sensitive —
202
+ // and with five resolution sources, "which one did it pick" is not
203
+ // answerable from the source label alone.
204
+ const credentials = resolveCredentials();
205
+ credentialSource = credentials.source;
206
+ clientId = credentials.clientId;
201
207
  } catch {
202
208
  // Not configured yet; reported as null below.
203
209
  }
@@ -205,6 +211,7 @@ export function buildProgram(deps = {}) {
205
211
  authenticated: accounts.length > 0,
206
212
  configDir: configDir(),
207
213
  credentialSource,
214
+ clientId,
208
215
  accounts,
209
216
  setupGuide: accounts.length === 0 ? SETUP_GUIDE : undefined,
210
217
  };
@@ -172,6 +172,41 @@ export const DIAGNOSTICS = {
172
172
  },
173
173
  }),
174
174
 
175
+ AUTH_CLIENT_MISMATCH: def({
176
+ code: 'AUTH_CLIENT_MISMATCH',
177
+ exitCode: EXIT.AUTH,
178
+ recoverable: true,
179
+ retryable: false,
180
+ title: 'Stored token belongs to a different OAuth client',
181
+ detail:
182
+ 'This channel was authorized with one Google Cloud OAuth client, but a different client ID was ' +
183
+ 'resolved for this run. Google binds a refresh token to the client that issued it, so refreshing ' +
184
+ 'with the wrong client would fail as invalid_grant — which reads like an expired token and sends ' +
185
+ 'you to fix the wrong thing.',
186
+ cause:
187
+ 'Usually a second ytstats login with a different client_secret file overwrote the stored ' +
188
+ 'credentials while another channel was still signed in, since credentials.json holds one client ' +
189
+ 'for the whole config directory. Also reached by setting YTSTATS_CLIENT_ID/SECRET or ' +
190
+ 'YTSTATS_CREDENTIALS_FILE to a different client than the one this channel was authorized with.',
191
+ remediation: {
192
+ summary:
193
+ 'Give each OAuth client its own config directory, or re-authorize this channel with the client ' +
194
+ 'that is now resolving.',
195
+ steps: [
196
+ 'Run: ytstats status — clientId shows what resolved, and each account shows the client it was authorized with.',
197
+ 'To keep several clients side by side, isolate them: export YTSTATS_CONFIG_DIR=~/.ytstats/<name>',
198
+ 'Point at that client\'s file in the same shell: export YTSTATS_CREDENTIALS_FILE=/path/to/client_secret.json',
199
+ 'Or, to consolidate on the client that is resolving now, re-run: ytstats login',
200
+ ],
201
+ commands: [
202
+ STATUS_CMD,
203
+ { run: 'ytstats login', description: 'Re-authorize this channel with the client that is resolving now' },
204
+ DOCTOR_CMD,
205
+ ],
206
+ docs: [SETUP_DOC],
207
+ },
208
+ }),
209
+
175
210
  AUTH_CONSENT_DECLINED: def({
176
211
  code: 'AUTH_CONSENT_DECLINED',
177
212
  exitCode: EXIT.AUTH,