memoir-cli 3.17.5 → 3.17.6

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/README.md CHANGED
@@ -113,7 +113,7 @@ Local pushes lock the complete read/merge/write operation. Git rejects conflicti
113
113
 
114
114
  ### Cloud
115
115
 
116
- New cloud writes require a user-held secret of at least 12 characters in `MEMOIR_CLOUD_PASSPHRASE` (or `MEMOIR_PASSPHRASE`). It is not derived from account identity or sent in metadata.
116
+ New cloud writes require a user-held secret of at least 12 characters in `MEMOIR_CLOUD_PASSPHRASE` (or `MEMOIR_PASSPHRASE`). It is not derived from account identity or sent in metadata. Client-side encryption is available on every plan; it is not Pro-only.
117
117
 
118
118
  ```sh
119
119
  memoir cloud push
@@ -163,7 +163,7 @@ Memory is context, not permission to run commands or configure tools. Model-writ
163
163
 
164
164
  With the SessionStart hook installed (`memoir hooks install`), `memoir auto-refresh` prints a short session brief on stdout, which Claude Code adds to the model's context. Delivery no longer depends on the model choosing to call `memoir_session`. The brief is capped (~300 tokens), silent when there is nothing to say, skipped on compaction, and disabled by `MEMOIR_NO_SESSION_BRIEF`. Preview it with `memoir auto-refresh --show-brief`. It scopes by the hand-written `[project]` text prefix first and the stored project field second; untagged items are shown under their own heading, not presented as facts about the current repository.
165
165
 
166
- `push --redact` heuristically redacts staged text, not originals or historical backups. Without it, ordinary memory backups warn and preserve content. Set `DO_NOT_TRACK=1` to disable remote telemetry. Local events distinguish execution failure from success; they do not measure answer usefulness.
166
+ `push --redact` heuristically redacts staged text, not originals or historical backups. Without it, ordinary memory backups warn and preserve content. Anonymous usage telemetry is **off by default** for new and existing installs. Enable it with `memoir telemetry on`; check with `memoir telemetry status`; turn it off with `memoir telemetry off`. `DO_NOT_TRACK=1` or `MEMOIR_TELEMETRY=0` always disable remote telemetry, even after opting in. No telemetry network calls are made while it is off, including on first run. Local events distinguish execution failure from success; they do not measure answer usefulness.
167
167
 
168
168
  Forgetting propagates on later sync between updated clients. Old clients, snapshots, and Git history can still contain deleted text. Purging every historical copy is separate. Native configuration restore is not atomic across multiple application directories.
169
169
 
package/bin/memoir.js CHANGED
@@ -161,7 +161,7 @@ program
161
161
  .command('share')
162
162
  .description('Share your AI memory via a secure link')
163
163
  .option('--only <tools>', 'Only share specific tools (comma-separated)')
164
- .option('--expires <hours>', 'Link expiry in hours (default: 24)')
164
+ .option('--expires <hours>', 'Link expiry in hours (default: 24, max: 720 / 30 days)')
165
165
  .option('--uses <number>', 'Max number of uses (default: 5)')
166
166
  .action(async (options) => {
167
167
  try {
@@ -576,7 +576,7 @@ const account = program.command('account').description('Manage your memoir accou
576
576
 
577
577
  account
578
578
  .command('delete')
579
- .description('Permanently delete your account and all cloud data')
579
+ .description('Permanently delete your account, cloud data, and login')
580
580
  .option('--confirm', 'Skip interactive prompt (Node 25 workaround)')
581
581
  .action(async (options) => {
582
582
  try {
@@ -766,7 +766,7 @@ program
766
766
 
767
767
  program
768
768
  .command('telemetry [action]')
769
- .description('Anonymous usage telemetry: `on`, `off`, or `status` (default)')
769
+ .description('Anonymous usage telemetry (off by default): `on`, `off`, or `status`')
770
770
  .action(async (action) => {
771
771
  try {
772
772
  await telemetryCommand(action || 'status');
@@ -778,8 +778,8 @@ program
778
778
 
779
779
  program.hook('postAction', async (thisCommand, actionCommand) => {
780
780
  await checkForUpdate();
781
- // Anonymous, opt-out usage event. postAction already awaits a network call
782
- // (checkForUpdate), so this adds no perceived latency; no-op without a key.
781
+ // Anonymous, opt-in usage event. postAction already awaits a network call
782
+ // (checkForUpdate), so this adds no perceived latency; no-op unless opted in.
783
783
  try { await track('cli_command', { command: actionCommand?.name?.() || 'unknown' }); } catch {}
784
784
  // Local twin (events.jsonl): command name only, so `memoir recall`/`why`
785
785
  // reads are visible next to the writes and syncs already logged there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.17.5",
3
+ "version": "3.17.6",
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
@@ -170,9 +170,10 @@ async function verifyNoneRemaining(endpoint, headers, label) {
170
170
  }
171
171
  }
172
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.
173
+ // Deletes the data the current user is authorized to delete, then asks the
174
+ // hosted `account-delete` function to remove the Auth login. That function
175
+ // uses the service role on the server to delete only the JWT subject. This
176
+ // CLI never sends a service-role key or a caller-chosen user id.
176
177
  export async function deleteAccount(session) {
177
178
  const headers = {
178
179
  'Authorization': `Bearer ${session.access_token}`,
@@ -279,8 +280,36 @@ export async function deleteAccount(session) {
279
280
  );
280
281
  }
281
282
 
283
+ const identityRes = await fetch(`${SUPABASE_URL}/functions/v1/account-delete`, {
284
+ method: 'POST',
285
+ headers: {
286
+ ...headers,
287
+ 'Content-Type': 'application/json',
288
+ },
289
+ body: '{}',
290
+ });
291
+ const identityText = await identityRes.text().catch(() => '');
292
+ let identityBody = {};
293
+ try { identityBody = identityText ? JSON.parse(identityText) : {}; } catch { identityBody = {}; }
294
+ if (!identityRes.ok) {
295
+ if (identityRes.status === 409 && identityBody.error === 'subscription_active') {
296
+ throw new Error(identityBody.message || `Your Pro subscription is still active. Email ${SUPPORT_EMAIL} to cancel it, then run this again. Nothing else was reported as deleted; retry account deletion.`);
297
+ }
298
+ const detail = identityBody.message || identityText.trim();
299
+ throw new Error(
300
+ `Auth identity deletion failed (${identityRes.status})${detail ? ': ' + detail : ''}. ` +
301
+ 'Cloud data for this account was already removed; retry account deletion to finish removing the login.'
302
+ );
303
+ }
304
+ if (identityBody?.deleted !== true) {
305
+ throw new Error(
306
+ 'Auth identity deletion failed: unexpected response. ' +
307
+ 'Cloud data for this account was already removed; retry account deletion to finish removing the login.'
308
+ );
309
+ }
310
+
282
311
  await logout();
283
- return { localDataDeleted: true, authUserDeleted: false };
312
+ return { localDataDeleted: true, authUserDeleted: true };
284
313
  }
285
314
 
286
315
  export { AUTH_FILE, supaFetch };
@@ -161,7 +161,7 @@ export async function deleteAccountCommand(options = {}) {
161
161
  const answer = await inquirer.prompt([{
162
162
  type: 'input',
163
163
  name: 'confirmation',
164
- message: 'Type DELETE to remove your Memoir cloud data and sign out:',
164
+ message: 'Type DELETE to remove your Memoir account (cloud data and login):',
165
165
  }]);
166
166
 
167
167
  if (answer.confirmation !== 'DELETE') {
@@ -171,12 +171,14 @@ export async function deleteAccountCommand(options = {}) {
171
171
  }
172
172
 
173
173
  try {
174
- await deleteAccount(session);
174
+ const result = await deleteAccount(session);
175
+ if (!result?.authUserDeleted) {
176
+ throw new Error('Account deletion did not confirm that the login was removed.');
177
+ }
175
178
  console.log('\n' + boxen(
176
179
  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
+ chalk.green('✔ Account deleted. Cloud data and this login were removed, and this device was signed out.') + '\n' +
181
+ chalk.gray('Backups, shared links, subscription data, and the authentication identity for this account were removed.'),
180
182
  { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
181
183
  ) + '\n');
182
184
  } catch (error) {
@@ -13,6 +13,16 @@ import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
13
13
  import { bundleDir } from '../cloud/storage.js';
14
14
  import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
15
15
 
16
+ // Privacy §8: shares are purged within 30 days of expiry or exhaustion.
17
+ // Cap lifetime so a single --expires value cannot outrun that window by years.
18
+ export const MAX_SHARE_EXPIRES_HOURS = 24 * 30;
19
+
20
+ export function normalizeShareExpiresHours(raw) {
21
+ const n = parseInt(raw, 10);
22
+ if (!Number.isFinite(n) || n <= 0) return 24;
23
+ return Math.min(n, MAX_SHARE_EXPIRES_HOURS);
24
+ }
25
+
16
26
  export async function shareCommand(options = {}) {
17
27
  // Must be logged in to share
18
28
  const session = await getSession();
@@ -107,7 +117,7 @@ export async function shareCommand(options = {}) {
107
117
  }
108
118
 
109
119
  // Parse options
110
- const expiresHours = parseInt(options.expires) || 24;
120
+ const expiresHours = normalizeShareExpiresHours(options.expires);
111
121
  const maxUses = parseInt(options.uses) || 5;
112
122
  const expiresAt = new Date(Date.now() + expiresHours * 60 * 60 * 1000).toISOString();
113
123
 
@@ -4,7 +4,7 @@ import boxen from 'boxen';
4
4
  import gradient from 'gradient-string';
5
5
  import ora from 'ora';
6
6
  import { getSession, getSubscription, supaFetch } from '../cloud/auth.js';
7
- import { SUPABASE_URL } from '../cloud/constants.js';
7
+ import { SUPABASE_URL, MAX_BACKUPS_FREE, MAX_BACKUPS_PRO } from '../cloud/constants.js';
8
8
 
9
9
  async function createCheckoutSession(session) {
10
10
  const res = await fetch(`${SUPABASE_URL}/functions/v1/stripe-checkout`, {
@@ -57,7 +57,7 @@ export async function upgradeCommand() {
57
57
 
58
58
  const freeLabel = currentPlan === 'free' ? 'Free (current)' : 'Free';
59
59
  const proLabel = currentPlan === 'pro' ? 'Pro (current)' : 'Pro $15/mo';
60
- const teamsLabel = 'Teams $29/seat';
60
+ const teamsLabel = 'Teams (coming soon)';
61
61
 
62
62
  const header =
63
63
  pad(chalk.bold.white(freeLabel), col1) +
@@ -67,12 +67,12 @@ export async function upgradeCommand() {
67
67
  const sep = chalk.gray('─'.repeat(col1 + col2 + 18));
68
68
 
69
69
  const rows = [
70
- [chalk.gray('10 cloud backups'), chalk.white('100 backups'), chalk.white('100 backups')],
71
- [chalk.gray('Local only'), chalk.white('Unlimited machines'), chalk.white('Shared team context')],
72
- [chalk.gray('Manual snapshots'), chalk.white('Auto snapshots'), chalk.white('Team dashboard')],
73
- [chalk.gray('Community support'), chalk.white('Priority support'), chalk.white('Audit log')],
74
- [chalk.gray('—'), chalk.white('E2E encryption'), chalk.white('SSO & RBAC')],
75
- [chalk.gray('—'), chalk.white('Version history'), chalk.white('Priority onboarding')],
70
+ [chalk.gray(`${MAX_BACKUPS_FREE} retained backups`), chalk.white(`${MAX_BACKUPS_PRO} retained backups`), chalk.white(`${MAX_BACKUPS_PRO} retained backups`)],
71
+ [chalk.gray('Community support'), chalk.white('Sync across machines'), chalk.white('Shared team context')],
72
+ [chalk.white('E2E encryption'), chalk.white('E2E encryption'), chalk.white('E2E encryption')],
73
+ [chalk.gray('—'), chalk.gray('—'), chalk.white('Team dashboard')],
74
+ [chalk.gray('—'), chalk.gray('—'), chalk.white('Audit log')],
75
+ [chalk.gray('—'), chalk.gray('—'), chalk.white('SSO & RBAC')],
76
76
  ];
77
77
 
78
78
  const tableRows = rows.map(([a, b, c]) =>
package/src/mcp.js CHANGED
@@ -81,7 +81,8 @@ const server = new McpServer({
81
81
  // Wrap server.tool ONCE so every registered handler emits an anonymous, no-PII
82
82
  // "mcp_tool_used" event on call — the only place that proves memory was actually
83
83
  // used (the North Star's activation event). Fire-and-forget; can't block or
84
- // break a tool response. No-op unless a telemetry key is configured.
84
+ // break a tool response. No-op unless the user has opted in and a telemetry
85
+ // key is configured.
85
86
  track('mcp_server_start');
86
87
  const _registerTool = server.tool.bind(server);
87
88
  server.tool = (name, ...rest) => {
package/src/telemetry.js CHANGED
@@ -1,11 +1,12 @@
1
- // Anonymous, opt-out usage telemetry.
1
+ // Anonymous, opt-in usage telemetry.
2
2
  //
3
3
  // Fire-and-forget POST to PostHog's capture endpoint — no SDK, no batching/flush
4
- // problem for a short-lived CLI, and a HARD NO-OP unless a project key is set.
5
- // Honors DO_NOT_TRACK, CI, and `memoir telemetry off`. We NEVER send PII or any
6
- // memory contents — only an anonymous install UUID, the event name, the OS, and
7
- // the CLI version. All output goes to stderr so it can never corrupt the MCP
8
- // stdio protocol (which speaks JSON-RPC over stdout).
4
+ // problem for a short-lived CLI, and a HARD NO-OP unless a project key is set
5
+ // AND the user has explicitly opted in. Honors DO_NOT_TRACK, CI,
6
+ // MEMOIR_TELEMETRY=0/off/false, and `memoir telemetry off`. We NEVER send PII
7
+ // or any memory contents — only an anonymous install UUID, the event name, the
8
+ // OS, and the CLI version. All output goes to stderr so it can never corrupt
9
+ // the MCP stdio protocol (which speaks JSON-RPC over stdout).
9
10
  import fs from 'fs-extra';
10
11
  import path from 'path';
11
12
  import os from 'os';
@@ -17,35 +18,68 @@ const VERSION = (() => {
17
18
  try { return require('../package.json').version; } catch { return 'unknown'; }
18
19
  })();
19
20
 
20
- // PostHog PROJECT API key (phc_…). This is a PUBLIC client key — safe to ship in
21
- // the package, same model as posthog-js in a web app. Set MEMOIR_POSTHOG_KEY or
22
- // paste the project key here. Empty → telemetry is a silent no-op.
23
- const POSTHOG_KEY = process.env.MEMOIR_POSTHOG_KEY || 'phc_vS7ZKfmZAcGnaCE7Zt4hvwFioJBs6jr8gutyapDpqFXW';
21
+ // PostHog PROJECT API key (phc_…). PUBLIC client key — same model as posthog-js.
22
+ // Owner: rotating the project is a one-line change of POSTHOG_PROJECT_KEY (and
23
+ // retiring the old PostHog project). MEMOIR_POSTHOG_KEY overrides at runtime.
24
+ // Empty → telemetry is a silent no-op.
25
+ export const POSTHOG_PROJECT_KEY = 'phc_vS7ZKfmZAcGnaCE7Zt4hvwFioJBs6jr8gutyapDpqFXW';
26
+ const POSTHOG_KEY = process.env.MEMOIR_POSTHOG_KEY || POSTHOG_PROJECT_KEY;
24
27
  const POSTHOG_HOST = process.env.MEMOIR_POSTHOG_HOST || 'https://us.i.posthog.com';
25
28
 
26
29
  const CONFIG_DIR = process.platform === 'win32'
27
30
  ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'memoir')
28
31
  : path.join(os.homedir(), '.config', 'memoir');
29
32
  const ID_FILE = path.join(CONFIG_DIR, 'telemetry-id');
33
+ const OPTIN_FILE = path.join(CONFIG_DIR, 'telemetry-on');
30
34
  const OPTOUT_FILE = path.join(CONFIG_DIR, 'telemetry-off');
31
35
  const DISCLOSED_FILE = path.join(CONFIG_DIR, 'telemetry-disclosed');
32
36
 
37
+ function envFlag() {
38
+ return String(process.env.MEMOIR_TELEMETRY || '').toLowerCase();
39
+ }
40
+
41
+ function envForcesOff() {
42
+ if (['1', 'true'].includes(process.env.DO_NOT_TRACK)) return 'DO_NOT_TRACK is set';
43
+ if (process.env.CI) return 'running in CI';
44
+ if (['0', 'off', 'false'].includes(envFlag())) return 'MEMOIR_TELEMETRY is off';
45
+ return null;
46
+ }
47
+
48
+ function fileOptedOut() {
49
+ try { return fs.existsSync(OPTOUT_FILE); } catch { return false; }
50
+ }
51
+
52
+ function fileOptedIn() {
53
+ try { return fs.existsSync(OPTIN_FILE); } catch { return false; }
54
+ }
55
+
56
+ function envForcesOn() {
57
+ return ['1', 'on', 'true'].includes(envFlag());
58
+ }
59
+
33
60
  export function isEnabled() {
34
61
  if (!POSTHOG_KEY) return false; // no key → no-op
35
- if (['1', 'true'].includes(process.env.DO_NOT_TRACK)) return false;
36
- if (process.env.CI) return false; // never track CI
37
- if (['0', 'off', 'false'].includes(process.env.MEMOIR_TELEMETRY)) return false;
38
- try { if (fs.existsSync(OPTOUT_FILE)) return false; } catch {}
39
- return true;
62
+ if (envForcesOff()) return false;
63
+ if (fileOptedOut()) return false;
64
+ return envForcesOn() || fileOptedIn(); // default off
65
+ }
66
+
67
+ function discardStaleIdentity() {
68
+ try { fs.removeSync(ID_FILE); } catch {}
69
+ try { fs.removeSync(DISCLOSED_FILE); } catch {}
70
+ }
71
+
72
+ function mintInstallId() {
73
+ const id = randomUUID();
74
+ try { fs.ensureDirSync(CONFIG_DIR); fs.writeFileSync(ID_FILE, id); } catch {}
75
+ return id;
40
76
  }
41
77
 
42
78
  function getInstallId() {
43
79
  try {
44
80
  if (fs.existsSync(ID_FILE)) return fs.readFileSync(ID_FILE, 'utf8').trim();
45
81
  } catch {}
46
- const id = randomUUID();
47
- try { fs.ensureDirSync(CONFIG_DIR); fs.writeFileSync(ID_FILE, id); } catch {}
48
- return id;
82
+ return mintInstallId();
49
83
  }
50
84
 
51
85
  function discloseOnce() {
@@ -54,8 +88,8 @@ function discloseOnce() {
54
88
  fs.ensureDirSync(CONFIG_DIR);
55
89
  fs.writeFileSync(DISCLOSED_FILE, new Date().toISOString());
56
90
  process.stderr.write(
57
- '\n memoir collects anonymous, no-PII usage stats to improve the tool.\n' +
58
- ' Opt out anytime: `memoir telemetry off` (or set DO_NOT_TRACK=1).\n\n'
91
+ '\n memoir sends anonymous, no-PII usage stats because telemetry is opted in.\n' +
92
+ ' Disable anytime: `memoir telemetry off` (or set DO_NOT_TRACK=1).\n\n'
59
93
  );
60
94
  } catch {}
61
95
  }
@@ -64,7 +98,10 @@ function discloseOnce() {
64
98
  // touches stdout. Callers may await (CLI) or not (MCP) — both are safe.
65
99
  export async function capture(event, properties = {}) {
66
100
  try {
67
- if (!isEnabled()) return;
101
+ if (!isEnabled()) {
102
+ discardStaleIdentity();
103
+ return;
104
+ }
68
105
  discloseOnce();
69
106
  const ctrl = new AbortController();
70
107
  const timer = setTimeout(() => ctrl.abort(), 1500);
@@ -90,19 +127,44 @@ export async function capture(event, properties = {}) {
90
127
  export async function telemetryCommand(action = 'status') {
91
128
  const a = String(action).toLowerCase();
92
129
  if (a === 'off') {
93
- try { await fs.ensureDir(CONFIG_DIR); await fs.writeFile(OPTOUT_FILE, '1'); } catch {}
130
+ try {
131
+ await fs.ensureDir(CONFIG_DIR);
132
+ await fs.writeFile(OPTOUT_FILE, '1');
133
+ await fs.remove(OPTIN_FILE);
134
+ } catch {}
135
+ discardStaleIdentity();
94
136
  console.log(' Telemetry disabled. memoir will not send any usage events.');
95
137
  return;
96
138
  }
97
139
  if (a === 'on') {
98
- try { await fs.remove(OPTOUT_FILE); } catch {}
99
- console.log(' Telemetry enabled (anonymous, no PII).');
140
+ try {
141
+ await fs.ensureDir(CONFIG_DIR);
142
+ await fs.writeFile(OPTIN_FILE, '1');
143
+ await fs.remove(OPTOUT_FILE);
144
+ } catch {}
145
+ discardStaleIdentity();
146
+ mintInstallId();
147
+ const blocked = envForcesOff();
148
+ if (blocked) {
149
+ console.log(` Telemetry opted in, but currently OFF — ${blocked}.`);
150
+ } else {
151
+ console.log(' Telemetry enabled (anonymous, no PII). Disable with `memoir telemetry off`.');
152
+ }
100
153
  return;
101
154
  }
102
155
  // status
103
156
  const reason = !POSTHOG_KEY ? 'no project key configured'
104
- : ['1', 'true'].includes(process.env.DO_NOT_TRACK) ? 'DO_NOT_TRACK is set'
105
- : process.env.CI ? 'running in CI'
106
- : (() => { try { return fs.existsSync(OPTOUT_FILE) ? 'opted out (`memoir telemetry off`)' : null; } catch { return null; } })();
107
- console.log(reason ? ` Telemetry: OFF — ${reason}.` : ' Telemetry: ON — anonymous usage events (no PII). Disable with `memoir telemetry off`.');
157
+ : envForcesOff()
158
+ || (fileOptedOut() ? 'opted out (`memoir telemetry off`)' : null);
159
+ if (reason) {
160
+ discardStaleIdentity();
161
+ console.log(` Telemetry: OFF — ${reason}.`);
162
+ return;
163
+ }
164
+ if (envForcesOn() || fileOptedIn()) {
165
+ console.log(' Telemetry: ON — anonymous usage events (no PII). Disable with `memoir telemetry off`.');
166
+ return;
167
+ }
168
+ discardStaleIdentity();
169
+ console.log(' Telemetry: OFF — off by default. Enable with `memoir telemetry on`.');
108
170
  }
@@ -0,0 +1,19 @@
1
+ -- Expired/exhausted share purge lives in supabase/functions/purge-expired-shares.
2
+ -- It removes objects through the Storage API, then deletes matching rows.
3
+ -- Direct SQL deletes against the Storage catalog orphan files (and current projects reject them).
4
+ --
5
+ -- This migration only drops the unsafe SQL purge if an earlier draft applied it.
6
+ -- There is no pg_cron → SQL delete path.
7
+ --
8
+ -- MANUAL STEPS (owner):
9
+ -- 1. Apply this file (no-op drop if the SQL purge was never created).
10
+ -- 2. Set MEMOIR_PURGE_SECRET on the purge-expired-shares function.
11
+ -- 3. Deploy supabase/functions/purge-expired-shares (verify_jwt = false;
12
+ -- the function requires header x-memoir-purge-secret).
13
+ -- 4. Schedule a daily POST to that function with the secret header.
14
+ -- Do not schedule `select memoir_purge_expired_shares()`.
15
+ begin;
16
+
17
+ drop function if exists public.memoir_purge_expired_shares();
18
+
19
+ commit;
@@ -0,0 +1,19 @@
1
+ -- shared_links.expires_at must be set so the purge always has an expiry.
2
+ -- Null-expiry rows under their download limit were never selected.
3
+ --
4
+ -- MANUAL STEP (owner): apply by SQL if `supabase db push` still mis-pairs
5
+ -- 12-digit history. Do not skip this; the edge function assumes the column
6
+ -- is present and not null after this lands.
7
+ begin;
8
+
9
+ update public.shared_links
10
+ set expires_at = coalesce(created_at, now()) + interval '30 days'
11
+ where expires_at is null;
12
+
13
+ alter table public.shared_links
14
+ alter column expires_at set default (now() + interval '24 hours');
15
+
16
+ alter table public.shared_links
17
+ alter column expires_at set not null;
18
+
19
+ commit;