opencode-supabase 0.1.2-alpha.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-supabase",
3
- "version": "0.1.2-alpha.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Supabase integration with server and TUI components",
6
6
  "license": "Apache-2.0",
@@ -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({ status: "disconnected", checked: false });
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") {
@@ -1,21 +1,274 @@
1
- import { mkdir } from "node:fs/promises";
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 authFile = Bun.file(file(input));
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
- const parsed = JSON.parse(await authFile.text()) as SavedState;
56
- if (parsed.version !== 1) {
57
- throw new Error("Unsupported Supabase auth store version");
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
- const path = file(input);
64
- await mkdir(dirname(path), { recursive: true });
65
- await Bun.write(path, JSON.stringify({ version: 1, auth }, null, 2));
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
- const path = file(input);
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;
@@ -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 { type SavedAuth, clearSavedAuth, getStoreFile, readSavedAuth, writeSavedAuth } from "./store.ts";
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 { status: "disconnected", checked: false };
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 && (error.status === 401 || error.status === 400)) {
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
- await clearSavedAuth(input);
350
- try {
351
- await clearHostAuth(input, fetchImpl);
352
- } catch {}
353
- throw new Error(NOT_CONNECTED_MESSAGE);
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
- inFlightRefreshes.delete(refreshKey);
416
+ inFlightRefreshes.delete(refreshKey);
370
417
  }
371
418
  });
372
419
 
@@ -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({