memoir-cli 3.17.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.17.4",
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
@@ -183,6 +183,19 @@ export async function deleteAccount(session) {
183
183
  'Prefer': 'return=representation',
184
184
  };
185
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
+
186
199
  const backupsRes = await fetch(
187
200
  `${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${encodeURIComponent(session.user.id)}`,
188
201
  { headers }
@@ -213,19 +226,20 @@ export async function deleteAccount(session) {
213
226
  await requireRowDelete(rowRes, 'Backup metadata deletion', { expectedMinRows: 1 });
214
227
  }
215
228
 
216
- const sharedRes = await fetch(
217
- `${SUPABASE_URL}/rest/v1/shared_links?created_by=eq.${encodeURIComponent(session.user.id)}`,
218
- { method: 'DELETE', headers: deleteHeaders }
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 }
219
234
  );
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
- );
235
+ if (!linksRes.ok) {
236
+ throw new Error(`Could not enumerate shared links (${linksRes.status}). Nothing else was reported as deleted; retry account deletion.`);
227
237
  }
228
- for (const link of deletedLinks) {
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) {
229
243
  if (typeof link?.backup_id === 'string' && link.backup_id.startsWith(session.user.id + '/')) {
230
244
  const shareObjRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
231
245
  method: 'DELETE',
@@ -239,6 +253,19 @@ export async function deleteAccount(session) {
239
253
  }
240
254
  }
241
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
+
242
269
  const subRes = await fetch(
243
270
  `${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${encodeURIComponent(session.user.id)}`,
244
271
  { method: 'DELETE', headers: deleteHeaders }
@@ -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';
@@ -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, relativeFile } from '../security/files.js';
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';
@@ -372,16 +371,40 @@ async function restoreFromShare(options) {
372
371
  // Download the backup
373
372
  spinner.start(chalk.gray('Downloading share bundle...'));
374
373
 
375
- // Try authenticated first, fall back to anon key
376
- const session = await getSession();
377
- const authHeaders = session
378
- ? { 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY }
379
- : { 'apikey': SUPABASE_ANON_KEY };
380
-
381
- const dlRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${relativeFile(shareLink.backup_id).split('/').map(encodeURIComponent).join('/')}`, {
382
- headers: authHeaders,
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 }),
383
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
+ }
384
406
 
407
+ const dlRes = await fetch(signedUrl);
385
408
  if (!dlRes.ok) {
386
409
  throw new Error(`Download failed: ${await dlRes.text()}`);
387
410
  }
@@ -447,20 +470,6 @@ async function restoreFromShare(options) {
447
470
  await fs.remove(decryptedDir);
448
471
  }
449
472
 
450
- // Increment use_count via token-scoped RPC
451
- try {
452
- const rpcHeaders = { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' };
453
- if (session) rpcHeaders['Authorization'] = `Bearer ${session.access_token}`;
454
-
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 }),
459
- });
460
- } catch {
461
- // Best-effort — don't fail restore if count update fails
462
- }
463
-
464
473
  spinner.stop();
465
474
 
466
475
  if (restored) {
@@ -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;