opencode-supabase 0.0.5 → 0.0.7-alpha.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/README.md CHANGED
@@ -20,6 +20,18 @@ Launch `opencode` in your project, then run:
20
20
 
21
21
  Connect your account and ask your agent about Supabase capabilities.
22
22
 
23
+ ## OAuth Callback Contract
24
+
25
+ Plugin uses fixed localhost callback window for browser auth:
26
+
27
+ - `http://localhost:14589/auth/callback`
28
+ - `http://localhost:14590/auth/callback`
29
+ - `http://localhost:14591/auth/callback`
30
+
31
+ Your Supabase OAuth app must allow all 3 redirect URIs.
32
+
33
+ Maintainer note: deployed OAuth app config must stay in sync with this fixed callback set. If callback ports change in code later, update OAuth app setup too.
34
+
23
35
  ## Debug Logging
24
36
 
25
37
  If you hit auth or tool errors and need logs for an issue, collect the newest OpenCode session log from its default log directory:
@@ -45,3 +57,17 @@ Then share that newest session log file in the issue. In our testing, the sessio
45
57
  ## Reference
46
58
 
47
59
  - Supabase Management API: https://supabase.com/docs/reference/api/introduction
60
+
61
+ ## Releasing
62
+
63
+ For user-visible or package-relevant changes, add a changeset in your PR:
64
+
65
+ ```bash
66
+ bun run changeset
67
+ ```
68
+
69
+ Commit the generated `.changeset/*.md` file with your code change.
70
+
71
+ Maintainers use a release PR workflow driven by Changesets. Internal-only changes can use the `no-changeset` label when appropriate.
72
+
73
+ See `docs/releasing.md` for the full maintainer runbook.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-supabase",
3
- "version": "0.0.5",
3
+ "version": "0.0.7-alpha.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Supabase integration with server and TUI components",
6
6
  "license": "Apache-2.0",
@@ -26,7 +26,10 @@
26
26
  "lint": "biome check .",
27
27
  "verify:pack": "npm pack --dry-run",
28
28
  "typecheck": "bunx tsc --noEmit",
29
- "test": "bun test"
29
+ "test": "bun test",
30
+ "changeset": "changeset",
31
+ "version-packages": "changeset version",
32
+ "release": "changeset publish"
30
33
  },
31
34
  "dependencies": {
32
35
  "@opencode-ai/plugin": "latest",
@@ -36,6 +39,7 @@
36
39
  },
