mcp-memory-bucket 0.10.11 → 0.10.13

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.
@@ -33,7 +33,7 @@
33
33
  z-index: 2147483000;
34
34
  }
35
35
  </style>
36
- <script type="module" crossorigin src="/assets/index-BWDogxb1.js"></script>
36
+ <script type="module" crossorigin src="/assets/index-BuS9zM5d.js"></script>
37
37
  </head>
38
38
  <body>
39
39
  <mem-bucket-app></mem-bucket-app>
@@ -13,10 +13,14 @@ function isRemoteEntry(entry) {
13
13
  * get mistaken for one another (see identity.ts) - a mirror connected while
14
14
  * logged into "dev" as "anatoli" lands at
15
15
  * .memory-bucket-remote-cache/dev_anatoli/<name>/, distinct from the same
16
- * folder name connected under "cloud" or a different username.
16
+ * folder name connected under "cloud" or a different username. `owner`, when
17
+ * this source is a folder someone ELSE shared (see RemoteFolder.owner),
18
+ * folds into the identity segment too - otherwise a shared folder connected
19
+ * under the same `name` as one of the caller's own folders would collide on
20
+ * the same mirror directory.
17
21
  */
18
- export function mirrorDirFor(baseDir, mode, username, name) {
19
- const identitySegment = sanitizeFolderName(`${mode}_${username}`);
22
+ export function mirrorDirFor(baseDir, mode, username, name, owner) {
23
+ const identitySegment = sanitizeFolderName(owner ? `${mode}_${username}_shared-by-${owner}` : `${mode}_${username}`);
20
24
  return path.join(baseDir, '.memory-bucket-remote-cache', identitySegment, sanitizeFolderName(name));
21
25
  }
22
26
  function memoryDirFlag(argv) {
@@ -65,7 +69,7 @@ function resolveFolders(entries, baseDir) {
65
69
  return { name: nameFromPath(entry), path: path.resolve(baseDir, entry) };
66
70
  }
67
71
  if (isRemoteEntry(entry)) {
68
- const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name);
72
+ const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name, entry.remote.owner);
69
73
  remoteFolders.push({ name: entry.name, ...entry.remote, mirrorDir });
70
74
  return { name: entry.name, path: mirrorDir };
71
75
  }
@@ -159,6 +163,7 @@ export function saveRemoteFolder(config, kind, entry) {
159
163
  folderPath: entry.folderPath,
160
164
  mode: entry.mode,
161
165
  username: entry.username,
166
+ ...(entry.owner ? { owner: entry.owner } : {}),
162
167
  },
163
168
  },
164
169
  ],
@@ -84,9 +84,9 @@ export class MemoryRepository {
84
84
  const remote = this.remoteFor(folderName);
85
85
  if (!remote || !this.credentialsBaseDir)
86
86
  return;
87
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
87
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
88
88
  const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
89
- await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
89
+ await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType, remote.owner);
90
90
  }
91
91
  /**
92
92
  * Trashes one attachment's remote copy on folderfoo, if `folderName` resolves to a remote source
@@ -102,9 +102,9 @@ export class MemoryRepository {
102
102
  const remote = this.remoteFor(folderName);
103
103
  if (!remote || !this.credentialsBaseDir)
104
104
  return;
105
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
105
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
106
106
  const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
107
- await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath));
107
+ await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), remote.owner);
108
108
  }
109
109
  /** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
110
110
  setWatcher(watcher) {
@@ -190,6 +190,11 @@ export class MemoryRepository {
190
190
  * perform the initial pull itself — the caller (the web route) does one
191
191
  * immediate poll right after this returns, so content shows up without
192
192
  * waiting for the first interval tick.
193
+ *
194
+ * `remote.name` (and `remote.mirrorDir`, derived from it) must already be the FINAL, resolved
195
+ * name — see resolveAvailableName, which the web route calls first (before it derives mirrorDir)
196
+ * so a possibly-auto-suffixed name is settled before anything touches disk, rather than this
197
+ * method renaming out from under an already-computed mirrorDir.
193
198
  */
