opencode-supabase 0.0.1

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,53 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+
5
+ type StoreInput = Pick<PluginInput, "directory" | "worktree">;
6
+
7
+ export type SavedAuth = {
8
+ access: string;
9
+ refresh: string;
10
+ expires: number;
11
+ };
12
+
13
+ export type SavedState = {
14
+ version: 1;
15
+ auth?: SavedAuth;
16
+ };
17
+
18
+ const STORE_FILE = "supabase-auth.json";
19
+
20
+ export function file(input: StoreInput): string {
21
+ const root = input.worktree || input.directory;
22
+ return join(root, ".opencode", STORE_FILE);
23
+ }
24
+
25
+ export async function read(input: StoreInput): Promise<SavedState> {
26
+ const authFile = Bun.file(file(input));
27
+ if (!(await authFile.exists())) {
28
+ return { version: 1 };
29
+ }
30
+
31
+ const parsed = JSON.parse(await authFile.text()) as SavedState;
32
+ if (parsed.version !== 1) {
33
+ throw new Error("Unsupported Supabase auth store version");
34
+ }
35
+ return parsed.auth ? { version: 1, auth: parsed.auth } : { version: 1 };
36
+ }
37
+
38
+ export async function write(input: StoreInput, auth: SavedAuth): Promise<void> {
39
+ const path = file(input);
40
+ await mkdir(dirname(path), { recursive: true });
41
+ await Bun.write(path, JSON.stringify({ version: 1, auth }, null, 2));
42
+ }
43
+
44
+ export async function clear(input: StoreInput): Promise<void> {
45
+ const path = file(input);
46
+ await mkdir(dirname(path), { recursive: true });
47
+ await Bun.write(path, JSON.stringify({ version: 1 }, null, 2));
48
+ }
49
+
50
+ export const getStoreFile = file;
51
+ export const readSavedAuth = read;
52
+ export const writeSavedAuth = write;
53
+ export const clearSavedAuth = clear;
@@ -0,0 +1,245 @@
1
+ import type { PluginOptions } from "@opencode-ai/plugin";
2
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
3
+ import { tool } from "@opencode-ai/plugin";
4
+
5
+ import {
6
+ BrokerClientError,
7
+ refreshTokenThroughBroker,
8
+ } from "../shared/broker.ts";
9
+ import { supabaseManagementApiFetch } from "../shared/api.ts";
10
+ import { readSupabaseConfig } from "../shared/cfg.ts";
11
+ import type { FetchLike } from "../shared/types.ts";
12
+ import { clearSavedAuth, readSavedAuth, writeSavedAuth, type SavedAuth } from "./store.ts";
13
+
14
+ type ToolDeps = {
15
+ fetch?: FetchLike;
16
+ };
17
+
18
+ type HostAuthWriter = {
19
+ set(input: {
20
+ path: { id: string };
21
+ query: { directory: string };
22
+ body: {
23
+ type: "oauth";
24
+ access: string;
25
+ refresh: string;
26
+ expires: number;
27
+ };
28
+ }): Promise<unknown>;
29
+ };
30
+
31
+ export type SupabaseToolInput = {
32
+ client: {
33
+ auth: HostAuthWriter;
34
+ };
35
+ directory: string;
36
+ serverUrl: URL;
37
+ worktree: string;
38
+ };
39
+
40
+ type SupabaseToolContext = Pick<
41
+ ToolContext,
42
+ "directory" | "worktree" | "abort" | "sessionID" | "messageID" | "agent" | "metadata" | "ask"
43
+ >;
44
+
45
+ const NOT_CONNECTED_MESSAGE = "Supabase is not connected. Run /supabase first.";
46
+ const REFRESH_BUFFER_MS = 30_000;
47
+
48
+ function isRefreshNeeded(auth: SavedAuth) {
49
+ return auth.expires <= Date.now() + REFRESH_BUFFER_MS;
50
+ }
51
+
52
+ function generateRandomString(length: number) {
53
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
54
+ return btoa(String.fromCharCode(...bytes))
55
+ .replace(/\+/g, "-")
56
+ .replace(/\//g, "_")
57
+ .replace(/=+$/, "")
58
+ .slice(0, length);
59
+ }
60
+
61
+ async function executeSupabaseRequest(
62
+ input: SupabaseToolInput,
63
+ options: PluginOptions | undefined,
64
+ deps: ToolDeps,
65
+ path: string,
66
+ errorLabel: string,
67
+ init?: RequestInit,
68
+ ) {
69
+ const config = readSupabaseConfig(options);
70
+ const auth = await ensureSupabaseToolAuth(input, options, deps);
71
+ const response = await supabaseManagementApiFetch(
72
+ config,
73
+ auth.access,
74
+ path,
75
+ init,
76
+ deps.fetch,
77
+ );
78
+
79
+ if (!response.ok) {
80
+ const body = await response.text().catch(() => "");
81
+ throw new Error(`Failed to ${errorLabel}: ${response.status} ${body}`.trim());
82
+ }
83
+
84
+ return JSON.stringify(await response.json(), null, 2);
85
+ }
86
+
87
+ async function executeSupabaseGet(
88
+ input: SupabaseToolInput,
89
+ options: PluginOptions | undefined,
90
+ deps: ToolDeps,
91
+ path: string,
92
+ errorLabel: string,
93
+ ) {
94
+ return executeSupabaseRequest(input, options, deps, path, errorLabel);
95
+ }
96
+
97
+ async function setHostAuth(
98
+ input: Pick<SupabaseToolInput, "client" | "directory">,
99
+ auth: SavedAuth,
100
+ ) {
101
+ await input.client.auth.set({
102
+ path: { id: "supabase" },
103
+ query: { directory: input.directory },
104
+ body: {
105
+ type: "oauth",
106
+ access: auth.access,
107
+ refresh: auth.refresh,
108
+ expires: auth.expires,
109
+ },
110
+ });
111
+ }
112
+
113
+ async function clearHostAuth(
114
+ input: Pick<SupabaseToolInput, "directory" | "serverUrl">,
115
+ fetchImpl: FetchLike,
116
+ ) {
117
+ const url = new URL(`/auth/supabase?directory=${encodeURIComponent(input.directory)}`, input.serverUrl);
118
+ const response = await fetchImpl(url.toString(), { method: "DELETE" });
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to clear host auth: ${response.status}`);
121
+ }
122
+ }
123
+
124
+ export async function ensureSupabaseToolAuth(
125
+ input: SupabaseToolInput,
126
+ options?: PluginOptions,
127
+ deps: ToolDeps = {},
128
+ ): Promise<SavedAuth> {
129
+ const fetchImpl = deps.fetch ?? fetch;
130
+ const saved = await readSavedAuth(input);
131
+ if (!saved.auth) {
132
+ throw new Error(NOT_CONNECTED_MESSAGE);
133
+ }
134
+
135
+ if (!isRefreshNeeded(saved.auth)) {
136
+ return saved.auth;
137
+ }
138
+
139
+ const config = readSupabaseConfig(options);
140
+
141
+ try {
142
+ const refreshed = await refreshTokenThroughBroker(
143
+ { baseUrl: config.brokerBaseUrl },
144
+ { refresh_token: saved.auth.refresh },
145
+ deps.fetch,
146
+ );
147
+
148
+ const nextAuth: SavedAuth = {
149
+ access: refreshed.access_token,
150
+ refresh: refreshed.refresh_token,
151
+ expires: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
152
+ };
153
+ await writeSavedAuth(input, nextAuth);
154
+ try {
155
+ await setHostAuth(input, nextAuth);
156
+ } catch {}
157
+ return nextAuth;
158
+ } catch (error) {
159
+ if (error instanceof BrokerClientError && (error.status === 401 || error.status === 400)) {
160
+ await clearSavedAuth(input);
161
+ try {
162
+ await clearHostAuth(input, fetchImpl);
163
+ } catch {}
164
+ throw new Error(NOT_CONNECTED_MESSAGE);
165
+ }
166
+
167
+ if (error instanceof BrokerClientError) {
168
+ throw new Error(`Supabase auth refresh failed: ${error.message}`);
169
+ }
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ export function createSupabaseTools(
175
+ input: SupabaseToolInput,
176
+ options?: PluginOptions,
177
+ deps: ToolDeps = {},
178
+ ) {
179
+ return {
180
+ supabase_list_organizations: tool({
181
+ description: "List all Supabase organizations for the authenticated user.",
182
+ args: {},
183
+ async execute(_args, _context: SupabaseToolContext) {
184
+ return executeSupabaseGet(input, options, deps, "/organizations", "list organizations");
185
+ },
186
+ }),
187
+ supabase_list_projects: tool({
188
+ description: "List all Supabase projects for the authenticated user.",
189
+ args: {},
190
+ async execute(_args, _context: SupabaseToolContext) {
191
+ return executeSupabaseGet(input, options, deps, "/projects", "list projects");
192
+ },
193
+ }),
194
+ supabase_get_project_api_keys: tool({
195
+ description: "Get the API keys for a Supabase project.",
196
+ args: {
197
+ project_ref: tool.schema.string().describe("Project reference ID"),
198
+ },
199
+ async execute(args, _context: SupabaseToolContext) {
200
+ return executeSupabaseGet(
201
+ input,
202
+ options,
203
+ deps,
204
+ `/projects/${args.project_ref}/api-keys`,
205
+ "get API keys",
206
+ );
207
+ },
208
+ }),
209
+ supabase_create_project: tool({
210
+ description: "Create a new Supabase project in an organization.",
211
+ args: {
212
+ organization_id: tool.schema.string().describe("Organization ID to create the project in"),
213
+ name: tool.schema.string().describe("Project name"),
214
+ region: tool.schema.string().describe("Database region").optional(),
215
+ db_pass: tool.schema.string().describe("Database password").optional(),
216
+ },
217
+ async execute(args, _context: SupabaseToolContext) {
218
+ return executeSupabaseRequest(
219
+ input,
220
+ options,
221
+ deps,
222
+ "/projects",
223
+ "create project",
224
+ {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify({
228
+ organization_id: args.organization_id,
229
+ name: args.name,
230
+ region: args.region ?? "us-east-1",
231
+ db_pass: args.db_pass ?? generateRandomString(32),
232
+ }),
233
+ },
234
+ );
235
+ },
236
+ }),
237
+ supabase_login: tool({
238
+ description: "Explain how to connect Supabase in the TUI.",
239
+ args: {},
240
+ async execute(_args, _context: SupabaseToolContext) {
241
+ return "Supabase login must be completed in the TUI. Run /supabase first.";
242
+ },
243
+ }),
244
+ };
245
+ }
@@ -0,0 +1,24 @@
1
+ export const DEFAULT_SUPABASE_OAUTH_AUTHORIZE_URL = "https://api.supabase.com/v1/oauth/authorize";
2
+ export const DEFAULT_SUPABASE_API_BASE_URL = "https://api.supabase.com/v1";
3
+ export const DEFAULT_SUPABASE_BROKER_URL = "https://iaoxncwzemnfxcdwakzb.supabase.co/functions/v1/opencode-supabase-broker";
4
+
5
+ import type { FetchLike, SupabaseSharedConfig } from "./types.ts";
6
+
7
+ export async function supabaseManagementApiFetch(
8
+ config: Pick<SupabaseSharedConfig, "apiBaseUrl">,
9
+ accessToken: string,
10
+ path: string,
11
+ init?: RequestInit,
12
+ fetchImpl: FetchLike = fetch,
13
+ ): Promise<Response> {
14
+ const url = `${config.apiBaseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
15
+
16
+ return fetchImpl(url, {
17
+ ...init,
18
+ headers: {
19
+ Accept: "application/json",
20
+ Authorization: `Bearer ${accessToken}`,
21
+ ...(init?.headers ?? {}),
22
+ },
23
+ });
24
+ }
@@ -0,0 +1,179 @@
1
+ import type { FetchLike, SupabaseTokenResponse } from "./types.ts";
2
+
3
+ export type BrokerConfig = {
4
+ baseUrl: string;
5
+ };
6
+
7
+ export type ExchangeRequest = {
8
+ code: string;
9
+ code_verifier: string;
10
+ redirect_uri: string;
11
+ };
12
+
13
+ export type RefreshRequest = {
14
+ refresh_token: string;
15
+ };
16
+
17
+ export type BrokerErrorCode =
18
+ | "invalid_request"
19
+ | "unauthorized"
20
+ | "rate_limited"
21
+ | "upstream_error"
22
+ | "server_error";
23
+
24
+ export type BrokerError = {
25
+ code: BrokerErrorCode;
26
+ message: string;
27
+ status: number;
28
+ };
29
+
30
+ export class BrokerClientError extends Error {
31
+ readonly code: BrokerErrorCode;
32
+ readonly status: number;
33
+
34
+ constructor(error: BrokerError) {
35
+ super(error.message);
36
+ this.name = "BrokerClientError";
37
+ this.code = error.code;
38
+ this.status = error.status;
39
+ }
40
+ }
41
+
42
+ function normalizeTokenResponse(payload: unknown): SupabaseTokenResponse {
43
+ if (!payload || typeof payload !== "object") {
44
+ throw new BrokerClientError({
45
+ code: "upstream_error",
46
+ message: "broker returned an invalid token response",
47
+ status: 502,
48
+ });
49
+ }
50
+
51
+ const record = payload as Record<string, unknown>;
52
+
53
+ if (typeof record.access_token !== "string" || record.access_token.length === 0) {
54
+ throw new BrokerClientError({
55
+ code: "upstream_error",
56
+ message: "broker returned an invalid token response",
57
+ status: 502,
58
+ });
59
+ }
60
+
61
+ if (typeof record.refresh_token !== "string" || record.refresh_token.length === 0) {
62
+ throw new BrokerClientError({
63
+ code: "upstream_error",
64
+ message: "broker returned an invalid token response",
65
+ status: 502,
66
+ });
67
+ }
68
+
69
+ if (typeof record.expires_in !== "number" || !Number.isFinite(record.expires_in)) {
70
+ throw new BrokerClientError({
71
+ code: "upstream_error",
72
+ message: "broker returned an invalid token response",
73
+ status: 502,
74
+ });
75
+ }
76
+
77
+ if (typeof record.token_type !== "string" || record.token_type.length === 0) {
78
+ throw new BrokerClientError({
79
+ code: "upstream_error",
80
+ message: "broker returned an invalid token response",
81
+ status: 502,
82
+ });
83
+ }
84
+
85
+ return {
86
+ access_token: record.access_token,
87
+ refresh_token: record.refresh_token,
88
+ expires_in: record.expires_in,
89
+ token_type: record.token_type,
90
+ };
91
+ }
92
+
93
+ async function makeBrokerRequest(
94
+ config: BrokerConfig,
95
+ endpoint: string,
96
+ body: unknown,
97
+ fetchImpl: FetchLike,
98
+ ): Promise<SupabaseTokenResponse> {
99
+ const url = `${config.baseUrl.replace(/\/$/, "")}${endpoint}`;
100
+
101
+ let response: Response;
102
+
103
+ try {
104
+ response = await fetchImpl(url, {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Accept: "application/json",
109
+ },
110
+ body: JSON.stringify(body),
111
+ });
112
+ } catch (cause) {
113
+ throw new BrokerClientError({
114
+ code: "upstream_error",
115
+ message: "broker request failed",
116
+ status: 502,
117
+ });
118
+ }
119
+
120
+ let payload: unknown;
121
+
122
+ try {
123
+ payload = await response.json();
124
+ } catch {
125
+ throw new BrokerClientError({
126
+ code: "upstream_error",
127
+ message: "broker returned an invalid response",
128
+ status: 502,
129
+ });
130
+ }
131
+
132
+ if (!response.ok) {
133
+ const errorBody = payload as Record<string, unknown> | undefined;
134
+ const error = errorBody?.error as Record<string, unknown> | undefined;
135
+
136
+ const code = (error?.code as BrokerErrorCode) || "upstream_error";
137
+ const message = (error?.message as string) || "broker request failed";
138
+
139
+ throw new BrokerClientError({
140
+ code,
141
+ message,
142
+ status: response.status,
143
+ });
144
+ }
145
+
146
+ return normalizeTokenResponse(payload);
147
+ }
148
+
149
+ export async function exchangeCodeThroughBroker(
150
+ config: BrokerConfig,
151
+ input: ExchangeRequest,
152
+ fetchImpl: FetchLike = fetch,
153
+ ): Promise<SupabaseTokenResponse> {
154
+ return makeBrokerRequest(
155
+ config,
156
+ "/exchange",
157
+ {
158
+ code: input.code,
159
+ code_verifier: input.code_verifier,
160
+ redirect_uri: input.redirect_uri,
161
+ },
162
+ fetchImpl,
163
+ );
164
+ }
165
+
166
+ export async function refreshTokenThroughBroker(
167
+ config: BrokerConfig,
168
+ input: RefreshRequest,
169
+ fetchImpl: FetchLike = fetch,
170
+ ): Promise<SupabaseTokenResponse> {
171
+ return makeBrokerRequest(
172
+ config,
173
+ "/refresh",
174
+ {
175
+ refresh_token: input.refresh_token,
176
+ },
177
+ fetchImpl,
178
+ );
179
+ }
@@ -0,0 +1,71 @@
1
+ import type { PluginOptions } from "@opencode-ai/plugin";
2
+
3
+ import {
4
+ DEFAULT_SUPABASE_API_BASE_URL,
5
+ DEFAULT_SUPABASE_BROKER_URL,
6
+ DEFAULT_SUPABASE_OAUTH_AUTHORIZE_URL,
7
+ } from "./api.ts";
8
+ import type { SupabaseEnv, SupabaseSharedConfig } from "./types.ts";
9
+
10
+ function readStringOption(options: PluginOptions | undefined, key: string) {
11
+ const value = options?.[key];
12
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
13
+ }
14
+
15
+ function readEnvString(value: string | undefined): string | undefined {
16
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
17
+ }
18
+
19
+ function readPortOption(options: PluginOptions | undefined, key: string) {
20
+ const value = options?.[key];
21
+ if (typeof value === "number") return value;
22
+ if (typeof value === "string" && value.trim()) return value.trim();
23
+ return undefined;
24
+ }
25
+
26
+ function requireString(value: string | undefined, key: string) {
27
+ if (!value) {
28
+ throw new Error(`Missing required Supabase config: ${key}`);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function requirePort(value: number | string | undefined) {
34
+ if (value === undefined) {
35
+ throw new Error("Missing required Supabase config: oauthPort");
36
+ }
37
+
38
+ const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
39
+ if (!Number.isInteger(parsed) || parsed <= 0) {
40
+ throw new Error("Invalid Supabase config: oauthPort must be a positive integer");
41
+ }
42
+
43
+ return parsed;
44
+ }
45
+
46
+ export function readSupabaseConfig(
47
+ options: PluginOptions | undefined,
48
+ env: SupabaseEnv = process.env,
49
+ ): SupabaseSharedConfig {
50
+ const clientId = requireString(
51
+ readStringOption(options, "clientId") ?? env.OPENCODE_SUPABASE_OAUTH_CLIENT_ID,
52
+ "clientId",
53
+ );
54
+ const oauthPort = requirePort(
55
+ readPortOption(options, "oauthPort") ?? env.OPENCODE_SUPABASE_OAUTH_PORT,
56
+ );
57
+ const brokerBaseUrl =
58
+ readStringOption(options, "brokerBaseUrl") ?? readEnvString(env.OPENCODE_SUPABASE_BROKER_URL) ?? DEFAULT_SUPABASE_BROKER_URL;
59
+
60
+ return {
61
+ clientId,
62
+ oauthPort,
63
+ authorizeUrl:
64
+ readStringOption(options, "authorizeUrl") ??
65
+ env.SUPABASE_OAUTH_AUTHORIZE_URL ??
66
+ DEFAULT_SUPABASE_OAUTH_AUTHORIZE_URL,
67
+ brokerBaseUrl,
68
+ apiBaseUrl:
69
+ readStringOption(options, "apiBaseUrl") ?? env.SUPABASE_API_BASE_URL ?? DEFAULT_SUPABASE_API_BASE_URL,
70
+ };
71
+ }
@@ -0,0 +1,48 @@
1
+ import type { PkceCodes, SupabaseSharedConfig } from "./types.ts";
2
+
3
+ const PKCE_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
4
+
5
+ export async function generatePKCE(): Promise<PkceCodes> {
6
+ const verifier = generateRandomString(43);
7
+ const encoder = new TextEncoder();
8
+ const hash = await crypto.subtle.digest("SHA-256", encoder.encode(verifier));
9
+ return {
10
+ verifier,
11
+ challenge: base64UrlEncode(hash),
12
+ };
13
+ }
14
+
15
+ export function generateRandomString(length: number): string {
16
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
17
+ return Array.from(bytes)
18
+ .map((value) => PKCE_CHARSET[value % PKCE_CHARSET.length])
19
+ .join("");
20
+ }
21
+
22
+ export function base64UrlEncode(buffer: ArrayBuffer): string {
23
+ const bytes = new Uint8Array(buffer);
24
+ const binary = String.fromCharCode(...bytes);
25
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26
+ }
27
+
28
+ export function generateState(): string {
29
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
30
+ }
31
+
32
+ export function buildAuthorizeUrl(
33
+ config: Pick<SupabaseSharedConfig, "authorizeUrl" | "clientId">,
34
+ redirectUri: string,
35
+ pkce: PkceCodes,
36
+ state: string,
37
+ ): string {
38
+ const params = new URLSearchParams({
39
+ response_type: "code",
40
+ client_id: config.clientId,
41
+ redirect_uri: redirectUri,
42
+ code_challenge: pkce.challenge,
43
+ code_challenge_method: "S256",
44
+ state,
45
+ });
46
+
47
+ return `${config.authorizeUrl}?${params.toString()}`;
48
+ }
@@ -0,0 +1,35 @@
1
+ import type { PluginOptions } from "@opencode-ai/plugin";
2
+
3
+ export type SupabaseEnv = Record<string, string | undefined>;
4
+
5
+ export type SupabaseConfigSource = PluginOptions | undefined;
6
+
7
+ export type SupabaseSharedConfig = {
8
+ clientId: string;
9
+ oauthPort: number;
10
+ authorizeUrl: string;
11
+ brokerBaseUrl: string;
12
+ apiBaseUrl: string;
13
+ };
14
+
15
+ export type PkceCodes = {
16
+ verifier: string;
17
+ challenge: string;
18
+ };
19
+
20
+ export type SupabaseTokenResponse = {
21
+ access_token: string;
22
+ refresh_token: string;
23
+ expires_in?: number;
24
+ token_type?: string;
25
+ id_token?: string;
26
+ scope?: string;
27
+ };
28
+
29
+ export type TokenExchangeInput = {
30
+ code: string;
31
+ redirectUri: string;
32
+ codeVerifier: string;
33
+ };
34
+
35
+ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
@@ -0,0 +1,8 @@
1
+ export function createSupabaseCommand(openDialog: () => void) {
2
+ return {
3
+ title: "Connect Supabase",
4
+ value: "supabase.connect",
5
+ slash: { name: "supabase" },
6
+ onSelect: openDialog,
7
+ };
8
+ }