@openephemeris/mcp-server 3.13.9 → 3.13.10

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 (61) hide show
  1. package/README.md +2 -2
  2. package/config/dev-allowlist.json +28 -4
  3. package/dist/index.js +45 -0
  4. package/dist/oauth/dcr.d.ts +11 -0
  5. package/dist/oauth/dcr.js +49 -0
  6. package/dist/oauth/discovery.d.ts +11 -0
  7. package/dist/oauth/discovery.js +40 -0
  8. package/dist/oauth/pkce.d.ts +18 -0
  9. package/dist/oauth/pkce.js +34 -0
  10. package/dist/oauth/rate-limit.d.ts +20 -0
  11. package/dist/oauth/rate-limit.js +65 -0
  12. package/dist/oauth/store.d.ts +68 -0
  13. package/dist/oauth/store.js +247 -0
  14. package/dist/oauth/supabase-jwt.d.ts +39 -0
  15. package/dist/oauth/supabase-jwt.js +194 -0
  16. package/dist/oauth/token.d.ts +21 -0
  17. package/dist/oauth/token.js +213 -0
  18. package/dist/prompts.js +171 -159
  19. package/dist/server-sse.js +134 -36
  20. package/dist/tools/apps/bazi-app.d.ts +23 -0
  21. package/dist/tools/apps/bazi-app.js +224 -0
  22. package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
  23. package/dist/tools/apps/bi-wheel-app.js +293 -108
  24. package/dist/tools/apps/bodygraph-app.js +38 -3
  25. package/dist/tools/apps/moon-phase-app.d.ts +19 -0
  26. package/dist/tools/apps/moon-phase-app.js +190 -0
  27. package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
  28. package/dist/tools/apps/transit-timeline-app.js +243 -0
  29. package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
  30. package/dist/tools/apps/vedic-chart-app.js +226 -0
  31. package/dist/tools/index.js +4 -0
  32. package/dist/tools/specialized/acg.js +2 -2
  33. package/dist/tools/specialized/bazi.js +367 -44
  34. package/dist/tools/specialized/bi_wheel.js +1 -1
  35. package/dist/tools/specialized/chart_wheel.js +1 -1
  36. package/dist/tools/specialized/comparative.js +4 -4
  37. package/dist/tools/specialized/eclipse.js +1 -1
  38. package/dist/tools/specialized/electional.js +4 -4
  39. package/dist/tools/specialized/ephemeris_core.js +2 -2
  40. package/dist/tools/specialized/ephemeris_extended.js +8 -8
  41. package/dist/tools/specialized/hd_bodygraph.js +1 -1
  42. package/dist/tools/specialized/hd_cycles.js +2 -2
  43. package/dist/tools/specialized/hd_group.js +2 -2
  44. package/dist/tools/specialized/human_design.js +1 -1
  45. package/dist/tools/specialized/moon.js +2 -2
  46. package/dist/tools/specialized/natal.js +1 -1
  47. package/dist/tools/specialized/progressed.js +1 -1
  48. package/dist/tools/specialized/relocation.js +1 -1
  49. package/dist/tools/specialized/returns.js +3 -3
  50. package/dist/tools/specialized/synastry.js +1 -1
  51. package/dist/tools/specialized/transits.js +1 -1
  52. package/dist/tools/specialized/vedic.js +1 -1
  53. package/dist/tools/specialized/venus_star_points.js +6 -6
  54. package/dist/ui/bazi.html +213 -0
  55. package/dist/ui/bi-wheel.html +762 -489
  56. package/dist/ui/bodygraph.html +1788 -1649
  57. package/dist/ui/chart-wheel.html +1772 -1593
  58. package/dist/ui/moon-phase.html +6758 -0
  59. package/dist/ui/transit-timeline.html +6835 -0
  60. package/dist/ui/vedic-chart.html +210 -0
  61. package/package.json +2 -2