194
199
  registerRemoteFolder(remote) {
195
200
  if (this.folders.some((f) => f.name === remote.name)) {
@@ -197,11 +202,61 @@ export class MemoryRepository {
197
202
  }
198
203
  fs.mkdirSync(remote.mirrorDir, { recursive: true });
199
204
  this.remoteFolders.push(remote);
200
- this.addFolder({ name: remote.name, path: remote.mirrorDir });
205
+ this.addFolder({ name: remote.name, path: remote.mirrorDir }, { skipCollisionCheck: true });
201
206
  }
202
- /** Registers a new folder: appends it, scans it once, and starts watching it live. */
203
- addFolder(folder) {
204
- if (this.folders.some((f) => f.name === folder.name)) {
207
+ /**
208
+ * `name` must stay globally unique across EVERY identity this process has ever seen, not just
209
+ * currently-visible folders — `folder` is the sole key used everywhere a doc/skill addresses which
210
+ * folder it lives in (the memory_docs.folder/skills.folder DB columns, every `folder = ?` SQL
211
+ * filter, MCP tool `folder` params, the web UI's folder-chip clicks). Two folders sharing a name
212
+ * would make all of those ambiguous the instant both became visible in the same request. Called by
213
+ * the web route BEFORE deriving mirrorDir/calling registerRemoteFolder, so the name is fully
214
+ * settled before anything touches disk or the config file.
215
+ *
216
+ * Two different cases, handled differently:
217
+ * - Collides with a folder the SAME identity already has (own or shared) → a genuine naming
218
+ * mistake (e.g. reusing a name they already picked themselves) → rejected.
219
+ * - Collides with a folder a DIFFERENT identity owns (e.g. two different folderfoo logins each
220
+ * naturally wanting "bbbmemz") → auto-suffixed with " (<connecting username>)" instead of
221
+ * rejected, since forcing every user to invent an arbitrary alternate name for their OWN
222
+ * folder just because someone else's login already used the obvious one is exactly the friction
223
+ * this exists to avoid. The suffixed form is still checked for its own (now vanishingly
224
+ * unlikely, but not impossible — e.g. two different usernames on two different modes) collision
225
+ * and suffixed further with an incrementing counter if needed, so this always terminates with a
226
+ * genuinely available name rather than looping forever or erroring on a contrived edge case.
227
+ */
228
+ resolveAvailableName(requestedName, connectingUsername) {
229
+ const takenByOwnIdentity = (name) => this.folders.some((f) => f.name === name) && this.isFolderNameVisible(name);
230
+ const taken = (name) => this.folders.some((f) => f.name === name);
231
+ if (!taken(requestedName))
232
+ return requestedName;
233
+ if (takenByOwnIdentity(requestedName)) {
234
+ throw new Error(`a memory folder named "${requestedName}" already exists`);
235
+ }
236
+ // Collides with a DIFFERENT identity's folder — auto-suffix rather than reject (see this
237
+ // method's own doc comment). Re-checks the suffixed candidate too: astronomically unlikely to
238
+ // still collide, but not impossible (e.g. someone literally named a folder "bbbmemz (bbb)"), so
239
+ // this keeps incrementing until it lands on a name nobody's using yet, rather than assuming the
240
+ // first suffix attempt always works.
241
+ let candidate = `${requestedName} (${connectingUsername})`;
242
+ let suffix = 2;
243
+ while (taken(candidate)) {
244
+ if (takenByOwnIdentity(candidate)) {
245
+ throw new Error(`a memory folder named "${candidate}" already exists`);
246
+ }
247
+ candidate = `${requestedName} (${connectingUsername}) ${suffix}`;
248
+ suffix++;
249
+ }
250
+ return candidate;
251
+ }
252
+ /**
253
+ * Registers a new folder: appends it, scans it once, and starts watching it live. A purely LOCAL
254
+ * folder (no folderfoo login involved) has no "different identity" to auto-suffix against — any
255
+ * collision here is unconditionally a plain rejection, unlike registerRemoteFolder's own
256
+ * resolveAvailableName.
257
+ */
258
+ addFolder(folder, options) {
259
+ if (!options?.skipCollisionCheck && this.folders.some((f) => f.name === folder.name)) {
205
260
  throw new Error(`a memory folder named "${folder.name}" already exists`);
206
261
  }
207
262
  this.folders.push(folder);
@@ -349,17 +404,30 @@ export class MemoryRepository {
349
404
  }
350
405
  params.push(limit, offset);
351
406
  try {
407
+ // shared_items is LEFT JOINed on m.source_path (== mirror_path for an item-level share —
408
+ // see remote/shared-items.ts) to surface owner/role on any hit that came from a share, since
409
+ // a shared item's own `folder` column is always '' (its mirror path is outside every
410
+ // configured source directory, so folderForFile can't attribute it to one) and would
411
+ // otherwise look identical to any other unfoldered local doc.
352
412
  const rows = this.db
353
413
  .prepare(`SELECT m.source_path, m.key, m.description, m.doc_type, m.folder,
354
414
  snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
355
- -bm25(search_index) AS score
415
+ -bm25(search_index) AS score,
416
+ si.owner AS shared_owner,
417
+ si.role AS shared_role
356
418
  FROM search_index
357
419
  JOIN memory_docs m ON m.source_path = search_index.ref_id
420
+ LEFT JOIN shared_items si ON si.status = 'active' AND si.mirror_path = m.source_path
358
421
  WHERE search_index.ref_table = 'memory_docs' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
359
422
  ORDER BY bm25(search_index)
360
423
  LIMIT ? OFFSET ?`)
361
424
  .all(...params);
362
- return rows.map(({ source_path, ...rest }) => ({ ...rest, filename: path.basename(source_path) }));
425
+ return rows.map(({ source_path, shared_owner, shared_role, ...rest }) => ({
426
+ ...rest,
427
+ filename: path.basename(source_path),
428
+ shared_owner: shared_owner ?? undefined,
429
+ shared_role: shared_role ?? undefined,
430
+ }));
363
431
  }
364
432
  catch (err) {
365
433
  throw new SearchQueryError(query, err);
@@ -399,7 +467,7 @@ export class MemoryRepository {
399
467
  // edits). Must parse it exactly like a local file read would.
400
468
  let raw;
401
469
  try {
402
- raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
470
+ raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, remote.owner);
403
471
  }
404
472
  catch (err) {
405
473
  // Also try the LEGACY extensionless remote name — a doc pushed before the
@@ -409,7 +477,7 @@ export class MemoryRepository {
409
477
  // FolderfooAuthError or any other failure, which must surface as-is.
410
478
  if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !name.endsWith('.md'))
411
479
  throw err;
412
- raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3));
480
+ raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3), remote.owner);
413
481
  }
414
482
  const liveBody = matter(raw).content.trim();
415
483
  return { ...doc, body: liveBody };
@@ -498,7 +566,7 @@ export class MemoryRepository {
498
566
  // Confirms the remote folder still exists before writing — folderfoo's own save endpoint
499
567
  // would otherwise silently recreate a deleted folder rather than failing (see
500
568
  // assertRemoteFolderExists's doc comment).
501
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name);
569
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name, remote.owner);
502
570
  // The remote filename keeps the SAME name (including .md) as the local mirror file — no
