@ctrl-spc/cs 0.7.15 → 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,6 +1,6 @@
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);
@@ -12,14 +12,28 @@ export function confirmedSessionRejection(error) {
12
12
  if (!error || typeof error !== 'object')
13
13
  return false;
14
14
  const value = error;
15
- return value.status === 401 || [
15
+ return [
16
16
  'refresh_token_not_found', 'refresh_token_already_used', 'session_not_found',
17
- 'session_expired', 'bad_jwt', 'user_not_found', 'invalid_credentials',
18
- ].includes(value.code ?? '');
17
+ 'session_expired', 'user_not_found', 'invalid_credentials',
18
+ ].includes(value.code ?? value.error_code ?? '');
19
19
  }
20
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
+ }
21
33
  /** Dispose the sole refresh writer before replacing it or abandoning startup. */
22
34
  export async function disposeClient(client) {
35
+ invalidateClients.get(client)?.();
36
+ invalidateClients.delete(client);
23
37
  sessionWriters.get(client)?.unsubscribe();
24
38
  sessionWriters.delete(client);
25
39
  await client.auth.stopAutoRefresh();
@@ -31,6 +45,7 @@ export async function disposeClient(client) {
31
45
  * Supabase happens here, server-side, not in the browser.
32
46
  */
33
47
  export async function signIn(email, password) {
48
+ const expectedGeneration = readSessionRecord()?.generation ?? null;
34
49
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
35
50
  auth: { persistSession: false, autoRefreshToken: false },
36
51
  global: { fetch: retryingFetch },
@@ -40,7 +55,7 @@ export async function signIn(email, password) {
40
55
  throw new Error(error.message);
41
56
  if (!data.session || !data.user?.email)
42
57
  throw new Error('Sign-in did not return a session.');
43
- 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);
44
59
  getMachineIdentity(); // ensure a stable machine id exists post sign-in
45
60
  return { email: data.user.email };
46
61
  }
@@ -50,29 +65,45 @@ export async function signIn(email, password) {
50
65
  * error responses pass straight through so callers see the status.
51
66
  */
52
67
  const RETRY_DELAYS_MS = [250, 1000, 3000];
53
- const retryingFetch = async (input, init) => {
54
- const supplied = init?.signal ?? (input instanceof Request ? input.signal : undefined);
55
- const deadline = AbortSignal.timeout(20_000);
56
- const signal = supplied ? AbortSignal.any([supplied, deadline]) : deadline;
57
- let lastErr;
58
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
59
- try {
60
- return await fetch(input, { ...init, signal });
61
- }
62
- catch (err) {
63
- if (signal.aborted)
64
- throw err;
65
- if (!(err instanceof TypeError))
66
- throw err;
67
- lastErr = err;
68
- const delay = RETRY_DELAYS_MS[attempt];
69
- if (delay === undefined)
70
- break;
71
- 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
+ }
72
90
  }
73
- }
74
- throw lastErr;
75
- };
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
+ }
76
107
  /**
77
108
  * Authenticated client built from the stored session.
78
109
  *
@@ -85,8 +116,14 @@ const retryingFetch = async (input, init) => {
85
116
  * refresh token, for a caller that shares session.json with another process:
86
117
  * two processes refreshing the same token family revoke it for both.
87
118
  */
119
+ const renewingGenerations = new Map();
120
+ export function sessionRenewing() {
121
+ const generation = readSessionRecord()?.generation;
122
+ return !!generation && (renewingGenerations.get(generation) ?? 0) > 0;
123
+ }
88
124
  export async function getClient({ refreshing = true } = {}) {
89
- const stored = readSession();
125
+ const record = readSessionRecord();
126
+ const stored = record?.state === 'signed-in' ? record.tokens : null;
90
127
  // The refreshing path still requires both, exactly as today. The
91
128
  // non-refreshing one is never handed a refresh token, so it must not demand
92
129
  // one: requiring it would refuse a session this client can legitimately use.
@@ -94,6 +131,53 @@ export async function getClient({ refreshing = true } = {}) {
94
131
  throw new NotLoggedIn();
95
132
  if (refreshing && !stored?.refresh_token)
96
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
+ };
97
181
  /* ═══ NOT REFRESHING MEANS NEVER HANDED THE REFRESH TOKEN. ═══ Not merely
98
182
  `autoRefreshToken: false`: auth-js refreshes inside `setSession` whenever
99
183
  the access token has already expired, regardless of that flag
@@ -107,22 +191,36 @@ export async function getClient({ refreshing = true } = {}) {
107
191
  if (!refreshing) {
108
192
  if (accessTokenExpired(stored.access_token))
109
193
  throw new NotLoggedIn();
110
- return createClient(SUPABASE_URL, SUPABASE_KEY, {
194
+ const passive = createClient(SUPABASE_URL, SUPABASE_KEY, {
111
195
  auth: { persistSession: false, autoRefreshToken: false },
112
- global: { fetch: retryingFetch, headers: { Authorization: `Bearer ${stored.access_token}` } },
196
+ global: { fetch: guardedFetch, headers: { Authorization: `Bearer ${stored.access_token}` } },
113
197
  });
198
+ clientChecks.set(passive, check);
199
+ invalidateClients.set(passive, () => { invalidated = true; });
200
+ return passive;
114
201
  }
115
202
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
116
203
  auth: { persistSession: false, autoRefreshToken: true },
117
- global: { fetch: retryingFetch },
204
+ global: { fetch: guardedFetch },
118
205
  });
119
- const writer = client.auth.onAuthStateChange((_event, session) => {
120
- // Update-only: a token refresh must never recreate session.json after
121
- // `cs logout` deleted it, or the logged-out machine would re-authorize
122
- // itself up to an hour later.
123
- if (session && readSession()) {
124
- writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
125
- }
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; });
126
224
  });
127
225
  sessionWriters.set(client, writer.data.subscription);
128
226
  try {
@@ -132,12 +230,16 @@ export async function getClient({ refreshing = true } = {}) {
132
230
  });
133
231
  if (error) {
134
232
  if (confirmedSessionRejection(error)) {
135
- throw new NotLoggedIn(`Stored sign-in was rejected (${error.message}). Run \`cs login\` again.`);
233
+ invalidated = true;
234
+ await rejectSession(generation);
235
+ throw new NotLoggedIn('Stored sign-in was rejected. Run `cs login` again.');
136
236
  }
137
237
  throw new Error(`Cloud sign-in could not be checked: ${error.message}`, { cause: error });
138
238
  }
139
239
  if (!data.session)
140
240
  throw new Error('Cloud sign-in did not return a session. Try again when the connection is available.');
241
+ await persistence;
242
+ check();
141
243
  return client;
142
244
  }
143
245
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.15",
3
+ "version": "0.7.16",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"