opencode-supabase 0.2.0 → 0.2.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.
- package/package.json +1 -1
- package/src/server/auth.ts +10 -4
- package/src/server/store.ts +272 -13
- package/src/server/tools.ts +63 -16
- package/src/tui/dialog.tsx +79 -2
package/package.json
CHANGED
package/src/server/auth.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { SupabaseLogger } from "../shared/log.ts";
|
|
|
12
12
|
import { buildAuthorizeUrl, generatePKCE, generateState } from "../shared/oauth.ts";
|
|
13
13
|
import type { FetchLike, SupabaseTokenResponse } from "../shared/types.ts";
|
|
14
14
|
import { HTML_SUCCESS, htmlError } from "./auth-html.ts";
|
|
15
|
+
import type { SavedStateNotice } from "./store.ts";
|
|
15
16
|
import { readSavedAuth, writeSavedAuth } from "./store.ts";
|
|
16
17
|
import { NOT_CONNECTED_MESSAGE, disconnectSupabaseAuth, ensureSupabaseToolAuth } from "./tools.ts";
|
|
17
18
|
|
|
@@ -44,6 +45,7 @@ type SupabaseStatusInstructions =
|
|
|
44
45
|
| {
|
|
45
46
|
status: "disconnected";
|
|
46
47
|
checked: false;
|
|
48
|
+
notice?: SavedStateNotice;
|
|
47
49
|
}
|
|
48
50
|
| {
|
|
49
51
|
status: "refresh_required";
|
|
@@ -63,10 +65,14 @@ function encodeStatusInstructions(status: SupabaseStatusInstructions) {
|
|
|
63
65
|
return JSON.stringify(status);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
async function getStatusInstructions(input: Pick<SupabaseAuthInput, "directory" | "worktree"
|
|
67
|
-
const saved = await readSavedAuth(input);
|
|
68
|
+
async function getStatusInstructions(input: Pick<SupabaseAuthInput, "directory" | "worktree">, deps: AuthDeps = {}) {
|
|
69
|
+
const saved = await readSavedAuth(input, { logger: deps.logger });
|
|
68
70
|
if (!saved.auth) {
|
|
69
|
-
return encodeStatusInstructions(
|
|
71
|
+
return encodeStatusInstructions(
|
|
72
|
+
saved.notice
|
|
73
|
+
? { status: "disconnected", checked: false, notice: saved.notice }
|
|
74
|
+
: { status: "disconnected", checked: false },
|
|
75
|
+
);
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
if (!isRefreshNeeded(saved.auth.expires)) {
|
|
@@ -360,7 +366,7 @@ export function createSupabaseAuth(
|
|
|
360
366
|
};
|
|
361
367
|
}
|
|
362
368
|
|
|
363
|
-
const instructions = await getStatusInstructions(input);
|
|
369
|
+
const instructions = await getStatusInstructions(input, deps);
|
|
364
370
|
const status = JSON.parse(instructions) as SupabaseStatusInstructions;
|
|
365
371
|
|
|
366
372
|
if (status.status !== "refresh_required") {
|
package/src/server/store.ts
CHANGED
|
@@ -1,21 +1,274 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, rename, unlink } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
4
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
4
5
|
|
|
6
|
+
import type { SupabaseLogger } from "../shared/log.ts";
|
|
7
|
+
|
|
5
8
|
type StoreInput = Pick<PluginInput, "directory" | "worktree">;
|
|
6
9
|
|
|
10
|
+
type StoreDeps = {
|
|
11
|
+
now?: () => Date;
|
|
12
|
+
logger?: Pick<SupabaseLogger, "warn">;
|
|
13
|
+
};
|
|
14
|
+
|
|
7
15
|
export type SavedAuth = {
|
|
8
16
|
access: string;
|
|
9
17
|
refresh: string;
|
|
10
18
|
expires: number;
|
|
11
19
|
};
|
|
12
20
|
|
|
21
|
+
export type SavedStateNotice = {
|
|
22
|
+
type: "auth_store_reset";
|
|
23
|
+
message: string;
|
|
24
|
+
backupPath: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
13
27
|
export type SavedState = {
|
|
14
28
|
version: 1;
|
|
15
29
|
auth?: SavedAuth;
|
|
30
|
+
notice?: SavedStateNotice;
|
|
16
31
|
};
|
|
17
32
|
|
|
18
33
|
const STORE_FILE = "supabase-auth.json";
|
|
34
|
+
const AUTH_STORE_RESET_MESSAGE = "Supabase auth was reset because the local auth store was corrupted. Reconnect to continue.";
|
|
35
|
+
const RECOVERY_LOCK_SUFFIX = ".recovering.lock";
|
|
36
|
+
const RECOVERY_MAX_WAIT_MS = 5000;
|
|
37
|
+
const RECOVERY_POLL_MS = 50;
|
|
38
|
+
const RECOVERY_LOCK_STALE_MS = 10_000;
|
|
39
|
+
const inFlightRecoveries = new Map<string, Promise<SavedState>>();
|
|
40
|
+
|
|
41
|
+
type RecoveryLockMetadata = {
|
|
42
|
+
startedAt?: number;
|
|
43
|
+
token?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type RecoveryLock = {
|
|
47
|
+
fd: import("node:fs/promises").FileHandle;
|
|
48
|
+
token: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
async function readLockMetadata(lockPath: string): Promise<RecoveryLockMetadata> {
|
|
52
|
+
try {
|
|
53
|
+
const text = await Bun.file(lockPath).text();
|
|
54
|
+
return JSON.parse(text) as { startedAt?: number };
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function isStaleLock(lockPath: string): Promise<boolean> {
|
|
61
|
+
const metadata = await readLockMetadata(lockPath);
|
|
62
|
+
if (typeof metadata.startedAt !== "number" || !Number.isFinite(metadata.startedAt)) return true;
|
|
63
|
+
return Date.now() - metadata.startedAt > RECOVERY_LOCK_STALE_MS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeAuth(value: unknown): SavedAuth | undefined {
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!isRecord(value)) {
|
|
76
|
+
throw new Error("Invalid Supabase auth store auth shape");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
typeof value.access !== "string" ||
|
|
81
|
+
typeof value.refresh !== "string" ||
|
|
82
|
+
typeof value.expires !== "number" ||
|
|
83
|
+
!Number.isFinite(value.expires)
|
|
84
|
+
) {
|
|
85
|
+
throw new Error("Invalid Supabase auth store auth shape");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
access: value.access,
|
|
90
|
+
refresh: value.refresh,
|
|
91
|
+
expires: value.expires,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeNotice(value: unknown): SavedStateNotice | undefined {
|
|
96
|
+
if (value === undefined) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!isRecord(value)) {
|
|
101
|
+
throw new Error("Invalid Supabase auth store notice shape");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
value.type !== "auth_store_reset" ||
|
|
106
|
+
typeof value.message !== "string" ||
|
|
107
|
+
typeof value.backupPath !== "string"
|
|
108
|
+
) {
|
|
109
|
+
throw new Error("Invalid Supabase auth store notice shape");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
type: "auth_store_reset",
|
|
114
|
+
message: value.message,
|
|
115
|
+
backupPath: value.backupPath,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeState(value: unknown): SavedState {
|
|
120
|
+
if (!isRecord(value)) {
|
|
121
|
+
throw new Error("Invalid Supabase auth store shape");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (value.version !== 1) {
|
|
125
|
+
throw new Error("Unsupported Supabase auth store version");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const auth = normalizeAuth(value.auth);
|
|
129
|
+
const notice = normalizeNotice(value.notice);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
version: 1,
|
|
133
|
+
...(auth ? { auth } : {}),
|
|
134
|
+
...(notice ? { notice } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function backupFile(path: string, now: () => Date) {
|
|
139
|
+
const timestamp = now().toISOString().replace(/[:.]/g, "-");
|
|
140
|
+
let candidate = join(dirname(path), `supabase-auth.corrupt-${timestamp}.json`);
|
|
141
|
+
let counter = 1;
|
|
142
|
+
while (await Bun.file(candidate).exists()) {
|
|
143
|
+
candidate = join(dirname(path), `supabase-auth.corrupt-${timestamp}-${counter}.json`);
|
|
144
|
+
counter++;
|
|
145
|
+
}
|
|
146
|
+
return candidate;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function writeState(path: string, state: SavedState): Promise<void> {
|
|
150
|
+
await mkdir(dirname(path), { recursive: true });
|
|
151
|
+
await Bun.write(path, JSON.stringify(state, null, 2));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function acquireRecoveryLock(lockPath: string): Promise<RecoveryLock | undefined> {
|
|
155
|
+
async function tryCreate(): Promise<RecoveryLock | undefined> {
|
|
156
|
+
try {
|
|
157
|
+
const fd = await open(lockPath, "wx");
|
|
158
|
+
const token = randomUUID();
|
|
159
|
+
const metadata = JSON.stringify({ startedAt: Date.now(), token });
|
|
160
|
+
await fd.write(metadata, 0, "utf8");
|
|
161
|
+
return { fd, token };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lock = await tryCreate();
|
|
171
|
+
if (lock) return lock;
|
|
172
|
+
|
|
173
|
+
// Lock exists; check if stale and take over if so.
|
|
174
|
+
if (await isStaleLock(lockPath)) {
|
|
175
|
+
try {
|
|
176
|
+
await unlink(lockPath);
|
|
177
|
+
} catch {
|
|
178
|
+
// Another process may have already removed it.
|
|
179
|
+
}
|
|
180
|
+
return tryCreate();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function releaseRecoveryLock(lock: RecoveryLock, lockPath: string): Promise<void> {
|
|
187
|
+
try {
|
|
188
|
+
await lock.fd.close();
|
|
189
|
+
} catch {}
|
|
190
|
+
try {
|
|
191
|
+
const metadata = await readLockMetadata(lockPath);
|
|
192
|
+
if (metadata.token === lock.token) {
|
|
193
|
+
await unlink(lockPath);
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function waitForRecoveredState(path: string, deps: StoreDeps): Promise<SavedState> {
|
|
199
|
+
const startedAt = Date.now();
|
|
200
|
+
while (Date.now() - startedAt < RECOVERY_MAX_WAIT_MS) {
|
|
201
|
+
try {
|
|
202
|
+
const text = await Bun.file(path).text();
|
|
203
|
+
return normalizeState(JSON.parse(text));
|
|
204
|
+
} catch {
|
|
205
|
+
// not ready yet
|
|
206
|
+
}
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, RECOVERY_POLL_MS));
|
|
208
|
+
}
|
|
209
|
+
throw new Error("Supabase auth store recovery timed out waiting for another process");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function recoverCorruptStoreOnce(path: string, error: unknown, deps: StoreDeps): Promise<SavedState> {
|
|
213
|
+
const existing = inFlightRecoveries.get(path);
|
|
214
|
+
if (existing) return existing;
|
|
215
|
+
|
|
216
|
+
const recovery = (async () => {
|
|
217
|
+
const lockPath = path + RECOVERY_LOCK_SUFFIX;
|
|
218
|
+
const lock = await acquireRecoveryLock(lockPath);
|
|
219
|
+
if (!lock) {
|
|
220
|
+
return waitForRecoveredState(path, deps);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Re-read under lock in case another process already fixed it.
|
|
225
|
+
try {
|
|
226
|
+
const text = await Bun.file(path).text();
|
|
227
|
+
const state = normalizeState(JSON.parse(text));
|
|
228
|
+
return state;
|
|
229
|
+
} catch {
|
|
230
|
+
// still corrupt or missing
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const backupPath = await backupFile(path, deps.now ?? (() => new Date()));
|
|
234
|
+
const state: SavedState = {
|
|
235
|
+
version: 1,
|
|
236
|
+
notice: {
|
|
237
|
+
type: "auth_store_reset",
|
|
238
|
+
message: AUTH_STORE_RESET_MESSAGE,
|
|
239
|
+
backupPath,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
await mkdir(dirname(path), { recursive: true });
|
|
244
|
+
try {
|
|
245
|
+
await rename(path, backupPath);
|
|
246
|
+
} catch (renameError) {
|
|
247
|
+
if ((renameError as NodeJS.ErrnoException).code === "ENOENT") {
|
|
248
|
+
// Store disappeared before we could back it up; write clean state directly.
|
|
249
|
+
await writeState(path, { version: 1 });
|
|
250
|
+
return { version: 1 } as SavedState;
|
|
251
|
+
}
|
|
252
|
+
throw renameError;
|
|
253
|
+
}
|
|
254
|
+
await writeState(path, state);
|
|
255
|
+
await deps.logger?.warn("supabase auth store reset", {
|
|
256
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
257
|
+
path,
|
|
258
|
+
backupPath,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return state;
|
|
262
|
+
} finally {
|
|
263
|
+
await releaseRecoveryLock(lock, lockPath);
|
|
264
|
+
}
|
|
265
|
+
})().finally(() => {
|
|
266
|
+
inFlightRecoveries.delete(path);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
inFlightRecoveries.set(path, recovery);
|
|
270
|
+
return recovery;
|
|
271
|
+
}
|
|
19
272
|
|
|
20
273
|
// Use worktree only when it is non-root and directory is equal to or inside it;
|
|
21
274
|
// otherwise fall back to the session directory.
|
|
@@ -46,32 +299,38 @@ export function file(input: StoreInput): string {
|
|
|
46
299
|
return join(root, ".opencode", STORE_FILE);
|
|
47
300
|
}
|
|
48
301
|
|
|
49
|
-
export async function read(input: StoreInput): Promise<SavedState> {
|
|
50
|
-
const
|
|
302
|
+
export async function read(input: StoreInput, deps: StoreDeps = {}): Promise<SavedState> {
|
|
303
|
+
const path = file(input);
|
|
304
|
+
const authFile = Bun.file(path);
|
|
51
305
|
if (!(await authFile.exists())) {
|
|
306
|
+
const lockPath = path + RECOVERY_LOCK_SUFFIX;
|
|
307
|
+
if ((await Bun.file(lockPath).exists()) && !(await isStaleLock(lockPath))) {
|
|
308
|
+
return waitForRecoveredState(path, deps);
|
|
309
|
+
}
|
|
52
310
|
return { version: 1 };
|
|
53
311
|
}
|
|
54
312
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
313
|
+
try {
|
|
314
|
+
return normalizeState(JSON.parse(await authFile.text()));
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return recoverCorruptStoreOnce(path, error, deps);
|
|
58
317
|
}
|
|
59
|
-
return parsed.auth ? { version: 1, auth: parsed.auth } : { version: 1 };
|
|
60
318
|
}
|
|
61
319
|
|
|
62
320
|
export async function write(input: StoreInput, auth: SavedAuth): Promise<void> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
321
|
+
await writeState(file(input), { version: 1, auth });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function writeNotice(input: StoreInput, notice: SavedStateNotice): Promise<void> {
|
|
325
|
+
await writeState(file(input), { version: 1, notice });
|
|
66
326
|
}
|
|
67
327
|
|
|
68
328
|
export async function clear(input: StoreInput): Promise<void> {
|
|
69
|
-
|
|
70
|
-
await mkdir(dirname(path), { recursive: true });
|
|
71
|
-
await Bun.write(path, JSON.stringify({ version: 1 }, null, 2));
|
|
329
|
+
await writeState(file(input), { version: 1 });
|
|
72
330
|
}
|
|
73
331
|
|
|
74
332
|
export const getStoreFile = file;
|
|
75
333
|
export const readSavedAuth = read;
|
|
76
334
|
export const writeSavedAuth = write;
|
|
335
|
+
export const writeSavedAuthNotice = writeNotice;
|
|
77
336
|
export const clearSavedAuth = clear;
|
package/src/server/tools.ts
CHANGED
|
@@ -10,11 +10,19 @@ import {
|
|
|
10
10
|
import { readSupabaseConfig } from "../shared/cfg.ts";
|
|
11
11
|
import type { SupabaseLogger } from "../shared/log.ts";
|
|
12
12
|
import type { FetchLike } from "../shared/types.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type SavedAuth,
|
|
15
|
+
type SavedStateNotice,
|
|
16
|
+
clearSavedAuth,
|
|
17
|
+
getStoreFile,
|
|
18
|
+
readSavedAuth,
|
|
19
|
+
writeSavedAuth,
|
|
20
|
+
} from "./store.ts";
|
|
14
21
|
|
|
15
22
|
type ToolDeps = {
|
|
16
23
|
fetch?: FetchLike;
|
|
17
24
|
logger?: SupabaseLogger;
|
|
25
|
+
now?: () => Date;
|
|
18
26
|
};
|
|
19
27
|
|
|
20
28
|
type InFlightRefresh = {
|
|
@@ -59,6 +67,7 @@ export type SupabaseAuthStatus =
|
|
|
59
67
|
| {
|
|
60
68
|
status: "disconnected";
|
|
61
69
|
checked: boolean;
|
|
70
|
+
notice?: SavedStateNotice;
|
|
62
71
|
}
|
|
63
72
|
| {
|
|
64
73
|
status: "unknown";
|
|
@@ -70,6 +79,18 @@ export const NOT_CONNECTED_MESSAGE = "Supabase is not connected. Run /supabase f
|
|
|
70
79
|
const REFRESH_BUFFER_MS = 30_000;
|
|
71
80
|
const inFlightRefreshes = new Map<string, InFlightRefresh>();
|
|
72
81
|
|
|
82
|
+
function formatAuthNoticeForTool(notice: SavedStateNotice) {
|
|
83
|
+
return `${notice.message.replace(". Reconnect to continue.", ".")}\n\nThe corrupted file was preserved here:\n${notice.backupPath}\n\nRun /supabase to reconnect, then retry this tool.`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function throwAuthNotice(input: SupabaseToolInput, notice: SavedStateNotice, deps: ToolDeps): Promise<never> {
|
|
87
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
88
|
+
try {
|
|
89
|
+
await clearHostAuth(input, fetchImpl);
|
|
90
|
+
} catch {}
|
|
91
|
+
throw new Error(formatAuthNoticeForTool(notice));
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
function isRefreshNeeded(auth: SavedAuth) {
|
|
74
95
|
return auth.expires <= Date.now() + REFRESH_BUFFER_MS;
|
|
75
96
|
}
|
|
@@ -236,9 +257,11 @@ export async function getSupabaseAuthStatus(
|
|
|
236
257
|
options?: PluginOptions,
|
|
237
258
|
deps: ToolDeps = {},
|
|
238
259
|
): Promise<SupabaseAuthStatus> {
|
|
239
|
-
const saved = await readSavedAuth(input);
|
|
260
|
+
const saved = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
240
261
|
if (!saved.auth) {
|
|
241
|
-
return
|
|
262
|
+
return saved.notice
|
|
263
|
+
? { status: "disconnected", checked: false, notice: saved.notice }
|
|
264
|
+
: { status: "disconnected", checked: false };
|
|
242
265
|
}
|
|
243
266
|
|
|
244
267
|
if (!isRefreshNeeded(saved.auth)) {
|
|
@@ -249,6 +272,11 @@ export async function getSupabaseAuthStatus(
|
|
|
249
272
|
const auth = await ensureSupabaseToolAuth(input, options, deps);
|
|
250
273
|
return { status: "connected", auth, checked: true };
|
|
251
274
|
} catch (error) {
|
|
275
|
+
const latest = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
276
|
+
if (!latest.auth && latest.notice) {
|
|
277
|
+
return { status: "disconnected", checked: true, notice: latest.notice };
|
|
278
|
+
}
|
|
279
|
+
|
|
252
280
|
const message = error instanceof Error ? error.message : String(error);
|
|
253
281
|
if (message === NOT_CONNECTED_MESSAGE) {
|
|
254
282
|
return { status: "disconnected", checked: true };
|
|
@@ -264,8 +292,11 @@ export async function ensureSupabaseToolAuth(
|
|
|
264
292
|
deps: ToolDeps = {},
|
|
265
293
|
): Promise<SavedAuth> {
|
|
266
294
|
const refreshKey = getStoreFile(input);
|
|
267
|
-
const saved = await readSavedAuth(input);
|
|
295
|
+
const saved = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
268
296
|
if (!saved.auth) {
|
|
297
|
+
if (saved.notice) {
|
|
298
|
+
await throwAuthNotice(input, saved.notice, deps);
|
|
299
|
+
}
|
|
269
300
|
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
270
301
|
}
|
|
271
302
|
|
|
@@ -281,6 +312,13 @@ export async function ensureSupabaseToolAuth(
|
|
|
281
312
|
try {
|
|
282
313
|
await clearHostAuth(input, fetchImpl);
|
|
283
314
|
} catch {}
|
|
315
|
+
} else {
|
|
316
|
+
const latest = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
317
|
+
if (!latest.auth && latest.notice) {
|
|
318
|
+
try {
|
|
319
|
+
await clearHostAuth(input, fetchImpl);
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
284
322
|
}
|
|
285
323
|
throw error;
|
|
286
324
|
}
|
|
@@ -300,8 +338,11 @@ export async function ensureSupabaseToolAuth(
|
|
|
300
338
|
};
|
|
301
339
|
const refreshPromise = (async () => {
|
|
302
340
|
const fetchImpl = deps.fetch ?? fetch;
|
|
303
|
-
const current = await readSavedAuth(input);
|
|
341
|
+
const current = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
304
342
|
if (!current.auth) {
|
|
343
|
+
if (current.notice) {
|
|
344
|
+
await throwAuthNotice(input, current.notice, deps);
|
|
345
|
+
}
|
|
305
346
|
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
306
347
|
}
|
|
307
348
|
|
|
@@ -325,8 +366,11 @@ export async function ensureSupabaseToolAuth(
|
|
|
325
366
|
expires: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
|
|
326
367
|
};
|
|
327
368
|
|
|
328
|
-
const latest = await readSavedAuth(input);
|
|
369
|
+
const latest = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
329
370
|
if (!latest.auth) {
|
|
371
|
+
if (latest.notice) {
|
|
372
|
+
await throwAuthNotice(input, latest.notice, deps);
|
|
373
|
+
}
|
|
330
374
|
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
331
375
|
}
|
|
332
376
|
|
|
@@ -337,23 +381,26 @@ export async function ensureSupabaseToolAuth(
|
|
|
337
381
|
await writeSavedAuth(input, nextAuth);
|
|
338
382
|
return nextAuth;
|
|
339
383
|
} catch (error) {
|
|
340
|
-
if (error instanceof BrokerClientError
|
|
341
|
-
const latest = await readSavedAuth(input);
|
|
384
|
+
if (error instanceof BrokerClientError) {
|
|
385
|
+
const latest = await readSavedAuth(input, { logger: deps.logger, now: deps.now });
|
|
342
386
|
if (!isSameAuth(latest.auth, current.auth)) {
|
|
343
387
|
if (latest.auth) {
|
|
344
388
|
return latest.auth;
|
|
345
389
|
}
|
|
390
|
+
if (latest.notice) {
|
|
391
|
+
await throwAuthNotice(input, latest.notice, deps);
|
|
392
|
+
}
|
|
346
393
|
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
347
394
|
}
|
|
348
395
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
396
|
+
if (error.code === "unauthorized") {
|
|
397
|
+
await clearSavedAuth(input);
|
|
398
|
+
try {
|
|
399
|
+
await clearHostAuth(input, fetchImpl);
|
|
400
|
+
} catch {}
|
|
401
|
+
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
402
|
+
}
|
|
355
403
|
|
|
356
|
-
if (error instanceof BrokerClientError) {
|
|
357
404
|
throw new Error(`Supabase auth refresh failed: ${error.message}`);
|
|
358
405
|
}
|
|
359
406
|
throw error;
|
|
@@ -366,7 +413,7 @@ export async function ensureSupabaseToolAuth(
|
|
|
366
413
|
})
|
|
367
414
|
.finally(() => {
|
|
368
415
|
if (inFlightRefreshes.get(refreshKey)?.promise === refreshEntry.promise) {
|
|
369
|
-
|
|
416
|
+
inFlightRefreshes.delete(refreshKey);
|
|
370
417
|
}
|
|
371
418
|
});
|
|
372
419
|
|
package/src/tui/dialog.tsx
CHANGED
|
@@ -37,6 +37,7 @@ type OAuthState =
|
|
|
37
37
|
| { type: "checking_auth" }
|
|
38
38
|
| { type: "idle" }
|
|
39
39
|
| { type: "already_connected" }
|
|
40
|
+
| { type: "notice"; notice: AuthNotice }
|
|
40
41
|
| { type: "authorizing"; url: string }
|
|
41
42
|
| { type: "waiting_callback"; url: string }
|
|
42
43
|
| { type: "success" }
|
|
@@ -53,9 +54,15 @@ type AuthData = {
|
|
|
53
54
|
|
|
54
55
|
type AuthStatus =
|
|
55
56
|
| { status: "connected"; checked: boolean }
|
|
56
|
-
| { status: "disconnected"; checked: boolean }
|
|
57
|
+
| { status: "disconnected"; checked: boolean; notice?: AuthNotice }
|
|
57
58
|
| { status: "refresh_required"; checked: true };
|
|
58
59
|
|
|
60
|
+
type AuthNotice = {
|
|
61
|
+
type: "auth_store_reset";
|
|
62
|
+
message: string;
|
|
63
|
+
backupPath: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
59
66
|
type AuthFlowContext = {
|
|
60
67
|
api: TuiPluginApi;
|
|
61
68
|
logger: SupabaseLogger;
|
|
@@ -169,19 +176,72 @@ function SupabaseSpinnerDialog(props: {
|
|
|
169
176
|
), { onClose: props.dismissible ? props.onClose : () => undefined });
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
180
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseAuthNotice(value: unknown): AuthNotice | undefined {
|
|
184
|
+
if (!isRecord(value)) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (value.type === "auth_store_reset" && typeof value.message === "string" && typeof value.backupPath === "string") {
|
|
189
|
+
return {
|
|
190
|
+
type: "auth_store_reset",
|
|
191
|
+
message: value.message,
|
|
192
|
+
backupPath: value.backupPath,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
172
199
|
function parseAuthStatus(instructions: string): AuthStatus {
|
|
173
200
|
const parsed = JSON.parse(instructions) as Partial<AuthStatus>;
|
|
174
201
|
if (
|
|
175
202
|
parsed.status === "connected" ||
|
|
176
|
-
parsed.status === "disconnected" ||
|
|
177
203
|
parsed.status === "refresh_required"
|
|
178
204
|
) {
|
|
179
205
|
return parsed as AuthStatus;
|
|
180
206
|
}
|
|
181
207
|
|
|
208
|
+
if (parsed.status === "disconnected") {
|
|
209
|
+
return {
|
|
210
|
+
status: "disconnected",
|
|
211
|
+
checked: parsed.checked === true,
|
|
212
|
+
notice: parseAuthNotice(parsed.notice),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
182
216
|
throw new Error("Invalid Supabase auth status response");
|
|
183
217
|
}
|
|
184
218
|
|
|
219
|
+
function noticeMessage(notice: AuthNotice) {
|
|
220
|
+
return `The local Supabase auth file was corrupted, so auth was reset.\n\nThe corrupted file was preserved here:\n${notice.backupPath}\n\nReconnect to continue.`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function checkNoticeAfterCallback(context: Pick<AuthFlowContext, "api" | "setState">): Promise<boolean> {
|
|
224
|
+
try {
|
|
225
|
+
const authResponse = (await context.api.client.provider.oauth.authorize({
|
|
226
|
+
providerID: "supabase",
|
|
227
|
+
method: 1,
|
|
228
|
+
})) as ApiResponse<AuthData>;
|
|
229
|
+
|
|
230
|
+
if (authResponse.error || !authResponse.data?.instructions) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const status = parseAuthStatus(authResponse.data.instructions);
|
|
235
|
+
if (status.status === "disconnected" && status.notice) {
|
|
236
|
+
context.setState({ type: "notice", notice: status.notice });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// ignore secondary errors
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
185
245
|
async function openBrowser(url: string, logger: SupabaseLogger) {
|
|
186
246
|
try {
|
|
187
247
|
const open = await import("open");
|
|
@@ -363,6 +423,11 @@ export async function runAuthPreflight(context: Pick<AuthFlowContext, "api" | "l
|
|
|
363
423
|
}
|
|
364
424
|
|
|
365
425
|
if (status.status === "disconnected") {
|
|
426
|
+
if (status.notice) {
|
|
427
|
+
context.setState({ type: "notice", notice: status.notice });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
366
431
|
context.setState({ type: "idle" });
|
|
367
432
|
return;
|
|
368
433
|
}
|
|
@@ -373,6 +438,7 @@ export async function runAuthPreflight(context: Pick<AuthFlowContext, "api" | "l
|
|
|
373
438
|
})) as ApiResponse<boolean>;
|
|
374
439
|
|
|
375
440
|
if (callbackResponse.error) {
|
|
441
|
+
if (await checkNoticeAfterCallback(context)) return;
|
|
376
442
|
throw new Error(formatAuthError("callback", callbackResponse.error));
|
|
377
443
|
}
|
|
378
444
|
|
|
@@ -381,8 +447,10 @@ export async function runAuthPreflight(context: Pick<AuthFlowContext, "api" | "l
|
|
|
381
447
|
return;
|
|
382
448
|
}
|
|
383
449
|
|
|
450
|
+
if (await checkNoticeAfterCallback(context)) return;
|
|
384
451
|
context.setState({ type: "idle" });
|
|
385
452
|
} catch (error) {
|
|
453
|
+
if (await checkNoticeAfterCallback(context)) return;
|
|
386
454
|
context.setState({
|
|
387
455
|
type: "unknown",
|
|
388
456
|
message: formatAuthError("unknown", error),
|
|
@@ -520,6 +588,15 @@ export function SupabaseDialog(props: SupabaseDialogProps) {
|
|
|
520
588
|
});
|
|
521
589
|
}
|
|
522
590
|
|
|
591
|
+
if (currentState.type === "notice") {
|
|
592
|
+
return props.api.ui.DialogConfirm({
|
|
593
|
+
title: "Supabase auth reset",
|
|
594
|
+
message: noticeMessage(currentState.notice),
|
|
595
|
+
onConfirm: startOAuth,
|
|
596
|
+
onCancel: closeDialog,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
523
600
|
if (currentState.type === "authorizing") {
|
|
524
601
|
if (!currentState.url) {
|
|
525
602
|
return SupabaseSpinnerDialog({
|