503
571
  // extension stripping. Historically this stripped ".md" to match the old opaque id (which
504
572
  // never carried an extension), which meant a memory doc's OWN remote file was named
@@ -511,7 +579,7 @@ export class MemoryRepository {
511
579
  const relPath = path.relative(remote.mirrorDir, filePath);
512
580
  const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
513
581
  const name = path.basename(relPath);
514
- await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
582
+ await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents, remote.owner);
515
583
  }, () => {
516
584
  fs.mkdirSync(targetDir, { recursive: true });
517
585
  fs.writeFileSync(filePath, fileContents, 'utf-8');
@@ -556,12 +624,12 @@ export class MemoryRepository {
556
624
  await writeRemoteThenLocal(async () => {
557
625
  if (!remote || !this.credentialsBaseDir)
558
626
  return;
559
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
627
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
560
628
  // No .md stripping — see create()'s comment.
561
629
  const relPath = path.relative(remote.mirrorDir, existing.source_path);
562
630
  const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
563
631
  const name = path.basename(relPath);
564
- await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
632
+ await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents, remote.owner);
565
633
  }, () => fs.writeFileSync(existing.source_path, fileContents, 'utf-8'));
566
634
  upsertFile(this.db, this.syncSpec, existing.source_path);
567
635
  return { ...merged, body: newBody, paused: existingPaused };
@@ -636,11 +704,11 @@ export class MemoryRepository {
636
704
  // recoverable if the delete was a mistake.
637
705
  const remote = this.remoteFor(existing.folder);
638
706
  if (remote && this.credentialsBaseDir) {
639
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
707
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
640
708
  const relPath = path.relative(remote.mirrorDir, existing.source_path);
641
709
  const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
642
710
  const name = path.basename(relPath);
643
- await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
711
+ await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, remote.owner);
644
712
  }
645
713
  // The sibling wrapper directory exists solely to hold attachments/ for this doc (memory docs
646
714
  // are otherwise flat files), so remove the whole wrapper — not just attachments/ — to avoid
@@ -678,13 +746,13 @@ export class MemoryRepository {
678
746
  await writeRemoteThenLocal(async () => {
679
747
  if (!remote || !this.credentialsBaseDir)
680
748
  return;
681
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
749
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
682
750
  const oldRelPath = path.relative(remote.mirrorDir, existing.source_path);
683
751
  const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(oldRelPath));
684
752
  const oldName = path.basename(oldRelPath);
685
753
  const newName = path.basename(newPath);
686
754
  try {
687
- await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName);
755
+ await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName, remote.owner);
688
756
  }
689
757
  catch (err) {
690
758
  // Same legacy-extensionless fallback as get() above: a doc pushed before the
@@ -694,7 +762,7 @@ export class MemoryRepository {
694
762
  // failure, which must surface as-is.
695
763
  if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !oldName.endsWith('.md'))
696
764
  throw err;
697
- await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName);
765
+ await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName, remote.owner);
698
766
  }
