@ctrl-spc/cs 0.7.14 → 0.7.16

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/dist/supabase.js CHANGED
@@ -1,12 +1,43 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
3
- import { readSession, writeSession, getMachineIdentity } from './config.js';
3
+ import { readSessionRecord, writeSession, rotateSession, rejectSession, getMachineIdentity } from './config.js';
4
4
  export class NotLoggedIn extends Error {
5
5
  constructor(message = 'Not logged in. Run `cs login` first.') {
6
6
  super(message);
7
7
  this.name = 'NotLoggedIn';
8
8
  }
9
9
  }
10
+ /** A rejected credential is different from a request that never reached Auth. */
11
+ export function confirmedSessionRejection(error) {
12
+ if (!error || typeof error !== 'object')
13
+ return false;
14
+ const value = error;
15
+ return [
16
+ 'refresh_token_not_found', 'refresh_token_already_used', 'session_not_found',
17
+ 'session_expired', 'user_not_found', 'invalid_credentials',
18
+ ].includes(value.code ?? value.error_code ?? '');
19
+ }
20
+ const sessionWriters = new WeakMap();
21
+ const invalidateClients = new WeakMap();
22
+ const clientChecks = new WeakMap();
23
+ export function assertClientSession(client) { clientChecks.get(client)?.(); }
24
+ export function clientSessionCurrent(client) {
25
+ try {
26
+ assertClientSession(client);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** Dispose the sole refresh writer before replacing it or abandoning startup. */
34
+ export async function disposeClient(client) {
35
+ invalidateClients.get(client)?.();
36
+ invalidateClients.delete(client);
37
+ sessionWriters.get(client)?.unsubscribe();
38
+ sessionWriters.delete(client);
39
+ await client.auth.stopAutoRefresh();
40
+ }
10
41
  /**
11
42
  * Sign in with email + password and persist the session to disk (shared with
12
43
  * the terminal CLI via session.json). Used by the companion GUI's sign-in form,
@@ -14,6 +45,7 @@ export class NotLoggedIn extends Error {
14
45
  * Supabase happens here, server-side, not in the browser.
15
46
  */
16
47
  export async function signIn(email, password) {
48
+ const expectedGeneration = readSessionRecord()?.generation ?? null;
17
49
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
18
50
  auth: { persistSession: false, autoRefreshToken: false },
19
51
  global: { fetch: retryingFetch },
@@ -23,7 +55,7 @@ export async function signIn(email, password) {
23
55
  throw new Error(error.message);
24
56
  if (!data.session || !data.user?.email)
25
57
  throw new Error('Sign-in did not return a session.');
26
- writeSession({ access_token: data.session.access_token, refresh_token: data.session.refresh_token });
58
+ await writeSession({ access_token: data.session.access_token, refresh_token: data.session.refresh_token }, data.user.id, expectedGeneration);
27
59
  getMachineIdentity(); // ensure a stable machine id exists post sign-in
28
60
  return { email: data.user.email };
29
61
  }
@@ -33,24 +65,45 @@ export async function signIn(email, password) {
33
65
  * error responses pass straight through so callers see the status.
34
66
  */
35
67
  const RETRY_DELAYS_MS = [250, 1000, 3000];
36
- const retryingFetch = async (input, init) => {
37
- let lastErr;
38
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
39
- try {
40
- return await fetch(input, init);
41
- }
42
- catch (err) {
43
- if (!(err instanceof TypeError))
44
- throw err;
45
- lastErr = err;
46
- const delay = RETRY_DELAYS_MS[attempt];
47
- if (delay === undefined)
48
- break;
49
- await new Promise((r) => setTimeout(r, delay));
68
+ function sessionFetch(check = () => { }) {
69
+ return async (input, init) => {
70
+ const supplied = init?.signal ?? (input instanceof Request ? input.signal : undefined);
71
+ const deadline = AbortSignal.timeout(20_000);
72
+ const signal = supplied ? AbortSignal.any([supplied, deadline]) : deadline;
73
+ let lastErr;
74
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
75
+ check();
76
+ try {
77
+ return await fetch(input, { ...init, signal });
78
+ }
79
+ catch (err) {
80
+ if (signal.aborted)
81
+ throw err;
82
+ if (!(err instanceof TypeError))
83
+ throw err;
84
+ lastErr = err;
85
+ const delay = RETRY_DELAYS_MS[attempt];
86
+ if (delay === undefined)
87
+ break;
88
+ await new Promise((r) => setTimeout(r, delay));
89
+ }
50
90
  }
51
- }
52
- throw lastErr;
53
- };
91
+ throw lastErr;
92
+ };
93
+ }
94
+ const retryingFetch = sessionFetch();
95
+ /** Candidate credentials are verified without granting them ownership of storage. */
96
+ export async function acceptLogin(session) {
97
+ const expectedGeneration = readSessionRecord()?.generation ?? null;
98
+ const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
99
+ auth: { persistSession: false, autoRefreshToken: false }, global: { fetch: retryingFetch },
100
+ });
101
+ const { data, error } = await client.auth.getUser(session.access_token);
102
+ if (error || !data.user?.email)
103
+ throw new Error('Sign-in could not be verified.', { cause: error });
104
+ await writeSession(session, data.user.id, expectedGeneration);
105
+ return data.user.email;
106
+ }
54
107
  /**
55
108
  * Authenticated client built from the stored session.
56
109
  *
@@ -63,8 +116,14 @@ const retryingFetch = async (input, init) => {
63
116
  * refresh token, for a caller that shares session.json with another process:
64
117
  * two processes refreshing the same token family revoke it for both.
65
118
  */
119
+ const renewingGenerations = new Map();
120
+ export function sessionRenewing() {
121
+ const generation = readSessionRecord()?.generation;
122
+ return !!generation && (renewingGenerations.get(generation) ?? 0) > 0;
123
+ }
66
124
  export async function getClient({ refreshing = true } = {}) {
67
- const stored = readSession();
125
+ const record = readSessionRecord();
126
+ const stored = record?.state === 'signed-in' ? record.tokens : null;
68
127
  // The refreshing path still requires both, exactly as today. The
69
128
  // non-refreshing one is never handed a refresh token, so it must not demand
70
129
  // one: requiring it would refuse a session this client can legitimately use.
@@ -72,6 +131,53 @@ export async function getClient({ refreshing = true } = {}) {
72
131
  throw new NotLoggedIn();
73
132
  if (refreshing && !stored?.refresh_token)
74
133
  throw new NotLoggedIn();
134
+ const generation = record.generation;
135
+ const accountId = record.accountId;
136
+ let invalidated = false;
137
+ let persistenceError;
138
+ const check = () => {
139
+ if (persistenceError)
140
+ throw new Error('Rotated sign-in could not be saved. Work is suspended.', { cause: persistenceError });
141
+ const current = readSessionRecord();
142
+ if (invalidated || current?.state !== 'signed-in' || current.generation !== generation
143
+ || (accountId !== null && current.accountId !== accountId))
144
+ throw new NotLoggedIn('This connection no longer owns the local sign-in.');
145
+ };
146
+ const guardedFetch = async (input, init) => {
147
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
148
+ const renewing = refreshing && url.startsWith(`${SUPABASE_URL}/auth/v1/token`);
149
+ if (renewing)
150
+ renewingGenerations.set(generation, (renewingGenerations.get(generation) ?? 0) + 1);
151
+ try {
152
+ const response = await sessionFetch(check)(input, init);
153
+ if (refreshing && url.startsWith(`${SUPABASE_URL}/auth/v1/token`) && !response.ok) {
154
+ let failure;
155
+ try {
156
+ failure = await response.clone().json();
157
+ }
158
+ catch { /* Invalid responses are unknown, never rejection. */ }
159
+ if (confirmedSessionRejection(failure)) {
160
+ invalidated = true;
161
+ try {
162
+ await rejectSession(generation);
163
+ }
164
+ catch (error) {
165
+ persistenceError = error;
166
+ }
167
+ }
168
+ }
169
+ return response;
170
+ }
171
+ finally {
172
+ if (renewing) {
173
+ const remaining = (renewingGenerations.get(generation) ?? 1) - 1;
174
+ if (remaining)
175
+ renewingGenerations.set(generation, remaining);
176
+ else
177
+ renewingGenerations.delete(generation);
178
+ }
179
+ }
180
+ };
75
181
  /* ═══ NOT REFRESHING MEANS NEVER HANDED THE REFRESH TOKEN. ═══ Not merely
76
182
  `autoRefreshToken: false`: auth-js refreshes inside `setSession` whenever
77
183
  the access token has already expired, regardless of that flag
@@ -85,31 +191,61 @@ export async function getClient({ refreshing = true } = {}) {
85
191
  if (!refreshing) {
86
192
  if (accessTokenExpired(stored.access_token))
87
193
  throw new NotLoggedIn();
88
- return createClient(SUPABASE_URL, SUPABASE_KEY, {
194
+ const passive = createClient(SUPABASE_URL, SUPABASE_KEY, {
89
195
  auth: { persistSession: false, autoRefreshToken: false },
90
- global: { fetch: retryingFetch, headers: { Authorization: `Bearer ${stored.access_token}` } },
196
+ global: { fetch: guardedFetch, headers: { Authorization: `Bearer ${stored.access_token}` } },
91
197
  });
198
+ clientChecks.set(passive, check);
199
+ invalidateClients.set(passive, () => { invalidated = true; });
200
+ return passive;
92
201
  }
93
202
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
94
203
  auth: { persistSession: false, autoRefreshToken: true },
95
- global: { fetch: retryingFetch },
204
+ global: { fetch: guardedFetch },
96
205
  });
97
- client.auth.onAuthStateChange((_event, session) => {
98
- // Update-only: a token refresh must never recreate session.json after
99
- // `cs logout` deleted it, or the logged-out machine would re-authorize
100
- // itself up to an hour later.
101
- if (session && readSession()) {
102
- writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
103
- }
104
- });
105
- const { data, error } = await client.auth.setSession({
106
- access_token: stored.access_token,
107
- refresh_token: stored.refresh_token,
206
+ clientChecks.set(client, check);
207
+ invalidateClients.set(client, () => { invalidated = true; });
208
+ let persistence = Promise.resolve();
209
+ const writer = client.auth.onAuthStateChange((event, session) => {
210
+ // Never await storage/SDK operations inside the SDK's own auth lock.
211
+ if (event === 'SIGNED_OUT')
212
+ invalidated = true;
213
+ if (!session && event !== 'SIGNED_OUT')
214
+ return;
215
+ persistence = persistence.then(async () => {
216
+ if (event === 'SIGNED_OUT') {
217
+ await rejectSession(generation);
218
+ return;
219
+ }
220
+ if (session && !invalidated) {
221
+ await rotateSession(generation, { access_token: session.access_token, refresh_token: session.refresh_token }, session.user.id);
222
+ }
223
+ }).catch(error => { persistenceError = error; });
108
224
  });
109
- if (error || !data.session) {
110
- throw new NotLoggedIn(`Stored session is invalid or expired (${error?.message ?? 'no session'}). Run \`cs login\` again.`);
225
+ sessionWriters.set(client, writer.data.subscription);
226
+ try {
227
+ const { data, error } = await client.auth.setSession({
228
+ access_token: stored.access_token,
229
+ refresh_token: stored.refresh_token,
230
+ });
231
+ if (error) {
232
+ if (confirmedSessionRejection(error)) {
233
+ invalidated = true;
234
+ await rejectSession(generation);
235
+ throw new NotLoggedIn('Stored sign-in was rejected. Run `cs login` again.');
236
+ }
237
+ throw new Error(`Cloud sign-in could not be checked: ${error.message}`, { cause: error });
238
+ }
239
+ if (!data.session)
240
+ throw new Error('Cloud sign-in did not return a session. Try again when the connection is available.');
241
+ await persistence;
242
+ check();
243
+ return client;
244
+ }
245
+ catch (error) {
246
+ await disposeClient(client);
247
+ throw error;
111
248
  }
112
- return client;
113
249
  }
114
250
  /** Whether a stored access token's own `exp` claim has already passed. A token
115
251
  * this process cannot parse is treated as unusable rather than trusted: the