Board filter matches re-check the board's access, not only its observer.

card-text-filter failed intermittently in the EVERYTHING runs: after a
member's assigned-only restriction was lifted, the text-filter matches
never widened again (still one match after 100 s). Instrumented on the
polling test server, the failing subscription's board observer delivered
its initial add and then nothing - neither the narrowing nor the widening
write - while the card observers of the same board kept firing; the
narrowing only applied because a card edit happened to trigger a rescan.
With nothing after the widening, nothing rescanned. The same stall would
also have kept a NARROWED member's old matches, which is an access leak.

publishBoardMatches (text matches, movement matches, table pages) now also
re-reads the board every 10 s and rescans when its access fields -
permission, members and the provider's own board fields - differ from
the ones the published result was computed from, unreadable boards
included. An unchanged board does not rescan.
tests/boardTextPublication.test.cjs widens, narrows and revokes with the
observer silent and only the re-check firing; both tests fail on the
previous code. card-text-filter passed 3 of 3 in Firefox afterwards.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-07 23:46:07 +03:00
1 parent e7017ca745
commit 479f530da0
2 files changed
+68 -5

No files matched your search

+24 -2
View File
@@ -4,12 +4,25 @@ import Cards from '/models/cards';
import { canReadBoard } from '/models/lib/boardVisibility';
const { assignedOnlyCardScope } = require('/models/lib/boardCardScope');
// The board's access fields are also re-read on this interval. The board
// observer below is what normally reports a membership change, but on a busy
// test server (polling, no oplog) one stalled for good: a member's
// assigned-only restriction was lifted and the matches never widened again,
// and the same stall would have kept a NARROWED member's old matches. A
// missed observer event must not decide what a member may see.
const ACCESS_RECHECK_MS = 10000;
const accessSignature = (board, boardFields) => JSON.stringify(board ? [board.permission, board.members,
...Object.keys(boardFields).sort().map(field => board[field])] : null);
// Shared authorized, reactive ID-only publication for board filter providers.
export async function publishBoardMatches({ publication, boardId, key, identity,
collectionName, children, cardFields = {}, boardFields = {}, snapshot = false, prepare, findMatches }) {
let stopped = false, initializing = true, running = false, pending = false, revision = 0;
const handles = [], published = new Set();
let pagePublished = false;
// The access fields the published result was computed from.
let seenAccess;
let accessTimer;
const pageCards = new Map();
const watches = new Map();
const rowId = cardId => JSON.stringify([boardId, key, cardId]);
@@ -23,6 +36,7 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
};
publication.onStop(() => {
stopped = true;
if (accessTimer) Meteor.clearInterval(accessTimer);
for (const handle of handles) handle.stop();
for (const { handle } of watches.values()) handle.stop();
watches.clear();
@@ -47,7 +61,7 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
const version = revision;
const board = await Boards.findOneAsync(boardId);
if (stopped) return;
if (!canReadBoard(publication.userId, board)) { clear(); continue; }
if (!canReadBoard(publication.userId, board)) { seenAccess = accessSignature(board, boardFields); clear(); continue; }
const scope = { boardId, archived: false, ...assignedOnlyCardScope(board, publication.userId) };
const isStopped = () => stopped || revision !== version;
const prepared = prepare ? await prepare({ scope, board, watch, stopped: isStopped }) : undefined;
@@ -59,10 +73,11 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
// the subscription starts. A concurrent policy change forces a retry.
const latest = await Boards.findOneAsync(boardId);
if (stopped) return;
if (!canReadBoard(publication.userId, latest)) { clear(); continue; }
if (!canReadBoard(publication.userId, latest)) { seenAccess = accessSignature(latest, boardFields); clear(); continue; }
if (revision !== version || JSON.stringify(latest.members) !== JSON.stringify(board.members)) {
pending = true; continue;
}
seenAccess = accessSignature(latest, boardFields);
if (prepared?.isCurrent && !(await prepared.isCurrent())) { clear(); pending = true; continue; }
if (isStopped()) { pending = !stopped; continue; }
if (snapshot) {
@@ -113,6 +128,13 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
}
initializing = false;
await refresh();
if (!stopped) accessTimer = Meteor.setInterval(async () => {
if (stopped || running || seenAccess === undefined) return;
try {
const latest = await Boards.findOneAsync(boardId);
if (!stopped && accessSignature(latest, boardFields) !== seenAccess) boardChanged();
} catch (error) { /* the next tick, or the observer, tries again */ }
}, ACCESS_RECHECK_MS);
} catch (error) {
if (!stopped) publication.error(new Meteor.Error('card-filter-failed', 'Card filtering failed'));
}
+44 -3
View File
@@ -6,7 +6,7 @@ const vm = require('node:vm');
const { assignedOnlyCardScope } = require('../models/lib/boardCardScope');
const tick = () => new Promise(resolve => setImmediate(resolve));
async function fixture(search, snapshot = false, prepare) {
const observers = [], rows = new Map();
const observers = [], rows = new Map(), intervals = [], cleared = [];
let handler, stop, ready = 0;
let board = { _id: 'board', permission: 'private', members: [{ userId: 'reader', isActive: true, isReadAssignedOnly: true }] };
const model = name => ({ rawCollection: () => name, find: () => ({ observeChangesAsync: async callbacks => {
@@ -23,7 +23,9 @@ async function fixture(search, snapshot = false, prepare) {
collectionName: 'pages', snapshot: true, cardFields: null, children: [], prepare: prepareFixture,
findMatches: boardTextSearch });
});` : '';
vm.runInNewContext(source + snapshotSource, { Meteor: { publish: (name, fn) => { handler = fn; }, Error }, check() {},
vm.runInNewContext(source + snapshotSource, { Meteor: { publish: (name, fn) => { handler = fn; }, Error,
setInterval: (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; },
clearInterval: id => { cleared.push(id); } }, check() {},
Boards, Cards: model('cards'), CardComments: model('comments'), Checklists: model('checklists'), ChecklistItems: model('items'),
canReadBoard: (id, doc) => !!doc && (doc.permission === 'public' || doc.members.some(m => m.userId === id && m.isActive)),
assignedOnlyCardScope, boardTextSearch: search, prepareFixture: prepare,
@@ -33,7 +35,7 @@ async function fixture(search, snapshot = false, prepare) {
changed: (collection, id, fields) => rows.set(id, { ...rows.get(id), ...fields }),
error: error => { throw error; } };
return { observers, rows, context, start: term => handler.call(context, 'board', term),
setBoard(value) { board = value; }, stop: () => stop(), ready: () => ready };
setBoard(value) { board = value; }, stop: () => stop(), ready: () => ready, intervals, cleared };
}
test('publication scopes the join, retracts revoked results and cleans every observer', async () => {
const scopes = [];
@@ -109,3 +111,42 @@ test('invalid or unauthorized requests never search and stopped scans never publ
const running = f.start('needle'); await started; f.stop(); finish(['secret']); await running;
assert.equal(f.rows.size, 0); assert.ok(f.observers.every(handle => handle.stopped));
});
// The board observer stalled for good on a busy test server (polling, no
// oplog): lifting a member's assigned-only restriction never widened the text
// matches. The periodic access re-check must catch a change the observer never
// reports - in BOTH directions, since a stalled narrowing would leak matches.
test('access changes reach the results even when the board observer never reports them', async () => {
const scopes = [];
const f = await fixture(async args => { scopes.push(args.scope); return args.scope.assignees ? ['assigned'] : ['assigned', 'other']; });
await f.start('needle');
assert.equal(f.intervals.length, 1, 'one access re-check per subscription');
assert.equal(f.intervals[0].ms, 10000);
assert.deepEqual([...f.rows.values()].map(row => row.cardId), ['assigned']);
// Widen: the restriction is lifted, and NO observer callback fires.
f.setBoard({ _id: 'board', permission: 'private', members: [{ userId: 'reader', isActive: true, isReadAssignedOnly: false }] });
await f.intervals[0].fn(); await tick(); await tick();
assert.deepEqual([...f.rows.values()].map(row => row.cardId).sort(), ['assigned', 'other']);
assert.equal(scopes.at(-1).assignees, undefined);
// Negative: narrowing again, also without an observer event, retracts.
f.setBoard({ _id: 'board', permission: 'private', members: [{ userId: 'reader', isActive: true, isReadAssignedOnly: true }] });
await f.intervals[0].fn(); await tick(); await tick();
assert.deepEqual([...f.rows.values()].map(row => row.cardId), ['assigned']);
// Negative: an unchanged board does not rescan on every tick.
const scans = scopes.length;
await f.intervals[0].fn(); await tick();
assert.equal(scopes.length, scans);
f.stop();
assert.deepEqual(f.cleared, [1], 'the re-check stops with the subscription');
});
test('revoked access is retracted by the re-check alone', async () => {
const f = await fixture(async () => ['assigned']);
await f.start('needle');
assert.equal(f.rows.size, 1);
f.setBoard({ _id: 'board', permission: 'private', members: [] });
await f.intervals[0].fn(); await tick(); await tick();
assert.equal(f.rows.size, 0);
f.stop();
});