memoir-cli 3.17.3 → 3.17.5
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/cloud/auth.js +190 -23
- package/src/cloud/constants.js +3 -0
- package/src/commands/cloud.js +15 -6
- package/src/commands/login.js +149 -22
- package/src/commands/restore.js +38 -27
- package/src/commands/share.js +1 -1
- package/supabase/migrations/202610180001_cloud_delete_and_share_rls.sql +113 -0
- package/supabase/migrations/202610190001_share_download_via_edge_function.sql +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.17.
|
|
3
|
+
"version": "3.17.5",
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from './constants.js';
|
|
4
|
+
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET, SUPPORT_EMAIL } from './constants.js';
|
|
5
5
|
|
|
6
6
|
const isWin = process.platform === 'win32';
|
|
7
7
|
const configDir = isWin
|
|
@@ -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)}`, {
|
|
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', {
|
|
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', {
|
|
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 = {
|
|
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 {
|
|
60
|
-
|
|
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() {
|
|
66
|
-
|
|
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: {
|
|
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)}`, {
|
|
84
|
-
|
|
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,146 @@ 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 = {
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
// Deleting data does not cancel a Stripe subscription, and the billing
|
|
187
|
+
// webhook re-creates the subscription row on the next renewal. Stop before
|
|
188
|
+
// deleting anything while the account is paying, or while the plan can't be read.
|
|
189
|
+
let sub;
|
|
190
|
+
try {
|
|
191
|
+
sub = await getSubscription(session);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new Error(`Could not check your plan (${error.message.replace(/ Existing backups were not pruned\.$/, '')}). Nothing was deleted; retry account deletion.`);
|
|
194
|
+
}
|
|
195
|
+
if (sub.status === 'pro') {
|
|
196
|
+
throw new Error(`Your Pro subscription is still active, and deleting your data would not stop billing. Email ${SUPPORT_EMAIL} to cancel it, then run this again. Nothing was deleted.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const backupsRes = await fetch(
|
|
200
|
+
`${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${encodeURIComponent(session.user.id)}`,
|
|
201
|
+
{ headers }
|
|
202
|
+
);
|
|
203
|
+
if (!backupsRes.ok) {
|
|
204
|
+
throw new Error(`Could not enumerate backups (${backupsRes.status}); account deletion stopped safely.`);
|
|
205
|
+
}
|
|
101
206
|
const backups = await backupsRes.json();
|
|
102
207
|
|
|
103
208
|
for (const backup of backups) {
|
|
104
|
-
if (typeof backup.storage_path !== 'string' || !backup.storage_path.startsWith(session.user.id + '/'))
|
|
105
|
-
|
|
106
|
-
|
|
209
|
+
if (typeof backup.storage_path !== 'string' || !backup.storage_path.startsWith(session.user.id + '/')) {
|
|
210
|
+
throw new Error('Unsafe backup path; account deletion stopped.');
|
|
211
|
+
}
|
|
212
|
+
const objectRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
|
|
213
|
+
method: 'DELETE',
|
|
214
|
+
headers: {
|
|
215
|
+
...headers,
|
|
216
|
+
'Content-Type': 'application/json',
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({ prefixes: [backup.storage_path] }),
|
|
107
219
|
});
|
|
108
|
-
await requireDelete(
|
|
109
|
-
|
|
110
|
-
|
|
220
|
+
await requireDelete(objectRes, 'Backup object deletion');
|
|
221
|
+
|
|
222
|
+
const rowRes = await fetch(
|
|
223
|
+
`${SUPABASE_URL}/rest/v1/backups?id=eq.${encodeURIComponent(backup.id)}`,
|
|
224
|
+
{ method: 'DELETE', headers: deleteHeaders }
|
|
225
|
+
);
|
|
226
|
+
await requireRowDelete(rowRes, 'Backup metadata deletion', { expectedMinRows: 1 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Share bundles go before their rows, like backups above: if a bundle
|
|
230
|
+
// delete fails, the row is still there for the retry to find.
|
|
231
|
+
const linksRes = await fetch(
|
|
232
|
+
`${SUPABASE_URL}/rest/v1/shared_links?select=id,backup_id&created_by=eq.${encodeURIComponent(session.user.id)}`,
|
|
233
|
+
{ headers }
|
|
234
|
+
);
|
|
235
|
+
if (!linksRes.ok) {
|
|
236
|
+
throw new Error(`Could not enumerate shared links (${linksRes.status}). Nothing else was reported as deleted; retry account deletion.`);
|
|
237
|
+
}
|
|
238
|
+
const links = await linksRes.json();
|
|
239
|
+
if (!Array.isArray(links)) {
|
|
240
|
+
throw new Error('Could not enumerate shared links. Nothing else was reported as deleted; retry account deletion.');
|
|
241
|
+
}
|
|
242
|
+
for (const link of links) {
|
|
243
|
+
if (typeof link?.backup_id === 'string' && link.backup_id.startsWith(session.user.id + '/')) {
|
|
244
|
+
const shareObjRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
|
|
245
|
+
method: 'DELETE',
|
|
246
|
+
headers: {
|
|
247
|
+
...headers,
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({ prefixes: [link.backup_id] }),
|
|
251
|
+
});
|
|
252
|
+
await requireDelete(shareObjRes, 'Shared bundle object deletion');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const sharedRes = await fetch(
|
|
257
|
+
`${SUPABASE_URL}/rest/v1/shared_links?created_by=eq.${encodeURIComponent(session.user.id)}`,
|
|
258
|
+
{ method: 'DELETE', headers: deleteHeaders }
|
|
259
|
+
);
|
|
260
|
+
const deletedLinks = await requireRowDelete(sharedRes, 'Shared-link deletion');
|
|
261
|
+
if (deletedLinks.length < links.length) {
|
|
262
|
+
await verifyNoneRemaining(
|
|
263
|
+
`/rest/v1/shared_links?select=id&created_by=eq.${encodeURIComponent(session.user.id)}&limit=1`,
|
|
264
|
+
headers,
|
|
265
|
+
'Shared-link deletion'
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const subRes = await fetch(
|
|
270
|
+
`${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${encodeURIComponent(session.user.id)}`,
|
|
271
|
+
{ method: 'DELETE', headers: deleteHeaders }
|
|
272
|
+
);
|
|
273
|
+
const deletedSubs = await requireRowDelete(subRes, 'Subscription deletion');
|
|
274
|
+
if (deletedSubs.length === 0) {
|
|
275
|
+
await verifyNoneRemaining(
|
|
276
|
+
`/rest/v1/subscriptions?select=user_id&user_id=eq.${encodeURIComponent(session.user.id)}&limit=1`,
|
|
277
|
+
headers,
|
|
278
|
+
'Subscription deletion'
|
|
279
|
+
);
|
|
111
280
|
}
|
|
112
281
|
|
|
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
282
|
await logout();
|
|
116
283
|
return { localDataDeleted: true, authUserDeleted: false };
|
|
117
284
|
}
|
package/src/cloud/constants.js
CHANGED
|
@@ -7,3 +7,6 @@ export const STORAGE_BUCKET = 'memoir-backups';
|
|
|
7
7
|
// Free is a safety net; Pro is "full version history" (memoir.sh pricing).
|
|
8
8
|
export const MAX_BACKUPS_FREE = 10;
|
|
9
9
|
export const MAX_BACKUPS_PRO = 100;
|
|
10
|
+
// Public contact on memoir.sh (forwards via ImprovMX). Used where the CLI
|
|
11
|
+
// can't finish a job itself, e.g. cancelling a Pro subscription.
|
|
12
|
+
export const SUPPORT_EMAIL = 'hello@memoir.sh';
|
package/src/commands/cloud.js
CHANGED
|
@@ -26,11 +26,20 @@ export async function cloudPushCommand(options = {}) {
|
|
|
26
26
|
throw new Error('Cloud login required');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
package/src/commands/login.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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)
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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() {
|
|
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) {
|
|
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([{
|
|
56
|
-
|
|
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(
|
|
61
|
-
|
|
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
|
}
|
package/src/commands/restore.js
CHANGED
|
@@ -6,13 +6,12 @@ import ora from 'ora';
|
|
|
6
6
|
import boxen from 'boxen';
|
|
7
7
|
import gradient from 'gradient-string';
|
|
8
8
|
import inquirer from 'inquirer';
|
|
9
|
-
import { readSafeFile, writeSafeFile
|
|
9
|
+
import { readSafeFile, writeSafeFile } from '../security/files.js';
|
|
10
10
|
import { getConfig, autoSetup } from '../config.js';
|
|
11
11
|
import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
|
|
12
12
|
import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
|
|
13
13
|
import { detectLocalHomeKey } from '../adapters/restore.js';
|
|
14
14
|
import { restoreWorkspace } from '../workspace/tracker.js';
|
|
15
|
-
import { getSession } from '../cloud/auth.js';
|
|
16
15
|
import { unbundleToDir, readBoundedResponse } from '../cloud/storage.js';
|
|
17
16
|
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
|
|
18
17
|
import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
|
|
@@ -298,7 +297,7 @@ export async function restoreCommand(options = {}) {
|
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
async function restoreFromShare(options) {
|
|
301
|
-
const
|
|
300
|
+
const rawToken = String(options.from || '').trim();
|
|
302
301
|
|
|
303
302
|
console.log();
|
|
304
303
|
const spinner = ora({ text: chalk.gray('Fetching share link...'), spinner: 'dots' }).start();
|
|
@@ -307,14 +306,16 @@ async function restoreFromShare(options) {
|
|
|
307
306
|
await fs.ensureDir(stagingDir);
|
|
308
307
|
|
|
309
308
|
try {
|
|
310
|
-
// Fetch share metadata
|
|
309
|
+
// Fetch share metadata via token-scoped RPC (shared_links table does not allow table-wide anonymous SELECT)
|
|
311
310
|
const metaRes = await fetch(
|
|
312
|
-
`${SUPABASE_URL}/rest/v1/
|
|
311
|
+
`${SUPABASE_URL}/rest/v1/rpc/memoir_get_shared_link`,
|
|
313
312
|
{
|
|
313
|
+
method: 'POST',
|
|
314
314
|
headers: {
|
|
315
315
|
'apikey': SUPABASE_ANON_KEY,
|
|
316
316
|
'Content-Type': 'application/json',
|
|
317
317
|
},
|
|
318
|
+
body: JSON.stringify({ link_token: rawToken }),
|
|
318
319
|
}
|
|
319
320
|
);
|
|
320
321
|
|
|
@@ -370,16 +371,40 @@ async function restoreFromShare(options) {
|
|
|
370
371
|
// Download the backup
|
|
371
372
|
spinner.start(chalk.gray('Downloading share bundle...'));
|
|
372
373
|
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
374
|
+
// Share bundles are not readable through Storage directly: a read rule
|
|
375
|
+
// for recipients would also let anyone list every bundle name, and each
|
|
376
|
+
// name contains its token. The share-download function checks the exact
|
|
377
|
+
// token, uses up one use, and returns a short-lived signed URL.
|
|
378
|
+
const grantRes = await fetch(`${SUPABASE_URL}/functions/v1/share-download`, {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
headers: {
|
|
381
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
382
|
+
'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
|
|
383
|
+
'Content-Type': 'application/json',
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({ token: rawToken }),
|
|
381
386
|
});
|
|
387
|
+
if (grantRes.status === 404) {
|
|
388
|
+
spinner.stop();
|
|
389
|
+
console.log('\n' + boxen(
|
|
390
|
+
chalk.red('✖ Share link no longer available\n\n') +
|
|
391
|
+
chalk.gray('It may have expired, been used up, or been deleted.'),
|
|
392
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'red' }
|
|
393
|
+
) + '\n');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!grantRes.ok) {
|
|
397
|
+
throw new Error(`Download failed (${grantRes.status})`);
|
|
398
|
+
}
|
|
399
|
+
const grant = await grantRes.json();
|
|
400
|
+
const signedUrl = typeof grant?.url === 'string' ? new URL(grant.url) : null;
|
|
401
|
+
if (!signedUrl
|
|
402
|
+
|| signedUrl.origin !== new URL(SUPABASE_URL).origin
|
|
403
|
+
|| !signedUrl.pathname.startsWith(`/storage/v1/object/sign/${STORAGE_BUCKET}/`)) {
|
|
404
|
+
throw new Error('Download failed: unexpected download URL');
|
|
405
|
+
}
|
|
382
406
|
|
|
407
|
+
const dlRes = await fetch(signedUrl);
|
|
383
408
|
if (!dlRes.ok) {
|
|
384
409
|
throw new Error(`Download failed: ${await dlRes.text()}`);
|
|
385
410
|
}
|
|
@@ -445,20 +470,6 @@ async function restoreFromShare(options) {
|
|
|
445
470
|
await fs.remove(decryptedDir);
|
|
446
471
|
}
|
|
447
472
|
|
|
448
|
-
// Increment use_count
|
|
449
|
-
try {
|
|
450
|
-
const patchHeaders = { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' };
|
|
451
|
-
if (session) patchHeaders['Authorization'] = `Bearer ${session.access_token}`;
|
|
452
|
-
|
|
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 }),
|
|
457
|
-
});
|
|
458
|
-
} catch {
|
|
459
|
-
// Best-effort — don't fail restore if count update fails
|
|
460
|
-
}
|
|
461
|
-
|
|
462
473
|
spinner.stop();
|
|
463
474
|
|
|
464
475
|
if (restored) {
|
package/src/commands/share.js
CHANGED
|
@@ -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 =
|
|
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;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Share bundles: replace the anonymous Storage read rule from
|
|
2
|
+
-- 202610180001 with the share-download edge function
|
|
3
|
+
-- (supabase/functions/share-download). The rule let anyone list every active
|
|
4
|
+
-- bundle, and each bundle name contains its share token.
|
|
5
|
+
drop policy if exists "Recipients read active shared bundles" on storage.objects;
|
|
6
|
+
drop function if exists public.memoir_can_read_shared_object(text);
|
|
7
|
+
|
|
8
|
+
-- Only share-download (service role) uses up a share now, at download time.
|
|
9
|
+
revoke execute on function public.memoir_consume_shared_link(text) from anon, authenticated;
|