699
767
  }, () => {
700
768
  fs.renameSync(existing.source_path, newPath);
@@ -96,22 +96,81 @@ function folderQuery(tenantId, folderPath, extra) {
96
96
  const params = new URLSearchParams({ folderPath, ...extra });
97
97
  return params.toString();
98
98
  }
99
- export async function getLastChanged(server, baseDir, tenantId, folderPath) {
100
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/last-changed?${folderQuery(tenantId, folderPath)}`, {
99
+ /**
100
+ * `owner`, when passed, addresses a folder in someone ELSE's tree via a direct-username share —
101
+ * every folderfoo route below that takes `owner` gates it through the same resolveUserDir/
102
+ * hasAccess check the file-level `owner` param in filenameParam does (see that function's own
103
+ * comment). Omitted entirely (not just falsy) for an own-folder call, matching folderfoo's own
104
+ * `owner ? ... : caller's own dir` branching server-side.
105
+ */
106
+ export async function getLastChanged(server, baseDir, tenantId, folderPath, owner) {
107
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/last-changed?${folderQuery(tenantId, folderPath, owner ? { owner } : undefined)}`, {
101
108
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
102
109
  }), async (res) => (await res.json()).lastChanged);
103
110
  }
104
111
  /**
105
- * Lists the caller's own folderfoo folders (flat list of full paths, each
106
- * with a createdAt timestamp - folderfoo's own GET /folders response
107
- * shape) via folderfoo's existing GET /folders — used by the "connect a
108
- * folderfoo folder" UI to offer a picker instead of requiring the user to
109
- * type a raw folder path. Own folders only (no owner param) - browsing
110
- * INTO a shared folder someone else owns is a separate, not-yet-exposed
111
- * flow.
112
+ * Lists folderfoo folders via GET /folders — the caller's own when `owner`/`rootFolder` are
113
+ * omitted, or a subtree someone else shared with the caller when both are passed (folderfoo gates
114
+ * this via the same hasAccess check every other shared read uses, requiring `rootFolder` to be a
115
+ * folder actually shared with the caller). Used by the "connect a folderfoo folder" UI's picker.
116
+ */
117
+ export async function listFolders(server, baseDir, tenantId, options) {
118
+ const params = new URLSearchParams();
119
+ if (options?.owner)
120
+ params.set('owner', options.owner);
121
+ if (options?.rootFolder)
122
+ params.set('rootFolder', options.rootFolder);
123
+ const qs = params.toString();
124
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders${qs ? `?${qs}` : ''}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
125
+ }
126
+ /**
127
+ * Lists everything (files and folders, from every owner) shared with the
128
+ * current user via folderfoo's direct-username `shares` mechanism — see
129
+ * folderfoo's GET /shared-with-me. Used ONLY by an explicit refresh action
130
+ * (see remote/shared-items.ts's refreshSharedItems) — never polled on a
131
+ * timer, per the settled "refresh is a UI-only concept" design. role/
132
+ * originId/kind are null for a plain (non-mcp-memory-bucket) share, e.g. one
133
+ * created before this feature existed or shared by a different app.
134
+ */
135
+ export async function getSharedWithMe(server, baseDir, tenantId) {
136
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/shared-with-me`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
137
+ }
138
+ /**
139
+ * Grants another folderfoo user direct access to one of the caller's own memory docs/skills, via
140
+ * folderfoo's POST /share/:filename — the immediate, no-link-needed half of Phase 4's share UX
141
+ * (paired with createShareLink/createPublicLink below for the out-of-band half). `kind` tags the
142
+ * grant so a recipient's shared_items row (see shared-items.ts) never has to guess memory-vs-skill
143
+ * from content — see folderfoo's shares.js v6->v7 migration for why this exists at all.
112
144
  */
113
- export async function listFolders(server, baseDir, tenantId) {
114
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
145
+ export async function shareWithUser(server, baseDir, tenantId, folderPath, name, targetUsername, kind, role) {
146
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/share/${filenameParam(folderPath, name)}`, {
147
+ method: 'POST',
148
+ headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
149
+ body: JSON.stringify({ username: targetUsername, kind, role }),
150
+ }), async () => undefined);
151
+ }
152
+ /** Revokes a specific recipient's direct access via folderfoo's DELETE /share/:filename/:username. */
153
+ export async function unshareWithUser(server, baseDir, tenantId, folderPath, name, targetUsername) {
154
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/share/${filenameParam(folderPath, name)}/${encodeURIComponent(targetUsername)}`, {
155
+ method: 'DELETE',
156
+ headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
157
+ }), async () => undefined);
158
+ }
159
+ /** Creates an out-of-band, login-required collaborator share link via folderfoo's POST /share-links. */
160
+ export async function createShareLink(server, baseDir, tenantId, folderPath, name, kind) {
161
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/share-links`, {
162
+ method: 'POST',
163
+ headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
164
+ body: JSON.stringify({ path: joinRemoteFolderPath(folderPath, name), type: 'file', kind }),
165
+ }), (res) => res.json());
166
+ }
167
+ /** Creates an anonymous, no-login-required public view link via folderfoo's POST /public-links. */
168
+ export async function createPublicLink(server, baseDir, tenantId, folderPath, name, kind) {
169
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/public-links`, {
170
+ method: 'POST',
171
+ headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
172
+ body: JSON.stringify({ path: joinRemoteFolderPath(folderPath, name), kind }),
173
+ }), (res) => res.json());
115
174
  }
