memoir-cli 3.17.3 → 3.17.4

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": "memoir-cli",
3
- "version": "3.17.3",
3
+ "version": "3.17.4",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
6
6
  "main": "src/index.js",
package/src/cloud/auth.js CHANGED
@@ -21,25 +21,39 @@ async function supaFetch(endpoint, options = {}) {
21
21
  });
22
22
  }
23
23
 
24
+ // GoTrue's REST API takes the post-email landing URL as a `redirect_to`
25
+ // QUERY PARAM. `options.emailRedirectTo` / `options.redirectTo` in the JSON
26
+ // body is supabase-js's client shape — sent raw it is silently ignored and
27
+ // the email links fall back to the project's Site URL. Both URLs below must
28
+ // also be on the Auth → URL Configuration → Redirect URLs allow-list.
24
29
  const CONFIRMED_URL = 'https://memoir.sh/confirmed';
25
30
  const RESET_URL = 'https://memoir.sh/reset-password';
26
31
 
27
32
  export async function signUp(email, password) {
28
- const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, { method: 'POST', body: JSON.stringify({ email, password }) });
33
+ const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, {
34
+ method: 'POST',
35
+ body: JSON.stringify({ email, password }),
36
+ });
29
37
  const data = await res.json();
30
38
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign up failed');
31
39
  return data;
32
40
  }
33
41
 
34
42
  export async function signIn(email, password) {
35
- const res = await supaFetch('/auth/v1/token?grant_type=password', { method: 'POST', body: JSON.stringify({ email, password }) });
43
+ const res = await supaFetch('/auth/v1/token?grant_type=password', {
44
+ method: 'POST',
45
+ body: JSON.stringify({ email, password }),
46
+ });
36
47
  const data = await res.json();
37
48
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign in failed');
38
49
  return data;
39
50
  }
40
51
 
41
52
  export async function refreshSession(refreshToken) {
42
- const res = await supaFetch('/auth/v1/token?grant_type=refresh_token', { method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }) });
53
+ const res = await supaFetch('/auth/v1/token?grant_type=refresh_token', {
54
+ method: 'POST',
55
+ body: JSON.stringify({ refresh_token: refreshToken }),
56
+ });
43
57
  const data = await res.json();
44
58
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Token refresh failed');
45
59
  return data;
