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 +26 -0
- package/package.json +6 -2
- package/src/server/auth.ts +166 -111
- package/src/shared/cfg.ts +1 -26
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.
|
|
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",
|
package/src/server/auth.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
|
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:
|
|
79
|
+
baseUrl: _config.brokerBaseUrl,
|
|
73
80
|
};
|
|
74
81
|
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
137
|
-
|
|
193
|
+
pending.reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
194
|
+
await stopServerIfIdle(deps.logger, "broker_exchange_failed");
|
|
138
195
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
await deps.logger?.
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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 =
|
|
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(
|
|
286
|
+
const port = await ensureServer(authCallbackPorts, config, input, deps);
|
|
232
287
|
await deps.logger?.info("supabase auth started", {
|
|
233
|
-
port
|
|
288
|
+
port,
|
|
234
289
|
});
|
|
235
290
|
const pkce = await generatePKCE();
|
|
236
291
|
const state = generateState();
|
|
237
|
-
const redirectUri = callbackUrl(
|
|
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 ??
|