116
175
  /** Thrown when a write targets a remote folder that no longer exists on folderfoo (deleted server-side since the last connect/UI-open sync). */
117
176
  export class RemoteFolderGoneError extends Error {
@@ -130,16 +189,26 @@ export class RemoteFolderGoneError extends Error {
130
189
  * folder — the user may have deleted it deliberately. `folderPath === ''` (a source's own root) is
131
190
  * never gone (the user's account root always exists), so this only ever checks a non-root path.
132
191
  */
133
- export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName) {
192
+ export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName, owner) {
134
193
  if (!folderPath)
135
194
  return;
195
+ // A shared folder's OWN path never appears in listFolders' own-tree result (it isn't a
196
+ // subdirectory of the caller's root) - list the owner's tree rooted at folderPath instead and
197
+ // check for a non-empty (i.e. accessible) result, same "does this still resolve" intent as the
198
+ // own-folder branch below, just via the owner-aware GET /folders shape (see listFolders' comment).
199
+ if (owner) {
200
+ await listFolders(server, baseDir, tenantId, { owner, rootFolder: folderPath }).catch(() => {
201
+ throw new RemoteFolderGoneError(folderName);
202
+ });
203
+ return;
204
+ }
136
205
  const folders = await listFolders(server, baseDir, tenantId);
137
206
  if (!folders.some((f) => f.path === folderPath)) {
138
207
  throw new RemoteFolderGoneError(folderName);
139
208
  }
140
209
  }
141
- export async function getChangedSince(server, baseDir, tenantId, folderPath, since) {
142
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since) })}`, {
210
+ export async function getChangedSince(server, baseDir, tenantId, folderPath, since, owner) {
211
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since), ...(owner ? { owner } : {}) })}`, {
143
212
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
144
213
  }), async (res) => (await res.json()).files);
145
214
  }