@@ -0,0 +1,247 @@
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: ["mcp:tools"],
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
+ // OAuthStore
108
+ // ---------------------------------------------------------------------------
109
+ export class OAuthStore {
110
+ /** In-memory is ALWAYS the hot-path source of truth */
111
+ codes = new Map();
112
+ refreshTokens = new Map();
113
+ cleanupTimer = null;
114
+ constructor() {
115
+ // Auto-cleanup expired codes every 60 seconds
116
+ this.cleanupTimer = setInterval(() => this.purgeExpired(), 60_000);
117
+ if (this.cleanupTimer.unref)
118
+ this.cleanupTimer.unref();
119
+ }
120
+ // ------------------------------------------------------------------
121
+ // Authorization codes
122
+ // ------------------------------------------------------------------
123
+ /**
124
+ * Issue a new authorization code.
125
+ *
126
+ * Always writes to in-memory (fast, test-safe).
127
+ * Also fire-and-forgets a Supabase persist when env vars are set.
128
+ */
129
+ issueCode(options) {
130
+ const code = generateAuthorizationCode();
131
+ const expiresInMs = options.expiresInMs ?? 600_000; // 10 min default
132
+ const expires_at = new Date(Date.now() + expiresInMs);
133
+ this.codes.set(code, { ...options, used: false, expires_at });
134
+ // Async durability write — non-blocking
135
+ persistCodeToSupabase(code, options, expires_at);
136
+ return code;
137
+ }
138
+ /**
139
+ * Consume a code synchronously from in-memory (single-use).
140
+ * This is the fast-path used by the token endpoint.
141
+ */
142
+ consumeCode(code) {
143
+ const stored = this.codes.get(code);
144
+ if (!stored)
145
+ return null;
146
+ if (stored.used)
147
+ return null;
148
+ if (stored.expires_at < new Date()) {
149
+ this.codes.delete(code);
150
+ return null;
151
+ }
152
+ stored.used = true;
153
+ return { ...stored };
154
+ }
155
+ /**
156
+ * Async consume — checks in-memory first, then Supabase.
157
+ *
158
+ * Use this from token.ts so production deployments with Supabase
159
+ * can survive process restarts (code issued before restart lives in DB).
160
+ */
161
+ async consumeCodeAsync(code) {
162
+ // Fast path: in-memory hit (covers 99% of cases)
163
+ const inMem = this.consumeCode(code);
164
+ if (inMem)
165
+ return inMem;
166
+ // Slow path: try Supabase (handles post-restart lookups)
167
+ const cfg = getSupabaseConfig();
168
+ if (!cfg)
169
+ return null;
170
+ try {
171
+ const selectPath = `/rest/v1/oauth_authorization_codes` +
172
+ `?code=eq.${encodeURIComponent(code)}` +
173
+ `&consumed=eq.false` +
174
+ `&expires_at=gt.${encodeURIComponent(new Date().toISOString())}` +
175
+ `&limit=1`;
176
+ const { status: selStatus, data: selData } = await postgrestRequest("GET", selectPath);
177
+ if (selStatus < 200 || selStatus >= 300)
178
+ return null;
179
+ const rows = Array.isArray(selData) ? selData : [];
180
+ if (rows.length === 0)
181
+ return null;
182
+ const row = rows[0];
183
+ // Optimistic lock: mark consumed=true WHERE consumed=false
184
+ const updatePath = `/rest/v1/oauth_authorization_codes?id=eq.${row.id}&consumed=eq.false`;
185
+ const { status: updStatus } = await postgrestRequest("PATCH", updatePath, {
186
+ consumed: true,
187
+ });
188
+ if (updStatus < 200 || updStatus >= 300) {
189
+ console.warn(`[store] Supabase consume mark failed id=${row.id} status=${updStatus}`);
190
+ return null;
191
+ }
192
+ console.info(`[store] code consumed from Supabase id=${row.id}`);
193
+ return {
194
+ client_id: row.client_id,
195
+ user_id: row.user_id,
196
+ redirect_uri: row.redirect_uri,
197
+ code_challenge: row.code_challenge,
198
+ code_challenge_method: row.code_challenge_method,
199
+ used: true,
200
+ expires_at: new Date(row.expires_at),
201
+ };
202
+ }
203
+ catch (err) {
204
+ console.warn("[store] Supabase consume error:", err.message);
205
+ return null;
206
+ }
207
+ }
208
+ // ------------------------------------------------------------------
209
+ // Refresh tokens (always in-memory — Supabase JWTs handle their own rotation)
210
+ // ------------------------------------------------------------------
211
+ storeRefreshToken(token, meta) {
212
+ this.refreshTokens.set(token, { ...meta, consumed: false });
213
+ }
214
+ consumeRefreshToken(token) {
215
+ const stored = this.refreshTokens.get(token);
216
+ if (!stored || stored.consumed)
217
+ return null;
218
+ stored.consumed = true;
219
+ return { ...stored };
220
+ }
221
+ generateRefreshToken() {
222
+ return randomBytes(32).toString("hex");
223
+ }
224
+ // ------------------------------------------------------------------
225
+ // Test utilities
226
+ // ------------------------------------------------------------------
227
+ reset() {
228
+ this.codes.clear();
229
+ this.refreshTokens.clear();
230
+ }
231
+ // ------------------------------------------------------------------
232
+ // Cleanup
233
+ // ------------------------------------------------------------------
234
+ purgeExpired() {
235
+ const now = new Date();
236
+ for (const [code, stored] of this.codes) {
237
+ if (stored.expires_at < now)
238
+ this.codes.delete(code);
239
+ }
240
+ }
241
+ destroy() {
242
+ if (this.cleanupTimer) {
243
+ clearInterval(this.cleanupTimer);
244
+ this.cleanupTimer = null;
245
+ }
246
+ }
247
+ }
@@ -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;