@vidofy/mcp 0.1.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Rate limiting for the unauthenticated OAuth endpoints.
3
+ *
4
+ * WHY THIS EXISTS — what one request to /mcp-app/authorize actually costs:
5
+ *
6
+ * • an outbound HTTPS request to a URL THE CALLER CHOSE (client_id is a URL and
7
+ * we fetch it), preceded by a DNS lookup, bounded at 2s and 8 KB
8
+ * • a socket and its buffers in the same process that serves every MCP tool
9
+ * • a Redis key that lives 600 seconds
10
+ *
11
+ * And it takes no credential at all. So a loop from one machine turns this server
12
+ * into three different problems at once: our egress IP hammering a third party
13
+ * who will rightly block us, a Node process out of sockets while real users wait
14
+ * for a generation, and Redis filling with pending records nobody will redeem.
15
+ *
16
+ * WHO ACTUALLY CALLS /authorize, because it decides the shape of the limit:
17
+ * the USER'S BROWSER, not the AI host's servers. That is not an inference from
18
+ * the probe log — it is the authorization-code flow: the endpoint carries `state`
19
+ * and ends at a consent screen the person has to read, which a server-to-server
20
+ * caller cannot complete. The measured chain agrees (the discovery documents and
21
+ * the first POST /mcp-app come from Anthropic's cloud; the authorize redirect does
22
+ * not). So a per-IP bucket is a per-USER bucket here, and the shared-egress worry
23
+ * that would make per-IP limiting wrong for a machine-to-machine endpoint does not
24
+ * apply.
25
+ *
26
+ * A FIXED WINDOW, not a sliding one. It lets a caller spend the whole budget at
27
+ * the end of one window and again at the start of the next — twice the nominal
28
+ * rate across a window boundary — which is the known cost of the cheap algorithm
29
+ * and is irrelevant at these limits. What it buys is an EXACT `Retry-After`:
30
+ * seconds until this window resets, computed rather than guessed, which is what
31
+ * the Partners API already does (see its Retry-After note). A sliding window
32
+ * cannot answer that question honestly.
33
+ */
34
+ export interface RateVerdict {
35
+ allowed: boolean;
36
+ /** Seconds until the window resets. Exact, not advisory. */
37
+ retryAfter: number;
38
+ /** How many hits are in this window, including this one. */
39
+ count: number;
40
+ }
41
+ /**
42
+ * Count one hit against a bucket.
43
+ *
44
+ * @param bucket Anything that identifies the caller for this limit, e.g.
45
+ * `authorize:ip:1.2.3.4`. Hashed into the key as-is, so callers
46
+ * must not put anything unbounded in it.
47
+ * @param limit Hits allowed per window.
48
+ * @param windowSec Window length.
49
+ *
50
+ * FAILS CLOSED on a Redis error, and that is deliberate rather than defensive
51
+ * habit: every endpoint this guards needs Redis to do its job anyway — /authorize
52
+ * cannot park a pending record without it — so a Redis outage means the request was
53
+ * going to fail regardless. Refusing early with an honest message beats doing the
54
+ * expensive outbound fetch first and failing after. The alternative, failing open,
55
+ * would remove the limiter precisely when the system is least able to absorb a
56
+ * flood.
57
+ */
58
+ export declare function hitLimit(bucket: string, limit: number, windowSec: number): Promise<RateVerdict>;
59
+ /** Starts of the flow, per IP. */
60
+ export declare const AUTHORIZE_PER_IP: {
61
+ limit: number;
62
+ windowSec: number;
63
+ };
64
+ /**
65
+ * Starts of the flow per client_id, across all IPs.
66
+ *
67
+ * The second layer, and the one that survives a distributed attacker: an attack
68
+ * from a thousand addresses defeats a per-IP limit entirely, but every request
69
+ * still has to name a client_id, and it is the client_id that drives the outbound
70
+ * fetch this endpoint exists to protect. Deliberately much higher than the per-IP
71
+ * limit — claude.ai is ONE client_id for every one of our users, so this bucket
72
+ * must never be the thing that throttles legitimate traffic.
73
+ *
74
+ * It is not a complete answer on its own: an attacker rotating client_id values
75
+ * gets a fresh bucket each time. What stops THAT is the concurrency cap on the
76
+ * fetch itself, in clients.ts — a limit on how many outbound requests can be in
77
+ * flight, which no amount of key rotation gets around.
78
+ */
79
+ export declare const AUTHORIZE_PER_CLIENT: {
80
+ limit: number;
81
+ windowSec: number;
82
+ };
83
+ /** The consent hand-off. Cheap (one Redis read) but not free, and unauthenticated. */
84
+ export declare const DECIDE_PER_IP: {
85
+ limit: number;
86
+ windowSec: number;
87
+ };
88
+ /** The code exchange. One per completed flow; a flood here is guessing codes. */
89
+ export declare const TOKEN_PER_IP: {
90
+ limit: number;
91
+ windowSec: number;
92
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Rate limiting for the unauthenticated OAuth endpoints.
3
+ *
4
+ * WHY THIS EXISTS — what one request to /mcp-app/authorize actually costs:
5
+ *
6
+ * • an outbound HTTPS request to a URL THE CALLER CHOSE (client_id is a URL and
7
+ * we fetch it), preceded by a DNS lookup, bounded at 2s and 8 KB
8
+ * • a socket and its buffers in the same process that serves every MCP tool
9
+ * • a Redis key that lives 600 seconds
10
+ *
11
+ * And it takes no credential at all. So a loop from one machine turns this server
12
+ * into three different problems at once: our egress IP hammering a third party
13
+ * who will rightly block us, a Node process out of sockets while real users wait
14
+ * for a generation, and Redis filling with pending records nobody will redeem.
15
+ *
16
+ * WHO ACTUALLY CALLS /authorize, because it decides the shape of the limit:
17
+ * the USER'S BROWSER, not the AI host's servers. That is not an inference from
18
+ * the probe log — it is the authorization-code flow: the endpoint carries `state`
19
+ * and ends at a consent screen the person has to read, which a server-to-server
20
+ * caller cannot complete. The measured chain agrees (the discovery documents and
21
+ * the first POST /mcp-app come from Anthropic's cloud; the authorize redirect does
22
+ * not). So a per-IP bucket is a per-USER bucket here, and the shared-egress worry
23
+ * that would make per-IP limiting wrong for a machine-to-machine endpoint does not
24
+ * apply.
25
+ *
26
+ * A FIXED WINDOW, not a sliding one. It lets a caller spend the whole budget at
27
+ * the end of one window and again at the start of the next — twice the nominal
28
+ * rate across a window boundary — which is the known cost of the cheap algorithm
29
+ * and is irrelevant at these limits. What it buys is an EXACT `Retry-After`:
30
+ * seconds until this window resets, computed rather than guessed, which is what
31
+ * the Partners API already does (see its Retry-After note). A sliding window
32
+ * cannot answer that question honestly.
33
+ */
34
+ import { redisClient } from './store.js';
35
+ /**
36
+ * `cache:` — and the prefix differs from `mcp_oauth:` on purpose.
37
+ *
38
+ * The pending records deliberately avoid `cache:` because the admin "Clear cache"
39
+ * button would destroy an authorization that exists nowhere else (see store.ts).
40
+ * A rate-limit counter is the opposite: it is safe to lose. Clearing it just
41
+ * refills everyone's budget, and the platform's own convention puts rate limits
42
+ * under `cache:` for exactly that reason, stating the trade-off outright.
43
+ * Kept visually distinct from the pending keys so nobody
44
+ * reading a Redis dump mistakes one for the other.
45
+ */
46
+ const PREFIX = 'cache:mcp:rl';
47
+ /**
48
+ * Count one hit against a bucket.
49
+ *
50
+ * @param bucket Anything that identifies the caller for this limit, e.g.
51
+ * `authorize:ip:1.2.3.4`. Hashed into the key as-is, so callers
52
+ * must not put anything unbounded in it.
53
+ * @param limit Hits allowed per window.
54
+ * @param windowSec Window length.
55
+ *
56
+ * FAILS CLOSED on a Redis error, and that is deliberate rather than defensive
57
+ * habit: every endpoint this guards needs Redis to do its job anyway — /authorize
58
+ * cannot park a pending record without it — so a Redis outage means the request was
59
+ * going to fail regardless. Refusing early with an honest message beats doing the
60
+ * expensive outbound fetch first and failing after. The alternative, failing open,
61
+ * would remove the limiter precisely when the system is least able to absorb a
62
+ * flood.
63
+ */
64
+ export async function hitLimit(bucket, limit, windowSec) {
65
+ const now = Math.floor(Date.now() / 1000);
66
+ const windowIndex = Math.floor(now / windowSec);
67
+ const retryAfter = (windowIndex + 1) * windowSec - now;
68
+ const key = `${PREFIX}:${bucket}:${windowIndex}`;
69
+ try {
70
+ const client = await redisClient();
71
+ const count = await client.incr(key);
72
+ /* Expire only on the first hit. Re-setting it on every hit would turn the
73
+ fixed window into a sliding one that never resets under sustained load,
74
+ so a blocked caller could never recover. */
75
+ if (count === 1)
76
+ await client.expire(key, windowSec + 1);
77
+ return { allowed: count <= limit, retryAfter, count };
78
+ }
79
+ catch {
80
+ return { allowed: false, retryAfter: windowSec, count: -1 };
81
+ }
82
+ }
83
+ /* ── the limits ──────────────────────────────────────────────────────────────
84
+ *
85
+ * Chosen against what a HUMAN does, then multiplied generously, because the cost
86
+ * of being wrong is asymmetric: too loose still stops the flood, too tight locks a
87
+ * real person out of connecting their account with no way to tell why.
88
+ *
89
+ * A person connecting a client does this ONCE. Twice if they make a mistake and
90
+ * start again. Ten times in a minute is already someone testing; thirty is not a
91
+ * person. An attacker needs thousands per minute for any of the three costs above
92
+ * to matter.
93
+ */
94
+ /** Starts of the flow, per IP. */
95
+ export const AUTHORIZE_PER_IP = { limit: 30, windowSec: 60 };
96
+ /**
97
+ * Starts of the flow per client_id, across all IPs.
98
+ *
99
+ * The second layer, and the one that survives a distributed attacker: an attack
100
+ * from a thousand addresses defeats a per-IP limit entirely, but every request
101
+ * still has to name a client_id, and it is the client_id that drives the outbound
102
+ * fetch this endpoint exists to protect. Deliberately much higher than the per-IP
103
+ * limit — claude.ai is ONE client_id for every one of our users, so this bucket
104
+ * must never be the thing that throttles legitimate traffic.
105
+ *
106
+ * It is not a complete answer on its own: an attacker rotating client_id values
107
+ * gets a fresh bucket each time. What stops THAT is the concurrency cap on the
108
+ * fetch itself, in clients.ts — a limit on how many outbound requests can be in
109
+ * flight, which no amount of key rotation gets around.
110
+ */
111
+ export const AUTHORIZE_PER_CLIENT = { limit: 600, windowSec: 60 };
112
+ /** The consent hand-off. Cheap (one Redis read) but not free, and unauthenticated. */
113
+ export const DECIDE_PER_IP = { limit: 60, windowSec: 60 };
114
+ /** The code exchange. One per completed flow; a flood here is guessing codes. */
115
+ export const TOKEN_PER_IP = { limit: 60, windowSec: 60 };
116
+ //# sourceMappingURL=ratelimit.js.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The state an authorization flow leaves behind between requests.
3
+ *
4
+ * Two short-lived records, both in Redis:
5
+ *
6
+ * pending /authorize → the consent page → back (the user has not decided yet)
7
+ * code the redirect to the client → /token (a one-time authorization code)
8
+ *
9
+ * WHY REDIS AND NOT THIS PROCESS'S MEMORY
10
+ * ---------------------------------------
11
+ * Owner decision 2026-09-12, and it buys two things memory cannot:
12
+ *
13
+ * 1. It survives a restart. A Map would mean every deploy cancels whoever is
14
+ * mid-sign-in, and they would see a failure with no cause.
15
+ * 2. PHP can read and write it. The consent screen lives on vidofy.ai because
16
+ * that is where the session cookie is — so approval is recorded by a
17
+ * different language in a different process. Shared storage is what lets
18
+ * that happen without inventing a signed side-channel between them.
19
+ *
20
+ * It is also how the rest of the project already works, which matters more than
21
+ * elegance: one place to look when something is stuck.
22
+ *
23
+ * KEY NAMING — deliberately NOT under `cache:`
24
+ * --------------------------------------------
25
+ * The platform's convention is that every cache key starts with `cache:` so an
26
+ * operator's "Clear cache" action can wipe them with one SCAN. These keys must
27
+ * NOT carry that prefix, for the same reason the platform exempts its other
28
+ * write-once secrets. They are not a copy of anything. A pending
29
+ * authorization exists ONLY here, and an authorization code is a single-use
30
+ * secret with no source to rebuild it from. An admin pressing Clear Cache while
31
+ * someone is signing in would destroy it, and the user would be bounced back to
32
+ * their client with an error nobody could explain.
33
+ *
34
+ * So: `mcp_oauth:pending:<id>` and `mcp_oauth:code:<code>`, alongside
35
+ * `mcp_flash:` which is there for the same reason.
36
+ *
37
+ * ⚠ AND THAT PROTECTS THESE KEYS FROM *ONE* ACTION, NOT FROM AN OPERATOR. There
38
+ * is a second, coarser one that flushes Redis outright. That erases every
39
+ * database, so it takes `mcp_oauth:` with it along with every session — which is
40
+ * why its own confirmation warns that all users are signed out. The paragraph
41
+ * above is about the prefix-scoped clear and was true of it; read alone it
42
+ * implied a safety these keys do not have.
43
+ *
44
+ * The consequence is small and worth stating so nobody hunts it: an admin
45
+ * pressing that button during someone's consent flow ends that flow with an
46
+ * error, and the user starts again. A ten-minute window, not a lost credential.
47
+ */
48
+ import { type RedisClientType } from 'redis';
49
+ /** An authorization request, parked while the user decides. */
50
+ export interface PendingAuthorization {
51
+ clientId: string;
52
+ clientName: string;
53
+ redirectUri: string;
54
+ codeChallenge: string;
55
+ /** The resource the token will be bound to — RFC 8707, sent by both hosts. */
56
+ resource: string;
57
+ scope: string;
58
+ /** Echoed back to the client untouched; absent when the client sent none. */
59
+ state: string | null;
60
+ /** Consent-screen language hint. ChatGPT sends it, Claude does not. */
61
+ uiLocales: string | null;
62
+ createdAt: number;
63
+ decision?: 'allow' | 'deny';
64
+ /** The approving account. 0 on deny. */
65
+ userId?: number;
66
+ decidedAt?: number;
67
+ /** The minted `vmt_` token — see IssuedCode.rawToken. Present only on allow. */
68
+ rawToken?: string;
69
+ tokenRowId?: number;
70
+ }
71
+ /** An issued authorization code, waiting to be exchanged exactly once. */
72
+ export interface IssuedCode {
73
+ clientId: string;
74
+ redirectUri: string;
75
+ codeChallenge: string;
76
+ resource: string;
77
+ scope: string;
78
+ /** Who approved it. The whole point of the flow. */
79
+ userId: number;
80
+ /**
81
+ * The `vmt_` token itself, minted by PHP at consent time.
82
+ *
83
+ * It travels here rather than being created at /token because
84
+ * The site owns the credential format and the row, and a second
85
+ * implementation in TypeScript would be a second definition of one
86
+ * credential. Same pattern as `mcp_flash:` on the tokens page.
87
+ * Lives at most CODE_TTL_SEC, and the
88
+ * record is destroyed by GETDEL the moment it is exchanged.
89
+ */
90
+ rawToken: string;
91
+ /** The row id, so a failed exchange can be traced back to what was created. */
92
+ tokenRowId: number;
93
+ }
94
+ /**
95
+ * The same connection, for code in this folder that is not about pending records.
96
+ *
97
+ * Exported for the rate limiter, which needs Redis and must not open a second
98
+ * connection to get it: two clients means two reconnect loops, two error handlers
99
+ * and twice the file descriptors, for one process that already has one working
100
+ * client. The `.env` reading and the NOAUTH problem below are also things a second
101
+ * connection would have to get right a second time.
102
+ */
103
+ export declare function redisClient(): Promise<RedisClientType>;
104
+ export declare function savePending(p: Omit<PendingAuthorization, 'createdAt'>): Promise<string>;
105
+ /**
106
+ * Read a parked request.
107
+ *
108
+ * Returns null when it is missing OR expired — the two are indistinguishable and
109
+ * should be: the caller's answer is the same, and telling a caller which one it
110
+ * was lets them probe for ids that exist.
111
+ */
112
+ export declare function readPending(id: string): Promise<PendingAuthorization | null>;
113
+ export declare function deletePending(id: string): Promise<void>;
114
+ /**
115
+ * Read a parked request and destroy it in ONE step.
116
+ *
117
+ * The same GETDEL reasoning as consumeCode, for the same reason: an approved
118
+ * request is worth exactly one authorization code. With a read followed by a
119
+ * delete, two simultaneous hits on /authorize/decide both see the approval
120
+ * before either removes it, and two codes come out of one consent — which
121
+ * multiplies a single "Allow" into more grants than the user agreed to.
122
+ */
123
+ export declare function consumePending(id: string): Promise<PendingAuthorization | null>;
124
+ export declare function issueCode(data: IssuedCode): Promise<string>;
125
+ /**
126
+ * Consume a code — read it and destroy it in ONE atomic step.
127
+ *
128
+ * GETDEL, not GET-then-DEL. With two commands, two simultaneous exchanges of the
129
+ * same stolen code both read it before either deletes it, and both get a token:
130
+ * the single-use rule becomes a race. OAuth 2.1 requires the code be usable once,
131
+ * and this is where that is enforced.
132
+ */
133
+ export declare function consumeCode(code: string): Promise<IssuedCode | null>;
134
+ /** Close the connection — for tests and a clean shutdown. */
135
+ export declare function closeStore(): Promise<void>;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The state an authorization flow leaves behind between requests.
3
+ *
4
+ * Two short-lived records, both in Redis:
5
+ *
6
+ * pending /authorize → the consent page → back (the user has not decided yet)
7
+ * code the redirect to the client → /token (a one-time authorization code)
8
+ *
9
+ * WHY REDIS AND NOT THIS PROCESS'S MEMORY
10
+ * ---------------------------------------
11
+ * Owner decision 2026-09-12, and it buys two things memory cannot:
12
+ *
13
+ * 1. It survives a restart. A Map would mean every deploy cancels whoever is
14
+ * mid-sign-in, and they would see a failure with no cause.
15
+ * 2. PHP can read and write it. The consent screen lives on vidofy.ai because
16
+ * that is where the session cookie is — so approval is recorded by a
17
+ * different language in a different process. Shared storage is what lets
18
+ * that happen without inventing a signed side-channel between them.
19
+ *
20
+ * It is also how the rest of the project already works, which matters more than
21
+ * elegance: one place to look when something is stuck.
22
+ *
23
+ * KEY NAMING — deliberately NOT under `cache:`
24
+ * --------------------------------------------
25
+ * The platform's convention is that every cache key starts with `cache:` so an
26
+ * operator's "Clear cache" action can wipe them with one SCAN. These keys must
27
+ * NOT carry that prefix, for the same reason the platform exempts its other
28
+ * write-once secrets. They are not a copy of anything. A pending
29
+ * authorization exists ONLY here, and an authorization code is a single-use
30
+ * secret with no source to rebuild it from. An admin pressing Clear Cache while
31
+ * someone is signing in would destroy it, and the user would be bounced back to
32
+ * their client with an error nobody could explain.
33
+ *
34
+ * So: `mcp_oauth:pending:<id>` and `mcp_oauth:code:<code>`, alongside
35
+ * `mcp_flash:` which is there for the same reason.
36
+ *
37
+ * ⚠ AND THAT PROTECTS THESE KEYS FROM *ONE* ACTION, NOT FROM AN OPERATOR. There
38
+ * is a second, coarser one that flushes Redis outright. That erases every
39
+ * database, so it takes `mcp_oauth:` with it along with every session — which is
40
+ * why its own confirmation warns that all users are signed out. The paragraph
41
+ * above is about the prefix-scoped clear and was true of it; read alone it
42
+ * implied a safety these keys do not have.
43
+ *
44
+ * The consequence is small and worth stating so nobody hunts it: an admin
45
+ * pressing that button during someone's consent flow ends that flow with an
46
+ * error, and the user starts again. A ten-minute window, not a lost credential.
47
+ */
48
+ import { readFileSync } from 'node:fs';
49
+ import { dirname, join } from 'node:path';
50
+ import { fileURLToPath } from 'node:url';
51
+ import { createClient } from 'redis';
52
+ /** How long a user has to finish the consent screen. */
53
+ const PENDING_TTL_SEC = 600;
54
+ /**
55
+ * How long the client has to exchange the code.
56
+ *
57
+ * OAuth 2.1 says a code SHOULD be short-lived and single-use, and recommends a
58
+ * maximum of 10 minutes; 60 seconds is enough for a redirect and one POST, and
59
+ * every second beyond that is a window for a leaked code to be replayed.
60
+ */
61
+ const CODE_TTL_SEC = 60;
62
+ const PREFIX = 'mcp_oauth';
63
+ let client = null;
64
+ /**
65
+ * The shared connection, opened on first use.
66
+ *
67
+ * `redis` reconnects on its own; what it does NOT do is queue commands forever
68
+ * while down, so a caller still has to handle a rejection — see the note on
69
+ * readPending.
70
+ */
71
+ /**
72
+ * The site's `.env`, parsed once — or an empty object when there is none.
73
+ *
74
+ * Deliberately minimal: `KEY=value`, `#` comments, optional surrounding quotes.
75
+ * It is not a dotenv replacement and must not become one; the site's own parser
76
+ * is the authority on this file's format, and anything it supports that this
77
+ * does not is a reason to pass the value through the real environment instead.
78
+ *
79
+ * Found by walking UP from this module rather than from `process.cwd()`, because
80
+ * the working directory depends on who started the process — a manager, a shell
81
+ * in `vidofy-mcp/`, or a shell in the repo root — while the module's own position
82
+ * relative to the repo never changes. `VIDOFY_ENV_FILE` overrides it outright for
83
+ * a deployment that puts the file somewhere else.
84
+ */
85
+ let cachedFileEnv = null;
86
+ function siteEnvFile() {
87
+ if (cachedFileEnv !== null)
88
+ return cachedFileEnv;
89
+ cachedFileEnv = {};
90
+ const explicit = (process.env['VIDOFY_ENV_FILE'] ?? '').trim();
91
+ const here = dirname(fileURLToPath(import.meta.url));
92
+ // dist/oauth → dist → vidofy-mcp → repo root. Four, to survive src/ vs dist/.
93
+ const candidates = explicit !== ''
94
+ ? [explicit]
95
+ : [1, 2, 3, 4].map((up) => join(here, ...Array(up).fill('..'), '.env'));
96
+ for (const path of candidates) {
97
+ let text;
98
+ try {
99
+ text = readFileSync(path, 'utf8');
100
+ }
101
+ catch {
102
+ continue; // absent is normal — the npm package has no .env
103
+ }
104
+ for (const line of text.split('\n')) {
105
+ const trimmed = line.trim();
106
+ if (trimmed === '' || trimmed.startsWith('#'))
107
+ continue;
108
+ const eq = trimmed.indexOf('=');
109
+ if (eq <= 0)
110
+ continue;
111
+ const k = trimmed.slice(0, eq).trim();
112
+ let v = trimmed.slice(eq + 1).trim();
113
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
114
+ v = v.slice(1, -1);
115
+ }
116
+ cachedFileEnv[k] = v;
117
+ }
118
+ break;
119
+ }
120
+ return cachedFileEnv;
121
+ }
122
+ /**
123
+ * The same connection, for code in this folder that is not about pending records.
124
+ *
125
+ * Exported for the rate limiter, which needs Redis and must not open a second
126
+ * connection to get it: two clients means two reconnect loops, two error handlers
127
+ * and twice the file descriptors, for one process that already has one working
128
+ * client. The `.env` reading and the NOAUTH problem below are also things a second
129
+ * connection would have to get right a second time.
130
+ */
131
+ export async function redisClient() {
132
+ return await redis();
133
+ }
134
+ async function redis() {
135
+ if (client !== null && client.isOpen)
136
+ return client;
137
+ /* Where the connection details come from, in order: the process environment
138
+ * under `VIDOFY_REDIS_*`, then under the site's own `REDIS_*`, then the site's
139
+ * `.env` FILE.
140
+ *
141
+ * That third source is the one that matters, and it was missing. Only the
142
+ * `VIDOFY_`-prefixed variables were read, and they appear in no deploy artifact
143
+ * anywhere — no process-manager config, no unit file, not `.env`. The
144
+ * first fix for that read `REDIS_*` too, which looked right and was still
145
+ * incomplete: **Node does not read `.env`.** That file is parsed by the
146
+ * site's own loader, so `process.env.REDIS_PASSWORD` is empty unless a
147
+ * human happened to export it in the shell that started this process.
148
+ *
149
+ * And it is not a production-only problem, which is what the first version of
150
+ * this comment assumed. Measured 2026-09-13: local Redis answers
151
+ * `NOAUTH Authentication required` without a password. So with none of these
152
+ * variables exported, every call here fails — locally too — and /authorize
153
+ * answers a bare 500 with no explanation.
154
+ *
155
+ * Reading the file is done here rather than by adding dotenv: one dependency
156
+ * for fifteen lines, on a package that also ships to npm as a standalone stdio
157
+ * server where the file does not exist and Redis is never used at all. Absent
158
+ * is therefore silent, not an error. */
159
+ const fileEnv = siteEnvFile();
160
+ const env = (a, b, fallback) => {
161
+ const first = (process.env[a] ?? '').trim();
162
+ if (first !== '')
163
+ return first;
164
+ const second = (process.env[b] ?? '').trim();
165
+ if (second !== '')
166
+ return second;
167
+ return (fileEnv[b] ?? '').trim() || fallback;
168
+ };
169
+ const host = env('VIDOFY_REDIS_HOST', 'REDIS_HOST', '127.0.0.1');
170
+ const port = Number(env('VIDOFY_REDIS_PORT', 'REDIS_PORT', '6379'));
171
+ const password = env('VIDOFY_REDIS_PASSWORD', 'REDIS_PASSWORD', '');
172
+ /* The same logical database the site uses. Sharing it is intentional: the
173
+ site reads these keys, and a different database would mean the consent
174
+ page writing where this process never looks. */
175
+ const database = Number(env('VIDOFY_REDIS_DB', 'REDIS_DB', '1'));
176
+ const c = createClient({
177
+ socket: { host, port },
178
+ ...(password !== '' ? { password } : {}),
179
+ database,
180
+ });
181
+ /* Without a listener, an error event on a node-redis client is an unhandled
182
+ 'error' and takes the process down — which would turn a Redis blip into an
183
+ outage of every tool, not just the ones that need storage. */
184
+ c.on('error', () => { });
185
+ await c.connect();
186
+ client = c;
187
+ return c;
188
+ }
189
+ /** 32 bytes of URL-safe randomness — the id and code format. */
190
+ function token() {
191
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(32)))
192
+ .toString('base64url');
193
+ }
194
+ /* ── pending authorizations ───────────────────────────────────────────────── */
195
+ export async function savePending(p) {
196
+ const id = token();
197
+ const c = await redis();
198
+ await c.set(`${PREFIX}:pending:${id}`, JSON.stringify({ ...p, createdAt: Date.now() }), {
199
+ expiration: { type: 'EX', value: PENDING_TTL_SEC },
200
+ });
201
+ return id;
202
+ }
203
+ /**
204
+ * Read a parked request.
205
+ *
206
+ * Returns null when it is missing OR expired — the two are indistinguishable and
207
+ * should be: the caller's answer is the same, and telling a caller which one it
208
+ * was lets them probe for ids that exist.
209
+ */
210
+ export async function readPending(id) {
211
+ const raw = await (await redis()).get(`${PREFIX}:pending:${id}`);
212
+ if (raw === null)
213
+ return null;
214
+ try {
215
+ return JSON.parse(raw);
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ }
221
+ export async function deletePending(id) {
222
+ await (await redis()).del(`${PREFIX}:pending:${id}`);
223
+ }
224
+ /**
225
+ * Read a parked request and destroy it in ONE step.
226
+ *
227
+ * The same GETDEL reasoning as consumeCode, for the same reason: an approved
228
+ * request is worth exactly one authorization code. With a read followed by a
229
+ * delete, two simultaneous hits on /authorize/decide both see the approval
230
+ * before either removes it, and two codes come out of one consent — which
231
+ * multiplies a single "Allow" into more grants than the user agreed to.
232
+ */
233
+ export async function consumePending(id) {
234
+ const raw = await (await redis()).getDel(`${PREFIX}:pending:${id}`);
235
+ if (raw === null)
236
+ return null;
237
+ try {
238
+ return JSON.parse(raw);
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
244
+ /* ── authorization codes ──────────────────────────────────────────────────── */
245
+ export async function issueCode(data) {
246
+ const code = token();
247
+ await (await redis()).set(`${PREFIX}:code:${code}`, JSON.stringify(data), {
248
+ expiration: { type: 'EX', value: CODE_TTL_SEC },
249
+ });
250
+ return code;
251
+ }
252
+ /**
253
+ * Consume a code — read it and destroy it in ONE atomic step.
254
+ *
255
+ * GETDEL, not GET-then-DEL. With two commands, two simultaneous exchanges of the
256
+ * same stolen code both read it before either deletes it, and both get a token:
257
+ * the single-use rule becomes a race. OAuth 2.1 requires the code be usable once,
258
+ * and this is where that is enforced.
259
+ */
260
+ export async function consumeCode(code) {
261
+ const raw = await (await redis()).getDel(`${PREFIX}:code:${code}`);
262
+ if (raw === null)
263
+ return null;
264
+ try {
265
+ return JSON.parse(raw);
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ /** Close the connection — for tests and a clean shutdown. */
272
+ export async function closeStore() {
273
+ if (client !== null && client.isOpen)
274
+ await client.quit();
275
+ client = null;
276
+ }
277
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `POST /mcp-app/token` — the authorization code becomes an access token.
3
+ *
4
+ * This is the only place a credential leaves the server, so everything it checks
5
+ * is a precondition for handing one over:
6
+ *
7
+ * 1. the code exists and has not been used (GETDEL — one code, one token)
8
+ * 2. the client asking is the client the code was issued to
9
+ * 3. the redirect_uri matches the one bound to the code
10
+ * 4. PKCE: SHA256(code_verifier) equals the challenge recorded at /authorize
11
+ *
12
+ * PKCE IS THE WHOLE PROTECTION HERE
13
+ * ---------------------------------
14
+ * The code travels through the user's browser — in a URL, through history, past
15
+ * whatever extensions are installed. Anyone who captures it could exchange it,
16
+ * and the client authenticates with `none` (a public client has no secret to
17
+ * prove itself with). What stops the exchange is that the thief does not have
18
+ * the verifier: it never left the client. So this check is not defence in depth,
19
+ * it is the door.
20
+ *
21
+ * WHY THERE IS NO TOKEN GENERATION IN THIS FILE
22
+ * ---------------------------------------------
23
+ * The `vmt_` token was minted by the site when the user approved, and rides in
24
+ * the code record. The site owns the credential format, the hash, the scope and
25
+ * the expiry; a second implementation here would be a second definition of one
26
+ * credential.
27
+ */
28
+ import type { IncomingMessage, ServerResponse } from 'node:http';
29
+ export declare function handleToken(req: IncomingMessage, res: ServerResponse): Promise<void>;