@@ -47,30 +61,58 @@ export async function refreshSession(refreshToken) {
47
61
 
48
62
  export async function saveSession(session) {
49
63
  await fs.ensureDir(configDir);
50
- const payload = { access_token: session.access_token, refresh_token: session.refresh_token, expires_at: Date.now() + (session.expires_in * 1000), user: { id: session.user.id, email: session.user.email } };
64
+ const payload = {
65
+ access_token: session.access_token,
66
+ refresh_token: session.refresh_token,
67
+ expires_at: Date.now() + (session.expires_in * 1000),
68
+ user: {
69
+ id: session.user.id,
70
+ email: session.user.email,
71
+ },
72
+ };
51
73
  await fs.writeFile(AUTH_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
52
74
  return payload;
53
75
  }
54
76
 
55
77
  export async function getSession() {
56
78
  if (!await fs.pathExists(AUTH_FILE)) return null;
79
+
57
80
  const stored = await fs.readJson(AUTH_FILE);
81
+
82
+ // If token expires within 60 seconds, refresh
58
83
  if (stored.expires_at < Date.now() + 60000) {
59
- try { return await saveSession(await refreshSession(stored.refresh_token)); }
60
- catch { await fs.remove(AUTH_FILE); return null; }
84
+ try {
85
+ const refreshed = await refreshSession(stored.refresh_token);
86
+ return await saveSession(refreshed);
87
+ } catch {
88
+ // Refresh failed — session is dead
89
+ await fs.remove(AUTH_FILE);
90
+ return null;
91
+ }
61
92
  }
93
+
62
94
  return stored;
63
95
  }
64
96
 
65
- export async function logout() { if (await fs.pathExists(AUTH_FILE)) await fs.remove(AUTH_FILE); }
66
- export async function isLoggedIn() { return !!(await getSession()); }
97
+ export async function logout() {
98
+ if (await fs.pathExists(AUTH_FILE)) {
99
+ await fs.remove(AUTH_FILE);
100
+ }
101
+ }
102
+
103
+ export async function isLoggedIn() {
104
+ const session = await getSession();
105
+ return !!session;
106
+ }
67
107
 
68
108
  // A failed plan lookup is not evidence that a user is on Free. Callers use
69
109
  // this value to choose destructive retention limits, so fail closed instead of
70
110
  // silently downgrading a Pro account and pruning its history.
71
111
  export async function getSubscription(session) {
72
112
  const res = await supaFetch('/rest/v1/subscriptions?select=*&user_id=eq.' + encodeURIComponent(session.user.id), {
73
- headers: { 'Authorization': `Bearer ${session.access_token}` },
113
+ headers: {
114
+ 'Authorization': `Bearer ${session.access_token}`,
115
+ },
74
116
  });
75
117
  if (!res.ok) throw new Error(`Could not verify subscription (${res.status}). Existing backups were not pruned.`);
76
118
  const data = await res.json();
@@ -80,8 +122,14 @@ export async function getSubscription(session) {
80
122
  }
81
123
 
82
124
  export async function resetPassword(email) {
83
- const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, { method: 'POST', body: JSON.stringify({ email }) });
84
- if (!res.ok) { const data = await res.json(); throw new Error(data.error_description || data.msg || 'Password reset failed'); }
125
+ const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, {
126
+ method: 'POST',
127
+ body: JSON.stringify({ email }),
128
+ });
129
+ if (!res.ok) {
130
+ const data = await res.json();
131
+ throw new Error(data.error_description || data.msg || 'Password reset failed');
132
+ }
85
133
  }
86
134
 
87
135
  async function requireDelete(res, label) {
@@ -91,27 +139,119 @@ async function requireDelete(res, label) {
91
139
  throw new Error(`${label} failed (${res.status})${detail ? ': ' + detail : ''}. Nothing else was reported as deleted; retry account deletion.`);
92
140
  }
93
141
 
142
+ async function requireRowDelete(res, label, { expectedMinRows = 0 } = {}) {
143
+ await requireDelete(res, label);
144
+ if (res.status === 204) {
145
+ throw new Error(`${label} failed: server returned 204 without representation (check RLS DELETE policy). Nothing else was reported as deleted; retry account deletion.`);
146
+ }
147
+ let rows = [];
148
+ try {
149
+ rows = await res.json();
150
+ } catch {
151
+ rows = [];
152
+ }
153
+ if (!Array.isArray(rows)) {
154
+ throw new Error(`${label} failed: unexpected response shape. Nothing else was reported as deleted; retry account deletion.`);
155
+ }
156
+ if (expectedMinRows > 0 && rows.length < expectedMinRows) {
157
+ throw new Error(`${label} failed: 0 rows removed (check RLS DELETE policy). Nothing else was reported as deleted; retry account deletion.`);
158
+ }
159
+ return rows;
160
+ }
161
+
162
+ async function verifyNoneRemaining(endpoint, headers, label) {
163
+ const checkRes = await fetch(`${SUPABASE_URL}${endpoint}`, { headers });
164
+ if (!checkRes.ok) {
165
+ throw new Error(`${label} verification failed (${checkRes.status}). Nothing else was reported as deleted; retry account deletion.`);
166
+ }
167
+ const remaining = await checkRes.json();
168
+ if (!Array.isArray(remaining) || remaining.length > 0) {
169
+ throw new Error(`${label} failed: 0 rows removed while account rows still exist (check RLS DELETE policy). Nothing else was reported as deleted; retry account deletion.`);
170
+ }
171
+ }
172
+
94
173
  // Deletes the data the current user is authorized to delete. Auth-user removal
95
174
  // itself requires a privileged server-side endpoint and must never be attempted
96
175
  // with a service-role key from this CLI.
97
176
  export async function deleteAccount(session) {
98
- const headers = { 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY };
99
- const backupsRes = await fetch(`${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${encodeURIComponent(session.user.id)}`, { headers });
100
- if (!backupsRes.ok) throw new Error(`Could not enumerate backups (${backupsRes.status}); account deletion stopped safely.`);
177
+ const headers = {
178
+ 'Authorization': `Bearer ${session.access_token}`,
179
+ 'apikey': SUPABASE_ANON_KEY,
180
+ };
181
+ const deleteHeaders = {
182
+ ...headers,
183
+ 'Prefer': 'return=representation',
184
+ };
185
+
186
+ const backupsRes = await fetch(
187
+ `${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${encodeURIComponent(session.user.id)}`,
188
+ { headers }
189
+ );
190
+ if (!backupsRes.ok) {
191
+ throw new Error(`Could not enumerate backups (${backupsRes.status}); account deletion stopped safely.`);
192
+ }
101
193
  const backups = await backupsRes.json();
102
194
 
103
195
  for (const backup of backups) {
104
- if (typeof backup.storage_path !== 'string' || !backup.storage_path.startsWith(session.user.id + '/')) throw new Error('Unsafe backup path; account deletion stopped.');
105
- const object = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
106
- method: 'DELETE', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ prefixes: [backup.storage_path] }),
196
+ if (typeof backup.storage_path !== 'string' || !backup.storage_path.startsWith(session.user.id + '/')) {
197
+ throw new Error('Unsafe backup path; account deletion stopped.');
198
+ }
199
+ const objectRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
200
+ method: 'DELETE',
201
+ headers: {
202
+ ...headers,
203
+ 'Content-Type': 'application/json',
204
+ },
205
+ body: JSON.stringify({ prefixes: [backup.storage_path] }),
107
206
  });
