memoir-cli 3.17.2 → 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 +1 -1
- package/src/cloud/auth.js +115 -44
- package/src/commands/cloud.js +15 -6
- package/src/commands/login.js +35 -19
- package/src/commands/restore.js +12 -10
- package/src/commands/share.js +1 -1
- package/src/commands/upgrade.js +4 -6
- package/supabase/migrations/202610180001_cloud_delete_and_share_rls.sql +113 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.17.
|
|
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
|
@@ -11,7 +11,7 @@ const AUTH_FILE = path.join(configDir, 'auth.json');
|
|
|
11
11
|
|
|
12
12
|
async function supaFetch(endpoint, options = {}) {
|
|
13
13
|
const url = `${SUPABASE_URL}${endpoint}`;
|
|
14
|
-
|
|
14
|
+
return fetch(url, {
|
|
15
15
|
...options,
|
|
16
16
|
headers: {
|
|
17
17
|
'apikey': SUPABASE_ANON_KEY,
|
|
@@ -19,7 +19,6 @@ async function supaFetch(endpoint, options = {}) {
|
|
|
19
19
|
...options.headers,
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
|
-
return res;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
// GoTrue's REST API takes the post-email landing URL as a `redirect_to`
|
|
@@ -106,14 +105,19 @@ export async function isLoggedIn() {
|
|
|
106
105
|
return !!session;
|
|
107
106
|
}
|
|
108
107
|
|
|
108
|
+
// A failed plan lookup is not evidence that a user is on Free. Callers use
|
|
109
|
+
// this value to choose destructive retention limits, so fail closed instead of
|
|
110
|
+
// silently downgrading a Pro account and pruning its history.
|
|
109
111
|
export async function getSubscription(session) {
|
|
110
|
-
const res = await supaFetch('/rest/v1/subscriptions?select=*&user_id=eq.' + session.user.id, {
|
|
112
|
+
const res = await supaFetch('/rest/v1/subscriptions?select=*&user_id=eq.' + encodeURIComponent(session.user.id), {
|
|
111
113
|
headers: {
|
|
112
114
|
'Authorization': `Bearer ${session.access_token}`,
|
|
113
115
|
},
|
|
114
116
|
});
|
|
117
|
+
if (!res.ok) throw new Error(`Could not verify subscription (${res.status}). Existing backups were not pruned.`);
|
|
115
118
|
const data = await res.json();
|
|
116
|
-
if (!
|
|
119
|
+
if (!Array.isArray(data)) throw new Error('Could not verify subscription. Existing backups were not pruned.');
|
|
120
|
+
if (!data.length) return { status: 'free' };
|
|
117
121
|
return data[0];
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -128,61 +132,128 @@ export async function resetPassword(email) {
|
|
|
128
132
|
}
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
async function requireDelete(res, label) {
|
|
136
|
+
if (res.ok || res.status === 404) return;
|
|
137
|
+
let detail = '';
|
|
138
|
+
try { detail = (await res.text()).trim(); } catch {}
|
|
139
|
+
throw new Error(`${label} failed (${res.status})${detail ? ': ' + detail : ''}. Nothing else was reported as deleted; retry account deletion.`);
|
|
140
|
+
}
|
|
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
|
+
|
|
173
|
+
// Deletes the data the current user is authorized to delete. Auth-user removal
|
|
174
|
+
// itself requires a privileged server-side endpoint and must never be attempted
|
|
175
|
+
// with a service-role key from this CLI.
|
|
131
176
|
export async function deleteAccount(session) {
|
|
132
|
-
|
|
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
|
+
|
|
133
186
|
const backupsRes = await fetch(
|
|
134
|
-
`${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${session.user.id}`,
|
|
135
|
-
{
|
|
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
|
+
}
|
|
193
|
+
const backups = await backupsRes.json();
|
|
194
|
+
|
|
195
|
+
for (const backup of backups) {
|
|
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',
|
|
136
201
|
headers: {
|
|
137
|
-
|
|
138
|
-
'
|
|
202
|
+
...headers,
|
|
203
|
+
'Content-Type': 'application/json',
|
|
139
204
|
},
|
|
140
|
-
|
|
141
|
-
|
|
205
|
+
body: JSON.stringify({ prefixes: [backup.storage_path] }),
|
|
206
|
+
});
|
|
207
|
+
await requireDelete(objectRes, 'Backup object deletion');
|
|
142
208
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
headers: {
|
|
150
|
-
'Authorization': `Bearer ${session.access_token}`,
|
|
151
|
-
'apikey': SUPABASE_ANON_KEY,
|
|
152
|
-
},
|
|
153
|
-
});
|
|
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
|
+
}
|
|
154
215
|
|
|
155
|
-
|
|
156
|
-
|
|
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}`, {
|
|
157
231
|
method: 'DELETE',
|
|
158
232
|
headers: {
|
|
159
|
-
|
|
160
|
-
'
|
|
233
|
+
...headers,
|
|
234
|
+
'Content-Type': 'application/json',
|
|
161
235
|
},
|
|
236
|
+
body: JSON.stringify({ prefixes: [link.backup_id] }),
|
|
162
237
|
});
|
|
238
|
+
await requireDelete(shareObjRes, 'Shared bundle object deletion');
|
|
163
239
|
}
|
|
164
240
|
}
|
|
165
241
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
method: 'DELETE',
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
headers: {
|
|
179
|
-
'Authorization': `Bearer ${session.access_token}`,
|
|
180
|
-
'apikey': SUPABASE_ANON_KEY,
|
|
181
|
-
},
|
|
182
|
-
});
|
|
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
|
+
);
|
|
253
|
+
}
|
|
183
254
|
|
|
184
|
-
// 4. Sign out locally
|
|
185
255
|
await logout();
|
|
256
|
+
return { localDataDeleted: true, authUserDeleted: false };
|
|
186
257
|
}
|
|
187
258
|
|
|
188
259
|
export { AUTH_FILE, supaFetch };
|
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
|
@@ -5,29 +5,35 @@ import inquirer from 'inquirer';
|
|
|
5
5
|
import { signIn, signUp, saveSession, getSession, logout, getSubscription, resetPassword, deleteAccount } from '../cloud/auth.js';
|
|
6
6
|
|
|
7
7
|
export async function loginCommand(options = {}) {
|
|
8
|
-
// Check if already logged in
|
|
9
8
|
const existing = await getSession();
|
|
10
9
|
if (existing) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
try {
|
|
11
|
+
const sub = await getSubscription(existing);
|
|
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');
|
|
18
|
+
} catch (error) {
|
|
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');
|
|
25
|
+
}
|
|
18
26
|
return;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
let action, email, password;
|
|
22
30
|
|
|
23
|
-
// Support non-interactive login via flags
|
|
24
31
|
if (options.email && options.password) {
|
|
25
32
|
action = options.signup ? 'signup' : 'signin';
|
|
26
33
|
email = options.email;
|
|
27
34
|
password = options.password;
|
|
28
35
|
} else {
|
|
29
36
|
console.log();
|
|
30
|
-
|
|
31
37
|
const actionAnswer = await inquirer.prompt([{
|
|
32
38
|
type: 'list',
|
|
33
39
|
name: 'action',
|
|
@@ -78,16 +84,25 @@ export async function loginCommand(options = {}) {
|
|
|
78
84
|
session = await saveSession(result);
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
let plan = 'Unknown';
|
|
88
|
+
try {
|
|
89
|
+
const sub = await getSubscription(session);
|
|
90
|
+
plan = sub.status === 'pro' ? 'Pro' : 'Free';
|
|
91
|
+
} catch {}
|
|
82
92
|
|
|
83
93
|
console.log('\n' + boxen(
|
|
84
94
|
gradient.pastel(' memoir cloud ') + '\n\n' +
|
|
85
95
|
chalk.green('✔ Logged in as ') + chalk.cyan(session.user.email) + '\n' +
|
|
86
|
-
chalk.gray('Plan: ') + (
|
|
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' +
|
|
87
103
|
chalk.gray('Try: ') + chalk.cyan('memoir cloud push') + chalk.gray(' to back up to the cloud'),
|
|
88
104
|
{ padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
89
105
|
) + '\n');
|
|
90
|
-
|
|
91
106
|
} catch (error) {
|
|
92
107
|
console.log('\n' + boxen(
|
|
93
108
|
chalk.red('✖ ' + error.message),
|
|
@@ -146,27 +161,28 @@ export async function deleteAccountCommand(options = {}) {
|
|
|
146
161
|
const answer = await inquirer.prompt([{
|
|
147
162
|
type: 'input',
|
|
148
163
|
name: 'confirmation',
|
|
149
|
-
message: 'Type DELETE to
|
|
164
|
+
message: 'Type DELETE to remove your Memoir cloud data and sign out:',
|
|
150
165
|
}]);
|
|
151
166
|
|
|
152
167
|
if (answer.confirmation !== 'DELETE') {
|
|
153
|
-
console.log('\n' + chalk.gray(' Account deletion cancelled.') + '\n');
|
|
168
|
+
console.log('\n' + chalk.gray(' Account-data deletion cancelled.') + '\n');
|
|
154
169
|
return;
|
|
155
170
|
}
|
|
156
171
|
}
|
|
157
172
|
|
|
158
173
|
try {
|
|
159
174
|
await deleteAccount(session);
|
|
160
|
-
|
|
161
175
|
console.log('\n' + boxen(
|
|
162
176
|
gradient.pastel(' memoir cloud ') + '\n\n' +
|
|
163
|
-
chalk.green('✔
|
|
164
|
-
chalk.gray('
|
|
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).'),
|
|
165
180
|
{ padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
166
181
|
) + '\n');
|
|
167
182
|
} catch (error) {
|
|
168
183
|
console.log('\n' + boxen(
|
|
169
|
-
chalk.red('✖ ' + error.message)
|
|
184
|
+
chalk.red('✖ Deletion incomplete: ' + error.message) + '\n' +
|
|
185
|
+
chalk.gray('Retry the command. Memoir will not report success for an incomplete deletion.'),
|
|
170
186
|
{ padding: 1, borderStyle: 'round', borderColor: 'red' }
|
|
171
187
|
) + '\n');
|
|
172
188
|
}
|
package/src/commands/restore.js
CHANGED
|
@@ -298,7 +298,7 @@ export async function restoreCommand(options = {}) {
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
async function restoreFromShare(options) {
|
|
301
|
-
const
|
|
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
|
|
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/
|
|
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
|
|
451
|
-
if (session)
|
|
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/
|
|
454
|
-
method: '
|
|
455
|
-
headers:
|
|
456
|
-
body: JSON.stringify({
|
|
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
|
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',
|
package/src/commands/upgrade.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
1
2
|
import chalk from 'chalk';
|
|
2
3
|
import boxen from 'boxen';
|
|
3
4
|
import gradient from 'gradient-string';
|
|
@@ -18,13 +19,10 @@ async function createCheckoutSession(session) {
|
|
|
18
19
|
return data.url;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
// No shell
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// being one. execFile passes the URL as a single argument; on Windows
|
|
25
|
-
// rundll32's URL handler avoids cmd.exe parsing `&` inside the query string.
|
|
22
|
+
// No shell interpolation: execFile passes the URL as a discrete argument
|
|
23
|
+
// array; on Windows rundll32's URL handler avoids cmd.exe parsing `&` inside
|
|
24
|
+
// the query string.
|
|
26
25
|
function openUrl(url) {
|
|
27
|
-
const { execFile } = require('child_process');
|
|
28
26
|
const platform = process.platform;
|
|
29
27
|
const [cmd, args] = platform === 'darwin' ? ['open', [url]]
|
|
30
28
|
: platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
@@ -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;
|