fix: show a folder shared read only on Stalwart 1.0 as read only, not custom

This commit is contained in:
Linus Rath
2026-10-28 04:50:08 +01:00
parent 9aa810f20d
commit 2e34759f54
2 changed files with 48 additions and 5 deletions
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { detectPreset, MAILBOX_PRESETS } from '../share-rights';
import type { MailboxRights } from '../types';
// What Stalwart 1.0 and v1.0.0 return for a folder shared with the "read"
// preset (checked on the test servers, 2026-10-28): the keyword right comes
// back true with the seen right, and v1.0.0 adds maySetMetadata.
const READ_AS_STORED_BY_1_0 = {
mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: true,
maySetKeywords: true, mayCreateChild: false, mayRename: false, maySubmit: false,
mayDelete: false, mayShare: false, maySetMetadata: false,
} as MailboxRights;
describe('detectPreset', () => {
it('reads a folder shared read-only on Stalwart 1.0 back as read only', () => {
expect(detectPreset('mailbox', READ_AS_STORED_BY_1_0)).toBe('read');
});
it('still tells read and write apart', () => {
expect(detectPreset('mailbox', { ...READ_AS_STORED_BY_1_0, mayAddItems: true, mayRemoveItems: true })).toBe('readWrite');
expect(detectPreset('mailbox', MAILBOX_PRESETS.read)).toBe('read');
});
it('does not excuse a keyword right without the seen right', () => {
expect(detectPreset('mailbox', { ...MAILBOX_PRESETS.read, maySetSeen: false, maySetKeywords: true })).toBe('custom');
});
it('ignores rights a newer server adds', () => {
expect(detectPreset('addressBook', { mayRead: true, mayWrite: true, mayDelete: false, mayShare: false, maySetMetadata: false } as never)).toBe('readWrite');
});
});
+17 -5
View File
@@ -1,7 +1,17 @@
/**
* The rights Bulwark grants when it shares a folder, calendar, address book
* or file (`shareWith/<principalId>`), as named presets, and the preset a
* stored grant amounts to.
* or file (`shareWith/<principalId>`), as named presets. The share dialog of
* a single item and the Delegates page both build their patches from here.
*
* What Stalwart stores differs from what it was sent in places:
* - 1.0 keeps `maySetSeen` and `maySetKeywords` of a mailbox as one right, so
* a "read" grant (seen yes, keywords no) reads back with both true.
* - v1.0.0 adds `maySetMetadata` to every type. Bulwark never sends it (0.16
* would refuse the property), and the server stores false.
* - Stalwart before 0.16.6 names file rights mayRead / mayWrite / mayShare;
* JMAPClient.setFileNodeShare converts on the way out.
* So presets are compared on the rights they define, and the mailbox
* keyword right is allowed to follow the seen right.
*/
import type { AddressBookRights, CalendarRights, FileNodeRights, MailboxRights } from './types';
@@ -79,11 +89,13 @@ function presetsFor(kind: ShareKind): Partial<Record<RolePreset, AnyRights>> {
return ADDRESS_BOOK_PRESETS;
}
function matches(preset: AnyRights, rights: AnyRights): boolean {
function matches(kind: ShareKind, preset: AnyRights, rights: AnyRights): boolean {
const have = rights as unknown as Record<string, boolean | undefined>;
return Object.entries(preset).every(([key, want]) => {
const got = have[key] ?? false;
return got === want;
if (got === want) return true;
// Stalwart 1.0 stores the two as one right (see the top of this file).
return kind === 'mailbox' && key === 'maySetKeywords' && got === true && have.maySetSeen === true;
});
}
@@ -96,7 +108,7 @@ export function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | u
/** The preset a stored grant amounts to, or "custom". */
export function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
for (const [name, preset] of Object.entries(presetsFor(kind)) as [RolePreset, AnyRights][]) {
if (matches(preset, rights)) return name;
if (matches(kind, preset, rights)) return name;
}
return 'custom';
}