fix: show shared folders under "Shared with me", not the owner's parents

Stalwart 1.0 lists the folders above a node shared with the user, so
the tree can be walked down to it, but without dates and with every
right false. Bulwark took the owner's top folder for the shared root:
the folder that was actually shared sat several levels further down,
and rename, delete and move were offered on folders where they could
only fail.

Nodes of other accounts with no `modified`, no `created` and no rights
are now marked as placeholders. They are left out of the shared roots,
of shared-drive listings and of search hits; search still uses them to
show the path of a hit below them. 0.16 always returns `modified`, so
nothing changes there.
This commit is contained in:
Linus Rath
2026-09-25 18:34:21 +02:00
parent 9960312b74
commit a7c2e531c7
7 changed files with 172 additions and 4 deletions
@@ -189,3 +189,64 @@ describe('JMAPClient FileNode name decoding (#869)', () => {
expect(nodes[0]).toMatchObject({ id: 'f1', accountId: 'account-1', isShared: false });
});
});
describe('JMAPClient folders listed only on the way to a shared node', () => {
const NO_RIGHTS = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false };
const READ = { ...NO_RIGHTS, mayRead: true };
const T = '2026-09-01T08:00:00Z';
// What Stalwart 1.0 returns for an ancestor the user may not read
// (`discoverable_only`): name and place in the tree, nothing else.
const placeholder = { blobId: null, type: null, size: null, created: null, modified: null, shareWith: null, myRights: NO_RIGHTS };
beforeEach(() => {
vi.restoreAllMocks();
});
/** The user's own drive plus another user's, answered per account. */
function sharingClient(drives: Record<string, object[]>): JMAPClient {
const client = createClient();
const fileCap = { 'urn:ietf:params:jmap:filenode': {} };
Object.assign(client, {
accounts: {
'account-1': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: fileCap },
owner: { name: 'owner@example.com', isPersonal: false, isReadOnly: false, accountCapabilities: fileCap },
},
});
vi.spyOn(globalThis, 'fetch').mockImplementation((async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: MethodCall[] };
const methodResponses = methodCalls.map(([method, args, callId]) =>
[method, { list: drives[args.accountId as string] ?? [], notFound: [] }, callId]);
const body = JSON.stringify({ methodResponses });
return { ok: true, status: 200, text: () => Promise.resolve(body) } as Response;
}) as never);
return client;
}
it('marks the owner\'s folders above a shared folder on Stalwart 1.0', async () => {
const client = sharingClient({
// Not a real shape for an own node; only other accounts are marked.
'account-1': [{ id: 'mine', parentId: null, name: 'Mine', ...placeholder }],
owner: [
{ id: 'a', parentId: null, name: 'Documents', ...placeholder },
{ id: 'b', parentId: 'a', name: 'Projects', ...placeholder },
{ id: 'c', parentId: 'b', name: 'Project X', blobId: null, type: null, size: null, created: T, modified: T, shareWith: null, myRights: READ },
],
});
const nodes = await client.listAllFileNodesAcrossAccounts();
expect(nodes.filter(n => n.discoverableOnly).map(n => n.id)).toEqual(['owner:a', 'owner:b']);
});
it('marks nothing on 0.16, where a child the share predates has no rights but has dates', async () => {
const client = sharingClient({
owner: [
{ id: 'f', parentId: 'hidden', name: 'Shared', blobId: null, type: null, size: null, created: T, modified: T, myRights: READ },
{ id: 'g', parentId: 'f', name: 'old.txt', blobId: 'b1', type: 'text/plain', size: 3, created: T, modified: T, myRights: NO_RIGHTS },
],
});
const nodes = await client.listAllFileNodesAcrossAccounts();
expect(nodes).toHaveLength(2);
expect(nodes.some(n => n.discoverableOnly)).toBe(false);
});
});
@@ -343,4 +343,17 @@ describe('files provider', () => {
const retry = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
expect(retry.hits).toHaveLength(2);
});
it('leaves the owner\'s folders above a shared node out of the hits but not out of its path on Stalwart 1.0', async () => {
const shared = [
// An ancestor listed only as the way down (no dates, no rights).
{ id: 'owner:a', parentId: null, name: 'zebra archive', type: null, blobId: null, size: null, created: null, modified: null, accountId: 'owner', isShared: true, discoverableOnly: true },
{ id: 'owner:x', parentId: 'owner:a', name: 'zebra project', type: null, blobId: null, size: null, created: '2026-01-01T00:00:00Z', modified: '2026-01-01T00:00:00Z', accountId: 'owner', isShared: true },
];
invalidateFileSearchCache();
const acc = account('login-s', { listAllFileNodesAcrossAccounts: vi.fn(async () => shared) });
const { hits } = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
expect(hits.map((h) => h.id)).toEqual(['x']);
expect(hits[0]).toMatchObject({ folderPath: '/zebra archive', isFolder: true, jmapAccountId: 'owner' });
});
});
+2
View File
@@ -97,6 +97,8 @@ export function rawFileNodeId(node: FileNode): string {
function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote'): FileHit[] {
const hits: FileHit[] = [];
for (const node of nodes) {
// The owner's folders above a shared node only feed the path below.
if (node.discoverableOnly) continue;
if (!matchesTerms(parsed.terms, [node.name, isFolderNode(node) ? 'folder' : node.type])) continue;
const folderPath = pathOfNode(nodes, node);
hits.push({
+17 -2
View File
@@ -81,6 +81,18 @@ function fromWireFileNode(node: FileNode): FileNode {
};
}
/**
* A folder Stalwart 1.0 lists only as the way down to something shared
* with the user: the ancestors of a shared node come back with a name and
* parent but no dates and every right false. A node the user may read always
* has both dates, and 0.16 never leaves `modified` out.
*/
function isDiscoverableOnly(node: FileNode): boolean {
const rights = node.myRights;
return node.modified == null && node.created == null
&& !!rights && Object.values(rights).every(granted => !granted);
}
/**
* RFC 7617 Basic credentials, UTF-8 encoded (what Stalwart decodes). `btoa`
* alone is Latin-1: it garbles `ü` and throws on anything past U+00FF, such
@@ -8032,7 +8044,8 @@ export class JMAPClient implements IJMAPClient {
* shared accounts. Nodes owned by another principal (shared with the user)
* are tagged with `isShared: true` and the owning `accountId`/`accountName`,
* and their ids are namespaced `accountId:nodeId` so they don't collide with
* the primary account's ids. Mirrors getAllCalendars().
* the primary account's ids. Folders listed only as the way down to a shared
* node are marked `discoverableOnly`. Mirrors getAllCalendars().
*/
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
const primaryId = this.getFilesAccountId();
@@ -8045,8 +8058,9 @@ export class JMAPClient implements IJMAPClient {
try {
const nodes = await this.fetchAllFileNodes(accountId);
for (const node of nodes) {
const wire = fromWireFileNode(node);
all.push({
...fromWireFileNode(node),
...wire,
id: isPrimary ? node.id : `${accountId}:${node.id}`,
parentId: node.parentId == null
? null
@@ -8054,6 +8068,7 @@ export class JMAPClient implements IJMAPClient {
accountId,
accountName: account?.name || (isPrimary ? this.username : accountId),
isShared: !isPrimary,
...(!isPrimary && isDiscoverableOnly(wire) ? { discoverableOnly: true } : {}),
});
}
} catch (error) {
+4
View File
@@ -1019,6 +1019,10 @@ export interface FileNode {
// True when this node was fetched from another principal's account that was
// shared with the logged-in user (mirrors Calendar.isShared / AddressBook.isShared).
isShared?: boolean;
// A folder of another account that is listed only because something below
// it was shared with the user (Stalwart 1.0): name and place in the tree,
// no dates, no rights. It leads to the shared node but is not shown itself.
discoverableOnly?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across connected/shared accounts so mutations route to the right account.
accountId?: string;
+66
View File
@@ -412,6 +412,72 @@ describe('file-store modification date (issue #700)', () => {
});
});
describe('file-store "Shared with me"', () => {
const NO_RIGHTS = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false };
const READ = { ...NO_RIGHTS, mayRead: true };
const T = '2026-09-01T08:00:00Z';
// Another user's nodes as listAllFileNodesAcrossAccounts hands them over.
const shared = (id: string, name: string, parentId: string | null, blobId: string | null = null): FileNode => ({
id: `owner:${id}`, parentId: parentId && `owner:${parentId}`, name, type: blobId ? 'text/plain' : '', blobId, size: blobId ? 3 : 0,
created: T, modified: T, myRights: READ, isShared: true, accountId: 'owner', accountName: 'Owner',
});
// Stalwart 1.0's ancestors of a shared node: no dates, no rights.
const placeholder = (id: string, name: string, parentId: string | null): FileNode => ({
...shared(id, name, parentId), created: null as unknown as string, modified: null as unknown as string,
myRights: NO_RIGHTS, discoverableOnly: true,
});
function withSharedDrive(nodes: FileNode[]) {
const client = makeMockClient([]);
Object.assign(client, { listAllFileNodesAcrossAccounts: async () => nodes.map(n => ({ ...n })) });
return client;
}
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
sharedRoots: [],
});
});
it('shows the shared folder as the root, not the owner\'s folders above it, on Stalwart 1.0', async () => {
useFileStore.getState().initClient(withSharedDrive([
placeholder('docs', 'Documents', null),
placeholder('projects', 'Projects', 'docs'),
shared('x', 'Project X', 'projects'),
shared('report', 'report.txt', 'x', 'b1'),
// A subfolder the owner took back, with something below it still shared.
placeholder('private', 'Private', 'x'),
shared('deep', 'Deep', 'private'),
]));
await useFileStore.getState().loadSharedRoots();
expect(useFileStore.getState().sharedRoots.map(r => r.name)).toEqual(['Deep', 'Project X']);
await useFileStore.getState().navigate('owner:x', 'Project X');
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['report.txt']);
});
it('keeps a child that reports no rights but has dates, as 0.16 lists one the share predates', async () => {
useFileStore.getState().initClient(withSharedDrive([
// 0.16 leaves the owner's folders above a share out of the listing.
shared('f', 'Shared', 'unlisted'),
{ ...shared('old', 'old.txt', 'f', 'b1'), myRights: NO_RIGHTS },
]));
await useFileStore.getState().loadSharedRoots();
expect(useFileStore.getState().sharedRoots.map(r => r.name)).toEqual(['Shared']);
await useFileStore.getState().navigate('owner:f', 'Shared');
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['old.txt']);
});
});
describe('resourceServerRef', () => {
it('splits a shared drive\'s namespaced id into the owner account and the node id', () => {
expect(resourceServerRef({ id: 'owner1:n5', isShared: true, ownerAccountId: 'owner1' }))
+9 -2
View File
@@ -528,7 +528,11 @@ export const useFileStore = create<FileState>((set, get) => ({
const allNodes = isCrossAccountId(parentId)
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource));
// The owner's folders on the way down to a shared node aren't shown
// (see loadSharedRoots); the user can do nothing in them.
const resources = sortResources(
childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource),
);
// Prune recent files whose backing node no longer exists on the server
const { recentFiles } = get();
@@ -1048,7 +1052,10 @@ export const useFileStore = create<FileState>((set, get) => ({
return;
}
try {
const all = await client.listAllFileNodesAcrossAccounts();
// Stalwart 1.0 also lists the owner's folders above a shared node
// (discoverableOnly); left in, the owner's top folder became the root
// and the shared folder sat somewhere below it.
const all = (await client.listAllFileNodesAcrossAccounts()).filter(n => !n.discoverableOnly);
const idSet = new Set(all.map(n => n.id));
// A shared node is a "root" of the shared-with-me tree when its parent is
// not among the nodes the user can see (either null, or owned by a