@@ -151,7 +220,15 @@ export async function getChangedSince(server, baseDir, tenantId, folderPath, sin
151
220
  // itself uses this same form in its own sharing tests for exactly this
152
221
  // reason. Always use it here rather than only for the single-segment case,
153
222
  // so this client doesn't need to special-case folder depth.
154
- function filenameParam(folderPath, name) {
223
+ //
224
+ // `owner`, when passed, addresses a path in someone ELSE's directory (a
225
+ // direct-username share, resolved via folderfoo's shares table rather than
226
+ // the caller's own files) - see resolveUserDir on the server side, which
227
+ // treats a bare (owner-less) filename as always the CALLER's own directory.
228
+ // Every other caller of this function addresses its own files and omits it.
229
+ function filenameParam(folderPath, name, owner) {
230
+ if (owner)
231
+ return `${encodeURIComponent(owner)}:${folderPath ? encodeURIComponent(folderPath) : ''}:${name}`;
155
232
  return folderPath ? `:${encodeURIComponent(folderPath)}:${name}` : name;
156
233
  }
157
234
  /**
@@ -169,13 +246,24 @@ export function joinRemoteFolderPath(remoteFolderPath, mirrorRelativeDir) {
169
246
  return remoteFolderPath;
170
247
  return remoteFolderPath ? `${remoteFolderPath}/${mirrorRelativeDir}` : mirrorRelativeDir;
171
248
  }
172
- /** Reads one file's raw content via GET /data/:filename. */
173
- export async function readFile(server, baseDir, tenantId, folderPath, name) {
174
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/data/${filenameParam(folderPath, name)}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.text());
249
+ /**
250
+ * Reads one file's raw content via GET /data/:filename. `owner`, when passed, reads a file from
251
+ * someone ELSE's directory via a direct-username share (see filenameParam's own comment) - used by
252
+ * shared-items.ts to pull a shared item's content, since the caller here is the share's RECIPIENT,
253
+ * not the file's owner.
254
+ */
255
+ export async function readFile(server, baseDir, tenantId, folderPath, name, owner) {
256
+ return withAuth(server, baseDir, (jwt) => fetch(`${server}/data/${filenameParam(folderPath, name, owner)}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.text());
175
257
  }
176
- /** Writes one file's raw content via POST /save/:filename. */
177
- export async function writeFile(server, baseDir, tenantId, folderPath, name, content) {
178
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
258
+ /**
259
+ * Writes one file's raw content via POST /save/:filename. `owner`, when passed, writes into
260
+ * someone ELSE's directory via a direct-username share (see filenameParam's own comment) — this is
261
+ * gated 'editor'-role-only server-side (see folderfoo's resolveUserDir(..., 'editor') on this
262
+ * route), so a 'member' (read-only) shared folder correctly 403s here rather than silently
263
+ * succeeding against the caller's own directory instead.
264
+ */
265
+ export async function writeFile(server, baseDir, tenantId, folderPath, name, content, owner) {
266
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name, owner)}`, {
179
267
  method: 'POST',
180
268
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'text/markdown' },
181
269
  body: content,
@@ -190,8 +278,8 @@ export async function writeFile(server, baseDir, tenantId, folderPath, name, con
190
278
  * write those callers already do locally, now also propagated as a real rename remotely, instead
191
279
  * of write-new (create) + never delete-old.
192
280
  */
193
- export async function renameFile(server, baseDir, tenantId, folderPath, name, newName) {
194
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/rename/${filenameParam(folderPath, name)}`, {
281
+ export async function renameFile(server, baseDir, tenantId, folderPath, name, newName, owner) {
282
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/rename/${filenameParam(folderPath, name, owner)}`, {
195
283
  method: 'POST',
196
284
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
197
285
  body: JSON.stringify({ newName }),
@@ -206,8 +294,8 @@ export async function renameFile(server, baseDir, tenantId, folderPath, name, ne
206
294
  * remote file was never touched at all, so the NEXT poll would pull it right back in, making a
207
295
  * "deleted" doc silently reappear.
208
296
  */
209
- export async function trashFile(server, baseDir, tenantId, folderPath, name) {
210
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/trash/${filenameParam(folderPath, name)}`, {
297
+ export async function trashFile(server, baseDir, tenantId, folderPath, name, owner) {
298
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/trash/${filenameParam(folderPath, name, owner)}`, {
211
299
  method: 'POST',
212
300
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
213
301
  }), async () => undefined);
@@ -221,8 +309,9 @@ export async function trashFile(server, baseDir, tenantId, folderPath, name) {
221
309
  * it as a wildcard path segment, unlike every other folderPath-bearing endpoint in this client
222
310
  * (which use a `?folderPath=` query param), because folderfoo's own route is DELETE /folders/*.
223
311
  */
224
- export async function trashFolder(server, baseDir, tenantId, folderPath) {
225
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/${folderPath.split('/').map(encodeURIComponent).join('/')}`, {
312
+ export async function trashFolder(server, baseDir, tenantId, folderPath, owner) {
313
+ const qs = owner ? `?owner=${encodeURIComponent(owner)}` : '';
314
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/${folderPath.split('/').map(encodeURIComponent).join('/')}${qs}`, {
226
315
  method: 'DELETE',
227
316
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
228
317
  }), async () => undefined);
@@ -238,11 +327,11 @@ export async function trashFolder(server, baseDir, tenantId, folderPath) {
238
327
  * "memz/old-skill-name" / "memz/new-skill-name"), sent as JSON body fields per folderfoo's own
239
328
  * route (unlike the wildcard-path DELETE /folders/* trashFolder uses).
240
329
  */
241
- export async function renameFolder(server, baseDir, tenantId, folderPath, newFolderPath) {
330
+ export async function renameFolder(server, baseDir, tenantId, folderPath, newFolderPath, owner) {
242
331
  await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/rename`, {
243
332
  method: 'POST',
244
333
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
245
- body: JSON.stringify({ folderPath, newFolderPath }),
334
+ body: JSON.stringify({ folderPath, newFolderPath, owner }),
246
335
  }), async () => undefined);
247
336
  }
248
337
  /**
@@ -260,8 +349,8 @@ export async function renameFolder(server, baseDir, tenantId, folderPath, newFol
260
349
  * (attach a dotted/hyphenated filename to a doc in a remote folder, then check the folderfoo UI
261
350
  * shows it unmangled) before relying on this for anything beyond best-effort.
262
351
  */
263
- export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType) {
264
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
352
+ export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType, owner) {
353
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name, owner)}`, {
265
354
  method: 'POST',
266
355
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': mimeType },
267
356
  body: data,
@@ -71,7 +71,7 @@ function toMirrorRelativeDir(remoteFolderPath, absoluteFolderPath) {
71
71
  * missing deletions.
72
72
  */
73
73
  async function reconcileDeletions(db, spec, folder, credentialsBaseDir) {
74
- const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0);
74
+ const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0, folder.owner);
75
75
  const remoteRelPaths = new Set(remoteFiles.map((f) => {
76
76
  const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, f.folderPath);
77
77
  return mirrorRelativeDir ? path.join(mirrorRelativeDir, f.name) : f.name;
@@ -169,7 +169,7 @@ async function pullFile(db, spec, folder, credentialsBaseDir, changedFile) {
169
169
  // correct value to pass straight through to readFile (folderfoo expects
170
170
  // that same absolute form for GET /data/:filename), but it must be
171
171
  // converted to mirror-relative before joining onto folder.mirrorDir.
172
- const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name);
172
+ const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name, folder.owner);
173
173
  const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, changedFile.folderPath);
174
174
  const localFilename = spec.remoteFilename.toLocal(changedFile.name);
175
175
  const relPath = mirrorRelativeDir ? path.join(mirrorRelativeDir, localFilename) : localFilename;
@@ -192,9 +192,21 @@ async function pullFile(db, spec, folder, credentialsBaseDir, changedFile) {
192
192
  * source's local content cache. All remote sources share one credentials
193
193
  * file at credentialsBaseDir, keyed by server URL (see credentials.ts).
194
194
  */
195
- export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}) {
195
+ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {},
196
+ // Called when this poll discovers the stored credential is dead (refresh failed, or a second
197
+ // 401 after refresh) — server.ts wires this to identity.clearUsername(). Without it, a dead JWT
198
+ // (e.g. revoked server-side, or expired past its refresh window) leaves IdentityTracker still
199
+ // reporting the OLD username as logged in forever, since nothing previously told it the
200
+ // credential folderfoo-client.ts already gave up on and cleared. That mismatch is exactly what
201
+ // let a remote folder keep showing as visible (isFolderVisible checks identity.current(), not
202
+ // whether the credential still works) even though every real read/write against it was already
203
+ // failing loudly with its own FolderfooAuthError — the folder list and the actual login state
204
+ // silently disagreed until the user manually re-logged-in. Only called once per poll failure,
205
+ // not per retry inside withAuth itself, since pollOne is the layer that already decides to
206
+ // swallow-and-log rather than propagate.
207
+ onAuthExpired) {
196
208
  try {
197
- const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath);
209
+ const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, folder.owner);
198
210
  const localWatermark = readLocalWatermark(folder.mirrorDir);
199
211
  // force (manual resync, or rebuild-cache) always does real work,
200
212
  // including reconcileDeletions - a user explicitly asking to resync
@@ -202,7 +214,7 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
202
214
  // to equal what we last saw; the whole point of a manual resync is to
203
215
  // re-verify against the live state, not trust the cheap check alone.
204
216
  if (!options.force && lastChanged <= localWatermark)
205
- return; // cheap path: nothing changed since our last sync, no listing call
217
+ return { ok: true }; // cheap path: nothing changed since our last sync, no listing call
206
218
  // force must query since=0, not localWatermark - getChangedSince filters strictly by
207
219
  // mtime > since, so a file whose mtime happens to equal (or predate) the local watermark -
208
220
  // e.g. a rename/move on folderfoo that doesn't bump mtime, or a file that was wrongly deleted
@@ -210,12 +222,13 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
210
222
  // be silently invisible to EVERY future poll, forced or not, forever. A real "force resync"
211
223
  // has to mean "re-verify everything against the live listing," matching what
212
224
  // reconcileDeletions already does unconditionally below.
213
- const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, options.force ? 0 : localWatermark);
225
+ const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, options.force ? 0 : localWatermark, folder.owner);
214
226
  for (const file of changed) {
215
227
  await pullFile(db, spec, folder, credentialsBaseDir, file);
216
228
  }
217
229
  await reconcileDeletions(db, spec, folder, credentialsBaseDir);
218
230
  writeLocalWatermark(folder.mirrorDir, lastChanged);
231
+ return { ok: true };
219
232
  }
220
233
  catch (err) {
221
234
  if (err instanceof FolderfooAuthError) {
@@ -225,7 +238,14 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
225
238
  // this source will surface its own FolderfooAuthError directly to
226
239
  // the caller regardless of poller state).
227
240
  console.error(`[memory-bucket] ${err.message}`);
228
- return;
241
+ onAuthExpired?.();
242
+ // { ok: false } tells pollAndNotify to skip onSynced for this folder this tick — onSynced
243
+ // (server.ts's onAttachmentSync) calls memoryRepo.get()/skillRepo.get(), which are gated by
244
+ // isFolderNameVisible/identity.current(). Calling onAuthExpired above can flip that gate to
245
+ // "invisible" in this exact tick, so firing onSynced right after would make a perfectly
246
+ // real, still-on-disk doc look "not found" (a confusing symptom of the SAME auth failure,
247
+ // not a second bug) rather than the original, more informative FolderfooAuthError.
248
+ return { ok: false };
229
249
  }
230
250
  throw err;
231
251
  }
@@ -246,11 +266,21 @@ onSynced,
246
266
  // fetch/ECONNREFUSED (or worse, succeed against a DIFFERENT server that happens to be listening on
247
267
  // that host:port right now) for a source nothing can currently see anyway. Defaults to "always
248
268
  // visible" so existing callers/tests that don't care about identity keep working unchanged.
249
- isVisible = () => true) {
269
+ isVisible = () => true,
270
+ // Forwarded straight through to every pollOne call this handle makes — see pollOne's own doc
271
+ // comment for why this exists. server.ts wires this to identity.clearUsername().
272
+ onAuthExpired) {
250
273
  const byName = new Map(remoteFolders.map((f) => [f.name, f]));
251
274
  async function pollAndNotify(folder, options) {
252
- await pollOne(db, spec, folder, credentialsBaseDir, options);
253
- await onSynced?.(folder);
275
+ const { ok } = await pollOne(db, spec, folder, credentialsBaseDir, options, onAuthExpired);
276
+ // Matches this function's own long-standing doc comment above ("NOT called on a failed poll")
277
+ // — previously not actually enforced, since pollOne swallowed a FolderfooAuthError internally
278
+ // and returned normally either way, so onSynced fired even on a failed poll. Surfaced as a
279
+ // real bug once onAuthExpired started clearing identity mid-tick (see pollOne's { ok: false }
280
+ // comment) — onSynced's memoryRepo.get()/skillRepo.get() calls are gated by that same identity,
281
+ // so firing it right after a just-failed poll could report a perfectly real doc as "not found".
282
+ if (ok)
283
+ await onSynced?.(folder);
254
284
  }
255
285
  const interval = setInterval(() => {
256
286
  for (const folder of remoteFolders) {