@openephemeris/mcp-server 3.13.9 → 3.14.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/config/dev-allowlist.json +28 -4
  4. package/dist/index.js +20 -13
  5. package/dist/oauth/dcr.d.ts +11 -0
  6. package/dist/oauth/dcr.js +49 -0
  7. package/dist/oauth/discovery.d.ts +12 -0
  8. package/dist/oauth/discovery.js +45 -0
  9. package/dist/oauth/pkce.d.ts +18 -0
  10. package/dist/oauth/pkce.js +34 -0
  11. package/dist/oauth/rate-limit.d.ts +20 -0
  12. package/dist/oauth/rate-limit.js +65 -0
  13. package/dist/oauth/store.d.ts +88 -0
  14. package/dist/oauth/store.js +361 -0
  15. package/dist/oauth/supabase-jwt.d.ts +39 -0
  16. package/dist/oauth/supabase-jwt.js +194 -0
  17. package/dist/oauth/token.d.ts +21 -0
  18. package/dist/oauth/token.js +215 -0
  19. package/dist/prompts.js +180 -159
  20. package/dist/server-sse.js +154 -49
  21. package/dist/tools/apps/bazi-app.d.ts +23 -0
  22. package/dist/tools/apps/bazi-app.js +226 -0
  23. package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
  24. package/dist/tools/apps/bi-wheel-app.js +347 -113
  25. package/dist/tools/apps/bodygraph-app.js +106 -13
  26. package/dist/tools/apps/chart-wheel-app.js +53 -4
  27. package/dist/tools/apps/location-tools.js +3 -0
  28. package/dist/tools/apps/moon-phase-app.d.ts +19 -0
  29. package/dist/tools/apps/moon-phase-app.js +244 -0
  30. package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
  31. package/dist/tools/apps/transit-timeline-app.js +295 -0
  32. package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
  33. package/dist/tools/apps/vedic-chart-app.js +228 -0
  34. package/dist/tools/auth.js +4 -0
  35. package/dist/tools/dev.js +3 -0
  36. package/dist/tools/index.d.ts +6 -0
  37. package/dist/tools/index.js +9 -4
  38. package/dist/tools/output-schemas.d.ts +37 -0
  39. package/dist/tools/output-schemas.js +130 -0
  40. package/dist/tools/specialized/acg.js +5 -2
  41. package/dist/tools/specialized/bazi.js +375 -44
  42. package/dist/tools/specialized/bi_wheel.js +3 -1
  43. package/dist/tools/specialized/chart_wheel.js +3 -1
  44. package/dist/tools/specialized/comparative.js +9 -4
  45. package/dist/tools/specialized/eclipse.js +3 -1
  46. package/dist/tools/specialized/electional.js +9 -4
  47. package/dist/tools/specialized/ephemeris_core.js +5 -2
  48. package/dist/tools/specialized/ephemeris_extended.js +17 -8
  49. package/dist/tools/specialized/hd_bodygraph.js +3 -1
  50. package/dist/tools/specialized/hd_cycles.js +5 -2
  51. package/dist/tools/specialized/hd_group.js +5 -2
  52. package/dist/tools/specialized/human_design.js +3 -1
  53. package/dist/tools/specialized/moon.js +5 -2
  54. package/dist/tools/specialized/natal.js +3 -1
  55. package/dist/tools/specialized/progressed.js +3 -1
  56. package/dist/tools/specialized/relocation.js +3 -1
  57. package/dist/tools/specialized/returns.js +7 -3
  58. package/dist/tools/specialized/synastry.js +3 -1
  59. package/dist/tools/specialized/transits.js +53 -44
  60. package/dist/tools/specialized/vedic.js +3 -1
  61. package/dist/tools/specialized/venus_star_points.js +13 -6
  62. package/dist/ui/bazi.html +213 -0
  63. package/dist/ui/bi-wheel.html +3714 -3048
  64. package/dist/ui/bodygraph.html +1952 -1766
  65. package/dist/ui/chart-wheel.html +3431 -2964
  66. package/dist/ui/moon-phase.html +6764 -0
  67. package/dist/ui/transit-timeline.html +6874 -0
  68. package/dist/ui/vedic-chart.html +210 -0
  69. package/package.json +15 -12
  70. package/smithery.yaml +16 -1