37
40
  "devDependencies": {
38
41
  "@biomejs/biome": "^1.9.4",
42
+ "@changesets/cli": "^2.30.0",
39
43
  "@opentui/core": "0.1.95",
40
44
  "@opentui/solid": "0.1.95",
41
45
  "@types/bun": "latest",
@@ -15,6 +15,7 @@ import { writeSavedAuth } from "./store.ts";
15
15
 
16
16
  const CALLBACK_PATH = "/auth/callback";
17
17
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
18
+ const CALLBACK_PORTS = [14589, 14590, 14591] as const;
18
19
 
19
20
  type PendingAuth = {
20
21
  codeVerifier: string;
@@ -25,6 +26,7 @@ type PendingAuth = {
25
26
  };
26
27
 
27
28
  type AuthDeps = {
29
+ callbackPorts?: number[];
28
30
  fetch?: FetchLike;
29
31
  logger?: SupabaseLogger;
30
32
  setCallbackTimeout?: typeof setTimeout;
@@ -38,6 +40,13 @@ function callbackUrl(port: number) {
38
40
  return `http://localhost:${port}${CALLBACK_PATH}`;
39
41
  }
40
42
 
43
+ function normalizeCallbackPorts(ports: readonly number[]) {
44
+ if (ports.length === 0) {
45
+ throw new Error("Supabase callback ports must not be empty");
46
+ }
47
+ return [...ports];
48
+ }
49
+
41
50
  async function isPortInUse(port: number) {
42
51
  return new Promise<boolean>((resolve) => {
43
52
  const socket = createConnection(port, "localhost");
@@ -52,139 +61,183 @@ async function isPortInUse(port: number) {
52
61
  }
53
62
 
54
63
  async function ensureServer(
55
- port: number,
56
- config: ReturnType<typeof readSupabaseConfig>,
64
+ callbackPorts: readonly number[],
65
+ _config: ReturnType<typeof readSupabaseConfig>,
57
66
  input: Pick<PluginInput, "directory" | "worktree">,
58
67
  deps: AuthDeps,
59
68
  ) {
69
+ const candidatePorts = normalizeCallbackPorts(callbackPorts);
70
+
60
71
  if (server) {
61
- if (serverPort !== port) {
72
+ if (!serverPort || !candidatePorts.includes(serverPort)) {
62
73
  throw new Error(`Supabase callback server already running on port ${serverPort}`);
63
74
  }
64
- return;
65
- }
66
-
67
- if (await isPortInUse(port)) {
68
- throw new Error(`Supabase callback port ${port} is already in use`);
75
+ return serverPort;
69
76
  }
70
77
 
71
78
  const brokerConfig: BrokerConfig = {
72
- baseUrl: config.brokerBaseUrl,
79
+ baseUrl: _config.brokerBaseUrl,
73
80
  };
74
81
 
75
- await deps.logger?.info("supabase callback server started", {
76
- port,
77
- });
82
+ let selectedPort: number | undefined;
83
+ for (const port of candidatePorts) {
84
+ const portBusy = await isPortInUse(port);
85
+ await deps.logger?.debug("supabase callback port probe", {
86
+ port,
87
+ available: !portBusy,
88
+ });
89
+ if (!portBusy) {
90
+ try {
91
+ server = Bun.serve({
92
+ port,
93
+ async fetch(req) {
94
+ const url = new URL(req.url);
95
+ if (url.pathname !== CALLBACK_PATH) {
96
+ return new Response("Not found", { status: 404 });
97
+ }
98
+
99
+ const state = url.searchParams.get("state");
100
+ await deps.logger?.debug("supabase auth callback received", {
101
+ has_state: Boolean(state),
102
+ has_code: Boolean(url.searchParams.get("code")),
103
+ has_error: Boolean(url.searchParams.get("error")),
104
+ });
105
+ if (!state) {
106
+ return new Response(htmlError("Missing required state parameter - potential CSRF attack"), {
107
+ status: 400,
108
+ headers: { "Content-Type": "text/html" },
109
+ });
110
+ }
111
+
112
+ const pending = pendingAuths.get(state);
113
+ if (!pending) {
114
+ return new Response(htmlError("Invalid or expired state parameter - potential CSRF attack"), {
115
+ status: 400,
116
+ headers: { "Content-Type": "text/html" },
117
+ });
118
+ }
119
+
120
+ const error = url.searchParams.get("error");
121
+ const errorDescription = url.searchParams.get("error_description");
122
+ if (error) {
123
+ clearTimeout(pending.timeout);
124
+ pendingAuths.delete(state);
125
+ await deps.logger?.error("supabase auth failed", {
126
+ reason: "provider_denied",
127
+ });
128
+ pending.reject(new Error(errorDescription || error));
129
+ await stopServerIfIdle(deps.logger, "provider_denied");
130
+ return new Response(htmlError(errorDescription || error), {
131
+ headers: { "Content-Type": "text/html" },
132
+ });
133
+ }
134
+
135
+ const code = url.searchParams.get("code");
136
+ if (!code) {
137
+ clearTimeout(pending.timeout);
138
+ pendingAuths.delete(state);
139
+ await deps.logger?.error("supabase auth failed", {
140
+ reason: "missing_code",
141
+ });
142
+ pending.reject(new Error("Missing authorization code"));
143
+ await stopServerIfIdle(deps.logger, "missing_code");
144
+ return new Response(htmlError("Missing authorization code"), {
145
+ status: 400,
146
+ headers: { "Content-Type": "text/html" },
147
+ });
148
+ }
149
+
150
+ clearTimeout(pending.timeout);
151
+ pendingAuths.delete(state);
152
+
153
+ try {
154
+ const tokens = await exchangeCodeThroughBroker(
155
+ brokerConfig,
156
+ {
157
+ code,
158
+ redirect_uri: pending.redirectUri,
159
+ code_verifier: pending.codeVerifier,
160
+ },
161
+ deps.fetch,
162
+ deps.logger,
163
+ );
164
+
165
+ const expires = Date.now() + (tokens.expires_in || 3600) * 1000;
166
+ await writeSavedAuth(input, {
167
+ access: tokens.access_token,
168
+ refresh: tokens.refresh_token,
169
+ expires,
170
+ });
78
171
 
79
- server = Bun.serve({
80
- port,
81
- async fetch(req) {
82
- const url = new URL(req.url);
83
- if (url.pathname !== CALLBACK_PATH) {
84
- return new Response("Not found", { status: 404 });
85
- }
172
+ pending.resolve({ tokens, expires });
86
173
 
87
- const state = url.searchParams.get("state");
88
- await deps.logger?.debug("supabase auth callback received", {
89
- has_state: Boolean(state),
90
- has_code: Boolean(url.searchParams.get("code")),
91
- has_error: Boolean(url.searchParams.get("error")),
92
- });
93
- if (!state) {
94
- return new Response(htmlError("Missing required state parameter - potential CSRF attack"), {
95
- status: 400,
96
- headers: { "Content-Type": "text/html" },
97
- });
98
- }
174
+ await deps.logger?.info("supabase auth completed", {
175
+ status: "success",
176
+ });
99
177
 
100
- const pending = pendingAuths.get(state);
101
- if (!pending) {
102
- return new Response(htmlError("Invalid or expired state parameter - potential CSRF attack"), {
103
- status: 400,
104
- headers: { "Content-Type": "text/html" },
105
- });
106
- }
178
+ await stopServerIfIdle(deps.logger, "auth_completed");
107
179
 
108
- const error = url.searchParams.get("error");
109
- const errorDescription = url.searchParams.get("error_description");
110
- if (error) {
111
- clearTimeout(pending.timeout);
112
- pendingAuths.delete(state);
113
- await deps.logger?.error("supabase auth failed", {
114
- reason: "provider_denied",
115
- });
116
- pending.reject(new Error(errorDescription || error));
117
- return new Response(htmlError(errorDescription || error), {
118
- headers: { "Content-Type": "text/html" },
119
- });
120
- }
180
+ return new Response(HTML_SUCCESS, {
181
+ headers: { "Content-Type": "text/html" },
182
+ });
183
+ } catch (cause) {
184
+ const errorMessage = cause instanceof BrokerClientError
185
+ ? `Authorization failed: ${cause.message}`
186
+ : "Authorization failed";
121
187
 
122
- const code = url.searchParams.get("code");
123
- if (!code) {
124
- clearTimeout(pending.timeout);
125
- pendingAuths.delete(state);
126
- await deps.logger?.error("supabase auth failed", {
127
- reason: "missing_code",
128
- });
129
- pending.reject(new Error("Missing authorization code"));
130
- return new Response(htmlError("Missing authorization code"), {
131
- status: 400,
132
- headers: { "Content-Type": "text/html" },
133
- });
134
- }
188
+ await deps.logger?.error("supabase auth failed", {
189
+ status: cause instanceof BrokerClientError ? cause.status : 400,
190
+ broker_error: cause instanceof BrokerClientError,
191
+ });
135
192
 
136
- clearTimeout(pending.timeout);
137
- pendingAuths.delete(state);
193
+ pending.reject(cause instanceof Error ? cause : new Error(String(cause)));
194
+ await stopServerIfIdle(deps.logger, "broker_exchange_failed");
138
195
 
139
- try {
140
- const tokens = await exchangeCodeThroughBroker(
141
- brokerConfig,
142
- {
143
- code,
144
- redirect_uri: pending.redirectUri,
145
- code_verifier: pending.codeVerifier,
196
+ return new Response(htmlError(errorMessage), {
197
+ status: cause instanceof BrokerClientError && cause.status >= 500 ? 502 : 400,
198
+ headers: { "Content-Type": "text/html" },
199
+ });
200
+ }
146
201
  },
147
- deps.fetch,
148
- deps.logger,
149
- );
150
-
151
- const expires = Date.now() + (tokens.expires_in || 3600) * 1000;
152
- await writeSavedAuth(input, {
153
- access: tokens.access_token,
154
- refresh: tokens.refresh_token,
155
- expires,
156
202
  });
157
-
158
- pending.resolve({ tokens, expires });
159
-
160
- await deps.logger?.info("supabase auth completed", {
161
- status: "success",
162
- });
163
-
164
- return new Response(HTML_SUCCESS, {
165
- headers: { "Content-Type": "text/html" },
166
- });
167
- } catch (cause) {
168
- const errorMessage = cause instanceof BrokerClientError
169
- ? `Authorization failed: ${cause.message}`
170
- : "Authorization failed";
171
-
172
- await deps.logger?.error("supabase auth failed", {
173
- status: cause instanceof BrokerClientError ? cause.status : 400,
174
- broker_error: cause instanceof BrokerClientError,
203
+ selectedPort = port;
204
+ break;
205
+ } catch (error) {
206
+ await deps.logger?.warn("supabase callback server bind failed", {
207
+ port,
208
+ message: error instanceof Error ? error.message : String(error),
175
209
  });
210
+ }
211
+ }
212
+ }
176
213
 
177
- pending.reject(cause instanceof Error ? cause : new Error(String(cause)));
214
+ if (selectedPort === undefined) {
215
+ await deps.logger?.error("supabase callback port window exhausted", {
216
+ ports_tried: candidatePorts,
217
+ });
218
+ throw new Error(
219
+ `Supabase callback ports busy: ${candidatePorts.join(", ")}. Close other OpenCode sessions and retry.`,
220
+ );
221
+ }
178
222
 
179
- return new Response(htmlError(errorMessage), {
180
- status: cause instanceof BrokerClientError && cause.status >= 500 ? 502 : 400,
181
- headers: { "Content-Type": "text/html" },
182
- });
183
- }
184
- },
223
+ await deps.logger?.info("supabase callback server started", {
224
+ port: selectedPort,
185
225
  });
186
226
 
187
- serverPort = port;
227
+ serverPort = selectedPort;
228
+ return selectedPort;
229
+ }
230
+
231
+ async function stopServerIfIdle(logger?: SupabaseLogger, reason?: string) {
232
+ if (pendingAuths.size > 0 || !server) return;
233
+ const port = serverPort;
234
+ server.stop();
235
+ server = undefined;
236
+ serverPort = undefined;
237
+ await logger?.info("supabase callback server stopped", {
238
+ reason,
239
+ port,
240
+ });
188
241
  }
189
242
 
190
243
  function waitForCallback(
@@ -201,6 +254,7 @@ function waitForCallback(
201
254
  void deps.logger?.error("supabase auth callback timed out", {
202
255
  reason: "timeout",
203
256
  });
257
+ void stopServerIfIdle(deps.logger, "timeout");
204
258
  reject(new Error("OAuth callback timeout - authorization took too long"));
205
259
  }, CALLBACK_TIMEOUT_MS);
206
260
 
@@ -220,6 +274,7 @@ export function createSupabaseAuth(
220
274
  deps: AuthDeps = {},
221
275
  ) {
222
276
  const config = readSupabaseConfig(options);
277
+ const authCallbackPorts = normalizeCallbackPorts(deps.callbackPorts ?? CALLBACK_PORTS);
223
278
 
224
279
  return {
225
280
  provider: "supabase",
@@ -228,13 +283,13 @@ export function createSupabaseAuth(
228
283
  type: "oauth" as const,
229
284
  label: "Supabase",
230
285
  async authorize() {
231
- await ensureServer(config.oauthPort, config, input, deps);
286
+ const port = await ensureServer(authCallbackPorts, config, input, deps);
232
287
  await deps.logger?.info("supabase auth started", {
233
- port: config.oauthPort,
288
+ port,
234
289
  });
235
290
  const pkce = await generatePKCE();
236
291
  const state = generateState();
237
- const redirectUri = callbackUrl(config.oauthPort);
292
+ const redirectUri = callbackUrl(port);
238
293
  const callbackPromise = waitForCallback(state, pkce.verifier, redirectUri, deps);
239
294
 
240
295
  return {
package/src/shared/cfg.ts CHANGED
@@ -18,26 +18,6 @@ function readEnvString(value: string | undefined): string | undefined {
18
18
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
19
19
  }
20
20
 
21
- function readPortOption(options: PluginOptions | undefined, key: string) {
22
- const value = options?.[key];
23
- if (typeof value === "number") return value;
24
- if (typeof value === "string" && value.trim()) return value.trim();
25
- return undefined;
26
- }
27
-
28
- function requirePort(value: number | string | undefined) {
29
- if (value === undefined) {
30
- throw new Error("Missing required Supabase config: oauthPort");
31
- }
32
-
33
- const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
34
- if (!Number.isInteger(parsed) || parsed <= 0) {
35
- throw new Error("Invalid Supabase config: oauthPort must be a positive integer");
36
- }
37
-
38
- return parsed;
39
- }
40
-
41
21
  export function readSupabaseConfig(
42
22
  options: PluginOptions | undefined,
43
23
  env: SupabaseEnv = process.env,
@@ -46,11 +26,6 @@ export function readSupabaseConfig(
46
26
  readStringOption(options, "clientId") ??
47
27
  readEnvString(env.OPENCODE_SUPABASE_OAUTH_CLIENT_ID) ??
48
28
  DEFAULT_SUPABASE_OAUTH_CLIENT_ID;
49
- const oauthPort = requirePort(
50
- readPortOption(options, "oauthPort") ??
51
- env.OPENCODE_SUPABASE_OAUTH_PORT ??
52
- DEFAULT_SUPABASE_OAUTH_PORT,
53
- );
54
29
  const brokerBaseUrl =
55
30
  readStringOption(options, "brokerBaseUrl") ??
56
31
  readEnvString(env.OPENCODE_SUPABASE_BROKER_URL) ??
@@ -58,7 +33,7 @@ export function readSupabaseConfig(
58
33
 
59
34
  return {
60
35
  clientId,
61
- oauthPort,
36
+ oauthPort: DEFAULT_SUPABASE_OAUTH_PORT,
62
37
  authorizeUrl:
63
38
  readStringOption(options, "authorizeUrl") ??
64
39
  env.SUPABASE_OAUTH_AUTHORIZE_URL ??