108
- await requireDelete(object, 'Backup object deletion');
109
- const row = await fetch(`${SUPABASE_URL}/rest/v1/backups?id=eq.${encodeURIComponent(backup.id)}`, { method: 'DELETE', headers });
110
- await requireDelete(row, 'Backup metadata deletion');
207
+ await requireDelete(objectRes, 'Backup object deletion');
208
+
209
+ const rowRes = await fetch(
210
+ `${SUPABASE_URL}/rest/v1/backups?id=eq.${encodeURIComponent(backup.id)}`,
211
+ { method: 'DELETE', headers: deleteHeaders }
212
+ );
213
+ await requireRowDelete(rowRes, 'Backup metadata deletion', { expectedMinRows: 1 });
214
+ }
215
+
216
+ const sharedRes = await fetch(
217
+ `${SUPABASE_URL}/rest/v1/shared_links?created_by=eq.${encodeURIComponent(session.user.id)}`,
218
+ { method: 'DELETE', headers: deleteHeaders }
219
+ );
220
+ const deletedLinks = await requireRowDelete(sharedRes, 'Shared-link deletion');
221
+ if (deletedLinks.length === 0) {
222
+ await verifyNoneRemaining(
223
+ `/rest/v1/shared_links?select=id&created_by=eq.${encodeURIComponent(session.user.id)}&limit=1`,
224
+ headers,
225
+ 'Shared-link deletion'
226
+ );
227
+ }
228
+ for (const link of deletedLinks) {
229
+ if (typeof link?.backup_id === 'string' && link.backup_id.startsWith(session.user.id + '/')) {
230
+ const shareObjRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
231
+ method: 'DELETE',
232
+ headers: {
233
+ ...headers,
234
+ 'Content-Type': 'application/json',
235
+ },
236
+ body: JSON.stringify({ prefixes: [link.backup_id] }),
237
+ });
238
+ await requireDelete(shareObjRes, 'Shared bundle object deletion');
239
+ }
240
+ }
241
+
242
+ const subRes = await fetch(
243
+ `${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${encodeURIComponent(session.user.id)}`,
244
+ { method: 'DELETE', headers: deleteHeaders }
245
+ );
246
+ const deletedSubs = await requireRowDelete(subRes, 'Subscription deletion');
247
+ if (deletedSubs.length === 0) {
248
+ await verifyNoneRemaining(
249
+ `/rest/v1/subscriptions?select=user_id&user_id=eq.${encodeURIComponent(session.user.id)}&limit=1`,
250
+ headers,
251
+ 'Subscription deletion'
252
+ );
111
253
  }
112
254
 