@@ -0,0 +1,361 @@
1
+ /**
2
+ * src/oauth/store.ts — OAuth authorization code store.
3
+ *
4
+ * Architecture: in-memory is always the source of truth for the MCP server
5
+ * process. When Supabase env vars are present AND valid, codes are ALSO
6
+ * persisted async to Supabase for durability and multi-instance lookup.
7
+ *
8
+ * This means:
9
+ * - Unit tests (no env vars) → pure in-memory, zero network calls ✓
10
+ * - Production (Supabase set) → in-memory for hot-path, Supabase for
11
+ * persistence/audit trail ✓
12
+ *
13
+ * NOTE: The consent page (/api/oauth/approve) writes directly to Supabase
14
+ * using the service role key without going through this store. The store
15
+ * is used by the MCP server's own /oauth/* endpoints.
16
+ *
17
+ * Security:
18
+ * - NEVER log the `code` value. Log only the row `id` (uuid).
19
+ * - consumeCodeAsync marks codes consumed atomically in Supabase when
20
+ * Supabase is reachable; falls through to in-memory otherwise.
21
+ */
22
+ import https from "node:https";
23
+ import { generateAuthorizationCode } from "./pkce.js";
24
+ import { randomBytes } from "node:crypto";
25
+ // ---------------------------------------------------------------------------
26
+ // Supabase config (read at runtime, never at import time)
27
+ // ---------------------------------------------------------------------------
28
+ function getSupabaseConfig() {
29
+ const env = process.env;
30
+ const url = env["SUPABASE_URL"] || "";
31
+ const key = env["SUPABASE_SERVICE_ROLE_KEY"] || "";
32
+ if (!url || !key)
33
+ return null;
34
+ return { url, key };
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Raw HTTPS helper (mirrors device-db.ts pattern)
38
+ // ---------------------------------------------------------------------------
39
+ function postgrestRequest(method, path, body, extraHeaders) {
40
+ const cfg = getSupabaseConfig();
41
+ const parsed = new URL(path, cfg.url);
42
+ return new Promise((resolve, reject) => {
43
+ const options = {
44
+ hostname: parsed.hostname,
45
+ port: 443,
46
+ path: parsed.pathname + parsed.search,
47
+ method,
48
+ headers: {
49
+ apikey: cfg.key,
50
+ Authorization: `Bearer ${cfg.key}`,
51
+ "Content-Type": "application/json",
52
+ Prefer: method === "POST" ? "return=representation" : "return=minimal",
53
+ ...extraHeaders,
54
+ },
55
+ };
56
+ const req = https.request(options, (res) => {
57
+ let raw = "";
58
+ res.on("data", (chunk) => { raw += chunk; });
59
+ res.on("end", () => {
60
+ try {
61
+ resolve({ status: res.statusCode ?? 500, data: JSON.parse(raw) });
62
+ }
63
+ catch {
64
+ resolve({ status: res.statusCode ?? 500, data: raw });
65
+ }
66
+ });
67
+ });
68
+ req.on("error", reject);
69
+ if (body)
70
+ req.write(JSON.stringify(body));
71
+ req.end();
72
+ });
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Supabase persistence (fire-and-forget durability write)
76
+ // ---------------------------------------------------------------------------
77
+ function persistCodeToSupabase(code, options, expiresAt) {
78
+ const cfg = getSupabaseConfig();
79
+ if (!cfg)
80
+ return; // No-op in tests
81
+ postgrestRequest("POST", "/rest/v1/oauth_authorization_codes", {
82
+ code,
83
+ client_id: options.client_id,
84
+ user_id: options.user_id ?? "00000000-0000-0000-0000-000000000000",
85
+ redirect_uri: options.redirect_uri,
86
+ code_challenge: options.code_challenge,
87
+ code_challenge_method: options.code_challenge_method,
88
+ scopes: ["openephemeris:read"],
89
+ consumed: false,
90
+ expires_at: expiresAt.toISOString(),
91
+ })
92
+ .then(({ status, data }) => {
93
+ if (status < 200 || status >= 300) {
94
+ console.warn(`[store] Supabase persist failed: ${status}`, data);
95
+ return;
96
+ }
97
+ const rows = Array.isArray(data) ? data : [data];
98
+ const id = rows[0]?.id ?? "(unknown)";
99
+ console.info(`[store] code persisted to Supabase id=${id}`);
100
+ })
101
+ .catch((err) => {
102
+ // Non-fatal — in-memory store is the authoritative source
103
+ console.warn("[store] Supabase persist error (non-fatal):", err.message);
104
+ });
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Refresh-token persistence (mirrors persistCodeToSupabase pattern)
108
+ // ---------------------------------------------------------------------------
109
+ function persistRefreshTokenToSupabase(token, meta, expiresAt) {
110
+ const cfg = getSupabaseConfig();
111
+ if (!cfg)
112
+ return; // No-op in tests
113
+ const m = meta;
114
+ postgrestRequest("POST", "/rest/v1/oauth_refresh_tokens", {
115
+ token,
116
+ client_id: m.client_id ?? "unknown",
117
+ user_id: m.user_id ?? null,
118
+ supabase_refresh_token: m.supabase_refresh_token ?? null,
119
+ consumed: false,
120
+ expires_at: expiresAt.toISOString(),
121
+ })
122
+ .then(({ status, data }) => {
123
+ if (status < 200 || status >= 300) {
124
+ console.warn(`[store] Supabase refresh-token persist failed: ${status}`, data);
125
+ return;
126
+ }
127
+ const rows = Array.isArray(data) ? data : [data];
128
+ const id = rows[0]?.id ?? "(unknown)";
129
+ console.info(`[store] refresh token persisted to Supabase id=${id}`);
130
+ })
131
+ .catch((err) => {
132
+ // Non-fatal — in-memory store still serves same-instance refreshes
133
+ console.warn("[store] Supabase refresh-token persist error (non-fatal):", err.message);
134
+ });
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // OAuthStore
138
+ // ---------------------------------------------------------------------------
139
+ export class OAuthStore {
140
+ /** In-memory is ALWAYS the hot-path source of truth */
141
+ codes = new Map();
142
+ refreshTokens = new Map();
143
+ cleanupTimer = null;
144
+ constructor() {
145
+ // Auto-cleanup expired codes every 60 seconds
146
+ this.cleanupTimer = setInterval(() => this.purgeExpired(), 60_000);
147
+ if (this.cleanupTimer.unref)
148
+ this.cleanupTimer.unref();
149
+ }
150
+ // ------------------------------------------------------------------
151
+ // Authorization codes
152
+ // ------------------------------------------------------------------
153
+ /**
154
+ * Issue a new authorization code.
155
+ *
156
+ * Always writes to in-memory (fast, test-safe).
157
+ * Also fire-and-forgets a Supabase persist when env vars are set.
158
+ */
159
+ issueCode(options) {
160
+ const code = generateAuthorizationCode();
161
+ const expiresInMs = options.expiresInMs ?? 600_000; // 10 min default
162
+ const expires_at = new Date(Date.now() + expiresInMs);
163
+ this.codes.set(code, { ...options, used: false, expires_at });
164
+ // Async durability write — non-blocking
165
+ persistCodeToSupabase(code, options, expires_at);
166
+ return code;
167
+ }
168
+ /**
169
+ * Consume a code synchronously from in-memory (single-use).
170
+ * This is the fast-path used by the token endpoint.
171
+ */
172
+ consumeCode(code) {
173
+ const stored = this.codes.get(code);
174
+ if (!stored)
175
+ return null;
176
+ if (stored.used)
177
+ return null;
178
+ if (stored.expires_at < new Date()) {
179
+ this.codes.delete(code);
180
+ return null;
181
+ }
182
+ stored.used = true;
183
+ return { ...stored };
184
+ }
185
+ /**
186
+ * Async consume — checks in-memory first, then Supabase.
187
+ *
188
+ * Use this from token.ts so production deployments with Supabase
189
+ * can survive process restarts (code issued before restart lives in DB).
190
+ */
191
+ async consumeCodeAsync(code) {
192
+ // Fast path: in-memory hit (covers 99% of cases)
193
+ const inMem = this.consumeCode(code);
194
+ if (inMem)
195
+ return inMem;
196
+ // Slow path: try Supabase (handles post-restart lookups)
197
+ const cfg = getSupabaseConfig();
198
+ if (!cfg)
199
+ return null;
200
+ try {
201
+ const selectPath = `/rest/v1/oauth_authorization_codes` +
202
+ `?code=eq.${encodeURIComponent(code)}` +
203
+ `&consumed=eq.false` +
204
+ `&expires_at=gt.${encodeURIComponent(new Date().toISOString())}` +
205
+ `&limit=1`;
206
+ const { status: selStatus, data: selData } = await postgrestRequest("GET", selectPath);
207
+ if (selStatus < 200 || selStatus >= 300)
208
+ return null;
209
+ const rows = Array.isArray(selData) ? selData : [];
210
+ if (rows.length === 0)
211
+ return null;
212
+ const row = rows[0];
213
+ // Optimistic lock: mark consumed=true WHERE consumed=false
214
+ const updatePath = `/rest/v1/oauth_authorization_codes?id=eq.${row.id}&consumed=eq.false`;
215
+ const { status: updStatus } = await postgrestRequest("PATCH", updatePath, {
216
+ consumed: true,
217
+ });
218
+ if (updStatus < 200 || updStatus >= 300) {
219
+ console.warn(`[store] Supabase consume mark failed id=${row.id} status=${updStatus}`);
220
+ return null;
221
+ }
222
+ console.info(`[store] code consumed from Supabase id=${row.id}`);
223
+ return {
224
+ client_id: row.client_id,
225
+ user_id: row.user_id,
226
+ redirect_uri: row.redirect_uri,
227
+ code_challenge: row.code_challenge,
228
+ code_challenge_method: row.code_challenge_method,
229
+ used: true,
230
+ expires_at: new Date(row.expires_at),
231
+ };
232
+ }
233
+ catch (err) {
234
+ console.warn("[store] Supabase consume error:", err.message);
235
+ return null;
236
+ }
237
+ }
238
+ // ------------------------------------------------------------------
239
+ // Refresh tokens
240
+ //
241
+ // In-memory map is the hot path. When Supabase is configured, we ALSO
242
+ // persist to oauth_refresh_tokens so a refresh request that lands on a
243
+ // different Fly machine (oe-mcp-live has min_machines_running=2) or
244
+ // arrives after a machine restart can still be honored. Without this,
245
+ // ~50% of refresh attempts fail with invalid_grant just from
246
+ // multi-instance load balancing alone.
247
+ // ------------------------------------------------------------------
248
+ /**
249
+ * Refresh-token TTL — 90 days, sliding (reset on each rotation).
250
+ * Matches GitHub's 6-month convention more closely than Supabase's 30d
251
+ * default. Tradeoff: longer-lived refresh tokens = bigger blast radius
252
+ * on leak; rotation + Supabase RLS mitigates this. An active user who
253
+ * touches OE at least once per 90d gets an effectively permanent
254
+ * connection.
255
+ */
256
+ static REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000;
257
+ storeRefreshToken(token, meta) {
258
+ const expires_at = new Date(Date.now() + OAuthStore.REFRESH_TTL_MS);
259
+ this.refreshTokens.set(token, { ...meta, consumed: false, expires_at });
260
+ // Async durability write — non-blocking
261
+ persistRefreshTokenToSupabase(token, meta, expires_at);
262
+ }
263
+ /**
264
+ * Synchronous in-memory consume. Kept for backward compat with any callers
265
+ * that don't need cross-instance resilience. Token endpoint must use
266
+ * consumeRefreshTokenAsync.
267
+ */
268
+ consumeRefreshToken(token) {
269
+ const stored = this.refreshTokens.get(token);
270
+ if (!stored || stored.consumed)
271
+ return null;
272
+ if (stored.expires_at < new Date()) {
273
+ // In-memory entry outlived its TTL. Drop it; the Supabase row (if any)
274
+ // is also past its expires_at and will be rejected by the WHERE filter
275
+ // in consumeRefreshTokenAsync's slow path.
276
+ this.refreshTokens.delete(token);
277
+ return null;
278
+ }
279
+ stored.consumed = true;
280
+ return { ...stored };
281
+ }
282
+ /**
283
+ * Async consume — checks in-memory first, then Supabase. This is what the
284
+ * token endpoint uses so refresh requests survive machine restarts and
285
+ * load-balancing across multiple Fly instances.
286
+ */
287
+ async consumeRefreshTokenAsync(token) {
288
+ // Fast path: in-memory hit
289
+ const inMem = this.consumeRefreshToken(token);
290
+ if (inMem)
291
+ return inMem;
292
+ // Slow path: Supabase lookup with atomic mark-consumed
293
+ const cfg = getSupabaseConfig();
294
+ if (!cfg)
295
+ return null;
296
+ try {
297
+ const selectPath = `/rest/v1/oauth_refresh_tokens` +
298
+ `?token=eq.${encodeURIComponent(token)}` +
299
+ `&consumed=eq.false` +
300
+ `&expires_at=gt.${encodeURIComponent(new Date().toISOString())}` +
301
+ `&limit=1`;
302
+ const { status: selStatus, data: selData } = await postgrestRequest("GET", selectPath);
303
+ if (selStatus < 200 || selStatus >= 300)
304
+ return null;
305
+ const rows = Array.isArray(selData) ? selData : [];
306
+ if (rows.length === 0)
307
+ return null;
308
+ const row = rows[0];
309
+ // Optimistic lock: mark consumed=true WHERE consumed=false
310
+ const updatePath = `/rest/v1/oauth_refresh_tokens?id=eq.${row.id}&consumed=eq.false`;
311
+ const { status: updStatus } = await postgrestRequest("PATCH", updatePath, {
312
+ consumed: true,
313
+ });
314
+ if (updStatus < 200 || updStatus >= 300) {
315
+ // Lost the race — another instance consumed it. Treat as invalid.
316
+ console.warn(`[store] Supabase refresh-token consume race lost id=${row.id} status=${updStatus}`);
317
+ return null;
318
+ }
319
+ console.info(`[store] refresh token consumed from Supabase id=${row.id}`);
320
+ const result = {
321
+ consumed: true,
322
+ client_id: row.client_id,
323
+ };
324
+ if (row.user_id)
325
+ result.user_id = row.user_id;
326
+ if (row.supabase_refresh_token)
327
+ result.supabase_refresh_token = row.supabase_refresh_token;
328
+ return result;
329
+ }
330
+ catch (err) {
331
+ console.warn("[store] Supabase refresh-token consume error:", err.message);
332
+ return null;
333
+ }
334
+ }
335
+ generateRefreshToken() {
336
+ return randomBytes(32).toString("hex");
337
+ }
338
+ // ------------------------------------------------------------------
339
+ // Test utilities
340
+ // ------------------------------------------------------------------
341
+ reset() {
342
+ this.codes.clear();
343
+ this.refreshTokens.clear();
344
+ }
345
+ // ------------------------------------------------------------------
346
+ // Cleanup
347
+ // ------------------------------------------------------------------
348
+ purgeExpired() {
349
+ const now = new Date();
350
+ for (const [code, stored] of this.codes) {
351
+ if (stored.expires_at < now)
352
+ this.codes.delete(code);
353
+ }
354
+ }
355
+ destroy() {
356
+ if (this.cleanupTimer) {
357
+ clearInterval(this.cleanupTimer);
358
+ this.cleanupTimer = null;
359
+ }
360
+ }
361
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * src/oauth/supabase-jwt.ts — Issue real Supabase JWTs for OAuth code exchange.
3
+ *
4
+ * Strategy:
5
+ * 1. After the consent page stores an authorization code (with user_id) in
6
+ * Supabase, the token endpoint consumes that code and retrieves user_id.
7
+ * 2. This module verifies the user exists via the Admin API, then signs an
8
+ * HS256 JWT using SUPABASE_JWT_SECRET — the same secret Supabase GoTrue
9
+ * uses internally.
10
+ * 3. The resulting JWT starts with "eyJ" → extractAuth classifies it as a
11
+ * JWT → the Go sidecar validates it via ValidateSupabaseJWT (HS256 path).
12
+ *
13
+ * Why HS256 self-sign instead of GoTrue session APIs:
14
+ * - Zero extra HTTP round-trips beyond the user-existence check.
15
+ * - The Go sidecar uses loadLiveBillingMetadata to fetch tier from the DB,
16
+ * so JWT-embedded app_metadata.billing_tier isn't required.
17
+ * - generate_link + OTP exchange adds fragile indirection for no benefit.
18
+ *
19
+ * Fallback: If env vars aren't set (test/dev), returns null and the token
20
+ * endpoint falls back to placeholder tokens.
21
+ *
22
+ * Required env vars:
23
+ * - SUPABASE_URL (e.g. https://xxxx.supabase.co)
24
+ * - SUPABASE_SERVICE_ROLE_KEY (for Admin API user lookup)
25
+ * - SUPABASE_JWT_SECRET (for HS256 signing)
26
+ */
27
+ export interface SupabaseSessionTokens {
28
+ access_token: string;
29
+ refresh_token: string;
30
+ expires_in: number;
31
+ token_type: "bearer";
32
+ }
33
+ /**
34
+ * Verify the user exists in Supabase, then self-sign an HS256 JWT using
35
+ * SUPABASE_JWT_SECRET. Returns null if any required env var is missing
36
+ * or the user doesn't exist.
37
+ */
38
+ export declare function issueSupabaseJWT(userId: string): Promise<SupabaseSessionTokens | null>;
39
+ export declare function refreshSupabaseSession(refreshToken: string): Promise<SupabaseSessionTokens | null>;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * src/oauth/supabase-jwt.ts — Issue real Supabase JWTs for OAuth code exchange.
3
+ *
4
+ * Strategy:
5
+ * 1. After the consent page stores an authorization code (with user_id) in
6
+ * Supabase, the token endpoint consumes that code and retrieves user_id.
7
+ * 2. This module verifies the user exists via the Admin API, then signs an
8
+ * HS256 JWT using SUPABASE_JWT_SECRET — the same secret Supabase GoTrue
9
+ * uses internally.
10
+ * 3. The resulting JWT starts with "eyJ" → extractAuth classifies it as a
11
+ * JWT → the Go sidecar validates it via ValidateSupabaseJWT (HS256 path).
12
+ *
13
+ * Why HS256 self-sign instead of GoTrue session APIs:
14
+ * - Zero extra HTTP round-trips beyond the user-existence check.
15
+ * - The Go sidecar uses loadLiveBillingMetadata to fetch tier from the DB,
16
+ * so JWT-embedded app_metadata.billing_tier isn't required.
17
+ * - generate_link + OTP exchange adds fragile indirection for no benefit.
18
+ *
19
+ * Fallback: If env vars aren't set (test/dev), returns null and the token
20
+ * endpoint falls back to placeholder tokens.
21
+ *
22
+ * Required env vars:
23
+ * - SUPABASE_URL (e.g. https://xxxx.supabase.co)
24
+ * - SUPABASE_SERVICE_ROLE_KEY (for Admin API user lookup)
25
+ * - SUPABASE_JWT_SECRET (for HS256 signing)
26
+ */
27
+ import https from "node:https";
28
+ import { createHmac, randomBytes } from "node:crypto";
29
+ function getConfig() {
30
+ const url = process.env["SUPABASE_URL"] || "";
31
+ const key = process.env["SUPABASE_SERVICE_ROLE_KEY"] || "";
32
+ if (!url || !key)
33
+ return null;
34
+ return { url, serviceRoleKey: key };
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Admin API request helper (raw HTTPS, no dependencies)
38
+ // ---------------------------------------------------------------------------
39
+ function adminRequest(method, pathname, body, cfg) {
40
+ const parsed = new URL(pathname, cfg.url);
41
+ return new Promise((resolve, reject) => {
42
+ const options = {
43
+ hostname: parsed.hostname,
44
+ port: 443,
45
+ path: parsed.pathname + parsed.search,
46
+ method,
47
+ headers: {
48
+ apikey: cfg.serviceRoleKey,
49
+ Authorization: `Bearer ${cfg.serviceRoleKey}`,
50
+ "Content-Type": "application/json",
51
+ },
52
+ };
53
+ const req = https.request(options, (res) => {
54
+ let raw = "";
55
+ res.on("data", (chunk) => { raw += chunk; });
56
+ res.on("end", () => {
57
+ try {
58
+ resolve({ status: res.statusCode ?? 500, data: JSON.parse(raw) });
59
+ }
60
+ catch {
61
+ resolve({ status: res.statusCode ?? 500, data: raw });
62
+ }
63
+ });
64
+ });
65
+ req.on("error", reject);
66
+ req.setTimeout(10_000, () => {
67
+ req.destroy(new Error("Supabase admin request timed out"));
68
+ });
69
+ if (body)
70
+ req.write(JSON.stringify(body));
71
+ req.end();
72
+ });
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Get user by ID — verifies existence and retrieves email for JWT claims
76
+ // ---------------------------------------------------------------------------
77
+ async function getUserById(userId, cfg) {
78
+ try {
79
+ const { status, data } = await adminRequest("GET", `/auth/v1/admin/users/${encodeURIComponent(userId)}`, null, cfg);
80
+ if (status < 200 || status >= 300) {
81
+ console.warn(`[supabase-jwt] getUserById failed: status=${status}`, data);
82
+ return null;
83
+ }
84
+ const user = data;
85
+ if (!user.id || !user.email)
86
+ return null;
87
+ return { id: user.id, email: user.email };
88
+ }
89
+ catch (err) {
90
+ console.warn("[supabase-jwt] getUserById error:", err.message);
91
+ return null;
92
+ }
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Issue a real Supabase-compatible JWT for a given user_id
96
+ // ---------------------------------------------------------------------------
97
+ /**
98
+ * Verify the user exists in Supabase, then self-sign an HS256 JWT using
99
+ * SUPABASE_JWT_SECRET. Returns null if any required env var is missing
100
+ * or the user doesn't exist.
101
+ */
102
+ export async function issueSupabaseJWT(userId) {
103
+ const cfg = getConfig();
104
+ if (!cfg)
105
+ return null;
106
+ const secret = process.env["SUPABASE_JWT_SECRET"];
107
+ if (!secret) {
108
+ console.warn("[supabase-jwt] SUPABASE_JWT_SECRET not set, cannot issue JWT");
109
+ return null;
110
+ }
111
+ // Verify the user exists and get their email for the JWT claims
112
+ const user = await getUserById(userId, cfg);
113
+ if (!user) {
114
+ console.warn(`[supabase-jwt] Cannot issue JWT: user ${userId} not found`);
115
+ return null;
116
+ }
117
+ return signJWT(userId, user.email, secret);
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // HS256 JWT signing — mirrors Supabase GoTrue's internal token structure
121
+ // ---------------------------------------------------------------------------
122
+ // The Go sidecar's ValidateSupabaseJWT accepts HS256 tokens signed with
123
+ // SUPABASE_JWT_SECRET. Tier/billing resolution happens via a live DB lookup
124
+ // in loadLiveBillingMetadata, so the JWT doesn't need billing_tier in
125
+ // app_metadata — sub (user_id) is sufficient.
126
+ function signJWT(userId, email, secret) {
127
+ const now = Math.floor(Date.now() / 1000);
128
+ const expiresIn = 3600; // 1 hour
129
+ const header = { alg: "HS256", typ: "JWT" };
130
+ const payload = {
131
+ aud: "authenticated",
132
+ exp: now + expiresIn,
133
+ iat: now,
134
+ nbf: now,
135
+ iss: process.env["SUPABASE_URL"]
136
+ ? `${process.env["SUPABASE_URL"]}/auth/v1`
137
+ : "https://mcp.openephemeris.com/auth/v1",
138
+ sub: userId,
139
+ email,
140
+ role: "authenticated",
141
+ session_id: `mcp_oauth_${Date.now()}`,
142
+ app_metadata: { provider: "mcp_oauth", providers: ["mcp_oauth"] },
143
+ user_metadata: {},
144
+ };
145
+ const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
146
+ const headerEncoded = b64url(header);
147
+ const payloadEncoded = b64url(payload);
148
+ const signingInput = `${headerEncoded}.${payloadEncoded}`;
149
+ const signature = createHmac("sha256", secret)
150
+ .update(signingInput)
151
+ .digest("base64url");
152
+ const accessToken = `${signingInput}.${signature}`;
153
+ const refreshToken = randomBytes(32).toString("hex");
154
+ console.info(`[supabase-jwt] HS256 JWT issued for user ${userId}`);
155
+ return {
156
+ access_token: accessToken,
157
+ refresh_token: refreshToken,
158
+ expires_in: expiresIn,
159
+ token_type: "bearer",
160
+ };
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Refresh a Supabase session (for grant_type=refresh_token flow)
164
+ // ---------------------------------------------------------------------------
165
+ // Note: Our self-signed JWTs use random hex refresh tokens, not real Supabase
166
+ // refresh tokens. This function handles the case where a real Supabase refresh
167
+ // token was obtained (e.g. from a future generate_link integration). For
168
+ // self-signed tokens, the token endpoint falls back to issueSupabaseJWT with
169
+ // the stored user_id instead of calling this.
170
+ export async function refreshSupabaseSession(refreshToken) {
171
+ const cfg = getConfig();
172
+ if (!cfg)
173
+ return null;
174
+ try {
175
+ const { status, data } = await adminRequest("POST", "/auth/v1/token?grant_type=refresh_token", { refresh_token: refreshToken }, cfg);
176
+ if (status < 200 || status >= 300) {
177
+ console.warn(`[supabase-jwt] refresh failed: status=${status}`);
178
+ return null;
179
+ }
180
+ const result = data;
181
+ if (!result.access_token?.startsWith("eyJ"))
182
+ return null;
183
+ return {
184
+ access_token: result.access_token,
185
+ refresh_token: result.refresh_token || "",
186
+ expires_in: result.expires_in || 3600,
187
+ token_type: "bearer",
188
+ };
189
+ }
190
+ catch (err) {
191
+ console.warn("[supabase-jwt] refresh error:", err.message);
192
+ return null;
193
+ }
194
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * src/oauth/token.ts — OAuth 2.1 Token endpoint.
3
+ *
4
+ * POST /oauth/token
5
+ *
6
+ * Supports:
7
+ * - grant_type=authorization_code (with PKCE S256 verification)
8
+ * - grant_type=refresh_token (with rotation enforcement)
9
+ *
10
+ * Token issuance strategy:
11
+ * 1. In-memory store validates code/PKCE/redirect_uri
12
+ * 2. If Supabase env vars are set AND the stored code has a user_id,
13
+ * issue a real Supabase JWT via supabase-jwt.ts
14
+ * 3. Otherwise fall back to local placeholder tokens (test path)
15
+ *
16
+ * The Supabase path is never attempted unless BOTH SUPABASE_URL and
17
+ * SUPABASE_SERVICE_ROLE_KEY are set. Tests run without either.
18
+ */
19
+ import express from "express";
20
+ import type { OAuthStore } from "./store.js";
21
+ export declare function oauthTokenRouter(store: OAuthStore): express.Router;