113
- await requireDelete(await fetch(`${SUPABASE_URL}/rest/v1/shared_links?user_id=eq.${encodeURIComponent(session.user.id)}`, { method: 'DELETE', headers }), 'Shared-link deletion');
114
- await requireDelete(await fetch(`${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${encodeURIComponent(session.user.id)}`, { method: 'DELETE', headers }), 'Subscription deletion');
115
255
  await logout();
116
256
  return { localDataDeleted: true, authUserDeleted: false };
117
257
  }
@@ -26,11 +26,20 @@ export async function cloudPushCommand(options = {}) {
26
26
  throw new Error('Cloud login required');
27
27
  }
28
28
 
29
- const sub = await getSubscription(session);
30
- const isPro = sub.status === 'pro';
29
+ let isPro = false;
30
+ let planVerified = false;
31
+ try {
32
+ const sub = await getSubscription(session);
33
+ isPro = sub.status === 'pro';
34
+ planVerified = true;
35
+ } catch {
36
+ // If plan lookup fails, still upload the new backup and skip retention
37
+ // pruning below rather than aborting the backup or assuming Free.
38
+ planVerified = false;
39
+ }
31
40
 
32
- // Check backup count for free users
33
- if (!isPro) {
41
+ // Check backup count for verified free users
42
+ if (planVerified && !isPro) {
34
43
  const existing = await listBackups(session);
35
44
  if (existing.length >= MAX_BACKUPS_FREE) {
36
45
  console.log('\n' + boxen(
@@ -75,8 +84,8 @@ export async function cloudPushCommand(options = {}) {
75
84
 
76
85
  const backup = await uploadBackup(stagingDir, session, toolResults);
77
86
 
78
- // Cleanup old backups
79
- const deleted = await cleanupOldBackups(session, isPro);
87
+ // Cleanup old backups only when the subscription plan was verified
88
+ const deleted = planVerified ? await cleanupOldBackups(session, isPro) : 0;
80
89
 
81
90
  spinner.stop();
82
91
 
@@ -9,54 +9,181 @@ export async function loginCommand(options = {}) {
9
9
  if (existing) {
10
10
  try {
11
11
  const sub = await getSubscription(existing);
12
- console.log('\n' + boxen(gradient.pastel(' memoir cloud ') + '\n\n' + chalk.green('✔ Already logged in as ') + chalk.cyan(existing.user.email) + '\n' + chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n');
12
+ console.log('\n' + boxen(
13
+ gradient.pastel(' memoir cloud ') + '\n\n' +
14
+ chalk.green('✔ Already logged in as ') + chalk.cyan(existing.user.email) + '\n' +
15
+ chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')),
16
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
17
+ ) + '\n');
13
18
  } catch (error) {
14
- console.log('\n' + boxen(chalk.green('✔ Logged in as ') + chalk.cyan(existing.user.email) + '\n' + chalk.yellow('Plan could not be verified right now. No backup history will be pruned until it can be verified.') + '\n' + chalk.gray(error.message), { padding: 1, borderStyle: 'round', borderColor: 'yellow' }) + '\n');
19
+ console.log('\n' + boxen(
20
+ chalk.green('✔ Logged in as ') + chalk.cyan(existing.user.email) + '\n' +
21
+ chalk.yellow('Plan could not be verified right now. No backup history will be pruned until it can be verified.') + '\n' +
22
+ chalk.gray(error.message),
23
+ { padding: 1, borderStyle: 'round', borderColor: 'yellow' }
24
+ ) + '\n');
15
25
  }
16
26
  return;
17
27
  }
18
28
 
19
29
  let action, email, password;
20
- if (options.email && options.password) { action = options.signup ? 'signup' : 'signin'; email = options.email; password = options.password; }
21
- else {
30
+
31
+ if (options.email && options.password) {
32
+ action = options.signup ? 'signup' : 'signin';
33
+ email = options.email;
34
+ password = options.password;
35
+ } else {
22
36
  console.log();
23
- action = (await inquirer.prompt([{ type: 'list', name: 'action', message: 'Sign in or create account?', choices: [{ name: 'Sign in (existing account)', value: 'signin' }, { name: 'Create account', value: 'signup' }] }])).action;
24
- email = (await inquirer.prompt([{ type: 'input', name: 'email', message: 'Email:', validate: v => v.includes('@') ? true : 'Enter a valid email' }])).email;
25
- password = (await inquirer.prompt([{ type: 'password', name: 'password', message: 'Password:', mask: '*', validate: v => v.length >= 6 ? true : 'Password must be at least 6 characters' }])).password;
37
+ const actionAnswer = await inquirer.prompt([{
38
+ type: 'list',
39
+ name: 'action',
40
+ message: 'Sign in or create account?',
41
+ choices: [
42
+ { name: 'Sign in (existing account)', value: 'signin' },
43
+ { name: 'Create account', value: 'signup' },
44
+ ],
45
+ }]);
46
+ action = actionAnswer.action;
47
+
48
+ const emailAnswer = await inquirer.prompt([{
49
+ type: 'input',
50
+ name: 'email',
51
+ message: 'Email:',
52
+ validate: v => v.includes('@') ? true : 'Enter a valid email',
53
+ }]);
54
+ email = emailAnswer.email;
55
+
56
+ const passwordAnswer = await inquirer.prompt([{
57
+ type: 'password',
58
+ name: 'password',
59
+ message: 'Password:',
60
+ mask: '*',
61
+ validate: v => v.length >= 6 ? true : 'Password must be at least 6 characters',
62
+ }]);
63
+ password = passwordAnswer.password;
26
64
  }
27
65
 
28
66
  try {
29
67
  let session;
68
+
30
69
  if (action === 'signup') {
31
70
  const result = await signUp(email, password);
32
- if (result.access_token) session = await saveSession(result);
33
- else { console.log('\n' + boxen(chalk.green('✔ Account created!') + '\n\n' + chalk.white('Check your email to confirm, then run ') + chalk.cyan('memoir login') + chalk.white(' again.'), { padding: 1, borderStyle: 'round', borderColor: 'green' }) + '\n'); return; }
34
- } else session = await saveSession(await signIn(email, password));
71
+ if (result.access_token) {
72
+ session = await saveSession(result);
73
+ } else {
74
+ // Email confirmation required
75
+ console.log('\n' + boxen(
76
+ chalk.green('✔ Account created!') + '\n\n' +
77
+ chalk.white('Check your email to confirm, then run ') + chalk.cyan('memoir login') + chalk.white(' again.'),
78
+ { padding: 1, borderStyle: 'round', borderColor: 'green' }
79
+ ) + '\n');
80
+ return;
81
+ }
82
+ } else {
83
+ const result = await signIn(email, password);
84
+ session = await saveSession(result);
85
+ }
35
86
 
36
87
  let plan = 'Unknown';
37
- try { const sub = await getSubscription(session); plan = sub.status === 'pro' ? 'Pro' : 'Free'; } catch {}
38
- console.log('\n' + boxen(gradient.pastel(' memoir cloud ') + '\n\n' + chalk.green('✔ Logged in as ') + chalk.cyan(session.user.email) + '\n' + chalk.gray('Plan: ') + (plan === 'Pro' ? chalk.green(plan) : plan === 'Free' ? chalk.yellow(plan) : chalk.yellow(plan + ' — will retry before retention cleanup')) + '\n\n' + chalk.gray('Try: ') + chalk.cyan('memoir cloud push') + chalk.gray(' to back up to the cloud'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n');
39
- } catch (error) { console.log('\n' + boxen(chalk.red('✖ ' + error.message), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); }
88
+ try {
89
+ const sub = await getSubscription(session);
90
+ plan = sub.status === 'pro' ? 'Pro' : 'Free';
91
+ } catch {}
92
+
93
+ console.log('\n' + boxen(
94
+ gradient.pastel(' memoir cloud ') + '\n\n' +
95
+ chalk.green('✔ Logged in as ') + chalk.cyan(session.user.email) + '\n' +
96
+ chalk.gray('Plan: ') + (
97
+ plan === 'Pro'
98
+ ? chalk.green(plan)
99
+ : plan === 'Free'
100
+ ? chalk.yellow(plan)
101
+ : chalk.yellow(plan + ' — will retry before retention cleanup')
102
+ ) + '\n\n' +
103
+ chalk.gray('Try: ') + chalk.cyan('memoir cloud push') + chalk.gray(' to back up to the cloud'),
104
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
105
+ ) + '\n');
106
+ } catch (error) {
107
+ console.log('\n' + boxen(
108
+ chalk.red('✖ ' + error.message),
109
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
110
+ ) + '\n');
111
+ }
40
112
  }
41
113
 
42
114
  export async function forgotPasswordCommand(options = {}) {
43
115
  let email = options.email;
44
- if (!email) email = (await inquirer.prompt([{ type: 'input', name: 'email', message: 'Email:', validate: v => v.includes('@') ? true : 'Enter a valid email' }])).email;
45
- try { await resetPassword(email); console.log('\n' + boxen(chalk.green('✔ Password reset email sent!') + '\n\n' + chalk.white('Check ') + chalk.cyan(email) + chalk.white(' for a reset link.'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n'); }
46
- catch (error) { console.log('\n' + boxen(chalk.red('✖ ' + error.message), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); }
116
+
117
+ if (!email) {
118
+ const emailAnswer = await inquirer.prompt([{
119
+ type: 'input',
120
+ name: 'email',
121
+ message: 'Email:',
122
+ validate: v => v.includes('@') ? true : 'Enter a valid email',
123
+ }]);
124
+ email = emailAnswer.email;
125
+ }
126
+
127
+ try {
128
+ await resetPassword(email);
129
+ console.log('\n' + boxen(
130
+ chalk.green('✔ Password reset email sent!') + '\n\n' +
131
+ chalk.white('Check ') + chalk.cyan(email) + chalk.white(' for a reset link.'),
132
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
133
+ ) + '\n');
134
+ } catch (error) {
135
+ console.log('\n' + boxen(
136
+ chalk.red('✖ ' + error.message),
137
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
138
+ ) + '\n');
139
+ }
47
140
  }
48
141
 
49
- export async function logoutCommand() { await logout(); console.log('\n' + boxen(chalk.green('✔ Logged out'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n'); }
142
+ export async function logoutCommand() {
143
+ await logout();
144
+ console.log('\n' + boxen(
145
+ chalk.green('✔ Logged out'),
146
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
147
+ ) + '\n');
148
+ }
50
149
 
51
150
  export async function deleteAccountCommand(options = {}) {
52
151
  const session = await getSession();
53
- if (!session) { console.log('\n' + boxen(chalk.red('✖ Not logged in. Run ') + chalk.cyan('memoir login') + chalk.red(' first.'), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); return; }
152
+ if (!session) {
153
+ console.log('\n' + boxen(
154
+ chalk.red('✖ Not logged in. Run ') + chalk.cyan('memoir login') + chalk.red(' first.'),
155
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
156
+ ) + '\n');
157
+ return;
158
+ }
159
+
54
160
  if (!options.confirm) {
55
- const answer = await inquirer.prompt([{ type: 'input', name: 'confirmation', message: 'Type DELETE to remove your Memoir cloud data and sign out:' }]);
56
- if (answer.confirmation !== 'DELETE') { console.log('\n' + chalk.gray(' Account-data deletion cancelled.') + '\n'); return; }
161
+ const answer = await inquirer.prompt([{
162
+ type: 'input',
163
+ name: 'confirmation',
164
+ message: 'Type DELETE to remove your Memoir cloud data and sign out:',
165
+ }]);
166
+
167
+ if (answer.confirmation !== 'DELETE') {
168
+ console.log('\n' + chalk.gray(' Account-data deletion cancelled.') + '\n');
169
+ return;
170
+ }
57
171
  }
172
+
58
173
  try {
59
174
  await deleteAccount(session);
60
- console.log('\n' + boxen(gradient.pastel(' memoir cloud ') + '\n\n' + chalk.green('✔ Memoir cloud data deleted and this device signed out.') + '\n' + chalk.gray('Backups, shared links, and subscription data accessible to this account were removed.') + '\n\n' + chalk.yellow('Your authentication identity may still exist. Full auth-account removal requires the hosted account-deletion service.'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n');
61
- } catch (error) { console.log('\n' + boxen(chalk.red('✖ Deletion incomplete: ' + error.message) + '\n' + chalk.gray('Retry the command. Memoir will not report success for an incomplete deletion.'), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); }
175
+ console.log('\n' + boxen(
176
+ gradient.pastel(' memoir cloud ') + '\n\n' +
177
+ chalk.green('✔ Memoir cloud data deleted and this device signed out.') + '\n' +
178
+ chalk.gray('Backups, shared links, and subscription data accessible to this account were removed.') + '\n\n' +
179
+ chalk.yellow('Your authentication identity may still exist in Supabase Auth (removing the login record requires server-side admin access).'),
180
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
181
+ ) + '\n');
182
+ } catch (error) {
183
+ console.log('\n' + boxen(
184
+ chalk.red('✖ Deletion incomplete: ' + error.message) + '\n' +
185
+ chalk.gray('Retry the command. Memoir will not report success for an incomplete deletion.'),
186
+ { padding: 1, borderStyle: 'round', borderColor: 'red' }
187
+ ) + '\n');
188
+ }
62
189
  }
@@ -298,7 +298,7 @@ export async function restoreCommand(options = {}) {
298
298
  }
299
299
 
300
300
  async function restoreFromShare(options) {
301
- const shareToken = encodeURIComponent(options.from);
301
+ const rawToken = String(options.from || '').trim();
302
302
 
303
303
  console.log();
304
304
  const spinner = ora({ text: chalk.gray('Fetching share link...'), spinner: 'dots' }).start();
@@ -307,14 +307,16 @@ async function restoreFromShare(options) {
307
307
  await fs.ensureDir(stagingDir);
308
308
 
309
309
  try {
310
- // Fetch share metadata from Supabase
310
+ // Fetch share metadata via token-scoped RPC (shared_links table does not allow table-wide anonymous SELECT)
311
311
  const metaRes = await fetch(
312
- `${SUPABASE_URL}/rest/v1/shared_links?select=*&token=eq.${shareToken}&limit=1`,
312
+ `${SUPABASE_URL}/rest/v1/rpc/memoir_get_shared_link`,
313
313
  {
314
+ method: 'POST',
314
315
  headers: {
315
316
  'apikey': SUPABASE_ANON_KEY,
316
317
  'Content-Type': 'application/json',
317
318
  },
319
+ body: JSON.stringify({ link_token: rawToken }),
318
320
  }
319
321
  );
320
322
 
@@ -445,15 +447,15 @@ async function restoreFromShare(options) {
445
447
  await fs.remove(decryptedDir);
446
448
  }
447
449
 
448
- // Increment use_count
450
+ // Increment use_count via token-scoped RPC
449
451
  try {
450
- const patchHeaders = { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' };
451
- if (session) patchHeaders['Authorization'] = `Bearer ${session.access_token}`;
452
+ const rpcHeaders = { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' };
453
+ if (session) rpcHeaders['Authorization'] = `Bearer ${session.access_token}`;
452
454
 
453
- await fetch(`${SUPABASE_URL}/rest/v1/shared_links?token=eq.${shareToken}`, {
454
- method: 'PATCH',
455
- headers: patchHeaders,
456
- body: JSON.stringify({ use_count: shareLink.use_count + 1 }),
455
+ await fetch(`${SUPABASE_URL}/rest/v1/rpc/memoir_consume_shared_link`, {
456
+ method: 'POST',
457
+ headers: rpcHeaders,
458
+ body: JSON.stringify({ link_token: rawToken }),
457
459
  });
458
460
  } catch {
459
461
  // Best-effort — don't fail restore if count update fails
@@ -89,7 +89,7 @@ export async function shareCommand(options = {}) {
89
89
  const gzipped = await bundleDir(encryptedDir);
90
90
 
91
91
  const shareToken = crypto.randomUUID();
92
- const storagePath = `shares/${session.user.id}/${shareToken}.gz`;
92
+ const storagePath = `${session.user.id}/shares/${shareToken}.gz`;
93
93
 
94
94
  const uploadRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${storagePath}`, {
95
95
  method: 'POST',
@@ -0,0 +1,113 @@
1
+ -- Fix account-deletion RLS (allow authenticated users to delete their own
2
+ -- shared_links and subscriptions rows) and close permissive shared_links
3
+ -- SELECT/UPDATE policies that allowed full-table enumeration and arbitrary
4
+ -- column mutation.
5
+ begin;
6
+
7
+ -- 1. subscriptions: allow owners to delete their own subscription row on account deletion
8
+ drop policy if exists "Users delete own subscription" on public.subscriptions;
9
+ create policy "Users delete own subscription"
10
+ on public.subscriptions
11
+ for delete
12
+ to authenticated
13
+ using ((select auth.uid()) = user_id);
14
+
15
+ -- 2. shared_links: replace world-readable / world-writable policies with owner-only RLS
16
+ drop policy if exists "Anyone can increment use count" on public.shared_links;
17
+ drop policy if exists "Anyone can read shared links" on public.shared_links;
18
+ drop policy if exists "Authenticated users can create shared links" on public.shared_links;
19
+ drop policy if exists "Users read own shared links" on public.shared_links;
20
+ drop policy if exists "Users delete own shared links" on public.shared_links;
21
+
22
+ create policy "Authenticated users can create shared links"
23
+ on public.shared_links
24
+ for insert
25
+ to authenticated
26
+ with check ((select auth.uid()) = created_by);
27
+
28
+ create policy "Users read own shared links"
29
+ on public.shared_links
30
+ for select
31
+ to authenticated
32
+ using ((select auth.uid()) = created_by);
33
+
34
+ create policy "Users delete own shared links"
35
+ on public.shared_links
36
+ for delete
37
+ to authenticated
38
+ using ((select auth.uid()) = created_by);
39
+
40
+ -- Token-scoped share lookup so callers with an exact share token can inspect
41
+ -- a link (including expired/exhausted status) without table-wide SELECT.
42
+ create or replace function public.memoir_get_shared_link(link_token text)
43
+ returns setof public.shared_links
44
+ language sql
45
+ security definer
46
+ set search_path = ''
47
+ as $$
48
+ select *
49
+ from public.shared_links
50
+ where token = link_token
51
+ limit 1;
52
+ $$;
53
+
54
+ revoke all on function public.memoir_get_shared_link(text) from public;
55
+ grant execute on function public.memoir_get_shared_link(text) to anon, authenticated;
56
+
57
+ -- Atomic, token-scoped use_count increment so callers with an exact share token
58
+ -- can redeem a link without granting table-wide UPDATE.
59
+ create or replace function public.memoir_consume_shared_link(link_token text)
60
+ returns setof public.shared_links
61
+ language plpgsql
62
+ security definer
63
+ set search_path = ''
64
+ as $$
65
+ begin
66
+ if link_token is null or length(trim(link_token)) = 0 then
67
+ return;
68
+ end if;
69
+
70
+ return query
71
+ update public.shared_links
72
+ set use_count = coalesce(use_count, 0) + 1
73
+ where token = link_token
74
+ and expires_at > now()
75
+ and coalesce(use_count, 0) < coalesce(max_uses, 5)
76
+ returning *;
77
+ end;
78
+ $$;
79
+
80
+ revoke all on function public.memoir_consume_shared_link(text) from public;
81
+ grant execute on function public.memoir_consume_shared_link(text) to anon, authenticated;
82
+
83
+ -- Helper for storage.objects SELECT policy on active shared bundles under <uid>/shares/<token>.gz
84
+ create or replace function public.memoir_can_read_shared_object(object_name text)
85
+ returns boolean
86
+ language sql
87
+ security definer
88
+ set search_path = ''
89
+ as $$
90
+ select exists (
91
+ select 1
92
+ from public.shared_links sl
93
+ where sl.backup_id = object_name
94
+ and sl.expires_at > now()
95
+ and coalesce(sl.use_count, 0) < coalesce(sl.max_uses, 5)
96
+ );
97
+ $$;
98
+
99
+ revoke all on function public.memoir_can_read_shared_object(text) from public;
100
+ grant execute on function public.memoir_can_read_shared_object(text) to anon, authenticated;
101
+
102
+ drop policy if exists "Recipients read active shared bundles" on storage.objects;
103
+ create policy "Recipients read active shared bundles"
104
+ on storage.objects
105
+ for select
106
+ to anon, authenticated
107
+ using (
108
+ bucket_id = 'memoir-backups'
109
+ and (storage.foldername(name))[2] = 'shares'
110
+ and public.memoir_can_read_shared_object(name)
111
+ );
112
+
113
+ commit;