feat(electron): add capability grant store for filesystem IPC

Lays the groundwork for issue #8228: today the FILE_SYNC_* IPCs accept
any renderer-supplied absolute path outside userData, which means any
plugin or XSS in the renderer can read/write arbitrary user files
(~/.ssh, /etc/passwd, etc.). The goal is to replace string-path
authorization with capability-based grants: the renderer holds only an
opaque grantId, the main process holds the canonical root, and FS ops
take (grantId, relativePath).

This commit adds the main-side primitives but does not wire them into
any IPC yet:

- grant-store: persisted, main-only, feature-scoped grants backed by
  simple-store under a new 'fileGrants' key. Opaque hex ids from
  crypto.randomBytes. Roots are canonicalized at create time, and
  revalidated at startup so a folder that was moved, deleted, or
  swapped for a symlink-elsewhere invalidates the grant cleanly.

- grant-resolver: resolveGrantPath(grantId, relativePath, feature)
  returns the absolute path the IPC handler may touch. Enforces the
  feature scope match, rejects absolute paths and '..' traversal, and
  refuses symlinks. For not-yet-existing leaves it canonicalizes the
  deepest existing ancestor so a directory symlink mid-path cannot
  sneak a write outside the root. Errors are opaque
  (PathNotAllowedError) and never embed the offending path.

Notes on tests: ts-node's transpile-only loader does not downlevel
'for...of map' iteration, so the store uses Array.from(map.entries())
where it iterates the cache. The existing electron *.test.cjs use the
same loader and the same workaround was already implicit in their
patterns.

Phase 2 (switching FILE_SYNC_SAVE/LOAD/REMOVE/LIST_FILES + CHECK_DIR_EXISTS
to grant-based, with re-confirm migration of the existing syncFolderPath)
and Phase 3 (image picker copy-to-cache) follow in separate commits.
This commit is contained in:
johannesjo 2026-06-09 22:13:54 +02:00
parent 595e7cef2a
commit 5cb83b41a0
4 changed files with 729 additions and 0 deletions

View file

@ -0,0 +1,236 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const { promises: fs } = require('node:fs');
const fsModule = require('node:fs');
const Module = require('node:module');
require('ts-node/register/transpile-only');
const originalModuleLoad = Module._load;
let userDataDir;
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
const grantStoreModulePath = path.resolve(__dirname, 'grant-store.ts');
const grantResolverModulePath = path.resolve(__dirname, 'grant-resolver.ts');
const installMocks = () => {
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
getPath: () => userDataDir,
},
};
}
if (request === 'electron-log/main') {
return { log: () => {}, error: () => {} };
}
return originalModuleLoad.call(this, request, parent, isMain);
};
};
const resetModules = () => {
delete require.cache[simpleStoreModulePath];
delete require.cache[grantStoreModulePath];
delete require.cache[grantResolverModulePath];
};
const loadModules = () => {
resetModules();
return {
grantStore: require(grantStoreModulePath),
resolver: require(grantResolverModulePath),
};
};
const setupGrant = async (grantStore, syncDir, feature = 'sync') => {
await grantStore.revalidateGrants();
const grant = await grantStore.createGrant(feature, syncDir);
assert.ok(grant);
return grant;
};
test.beforeEach(async () => {
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-grant-resolver-'));
installMocks();
});
test.afterEach(async () => {
Module._load = originalModuleLoad;
resetModules();
await fs.rm(userDataDir, { recursive: true, force: true });
});
test('resolves a relative file path inside the grant root', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
const grant = await setupGrant(grantStore, syncDir);
const result = resolver.resolveGrantPath(grant.id, 'main.json', 'sync');
assert.equal(
result.absolutePath,
path.join(fsModule.realpathSync.native(syncDir), 'main.json'),
);
assert.equal(result.root, fsModule.realpathSync.native(syncDir));
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('rejects absolute renderer-supplied paths', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
const grant = await setupGrant(grantStore, syncDir);
assert.throws(
() => resolver.resolveGrantPath(grant.id, '/etc/passwd', 'sync'),
/Path not allowed/,
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('rejects .. traversal that escapes the grant root', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
const grant = await setupGrant(grantStore, syncDir);
assert.throws(
() => resolver.resolveGrantPath(grant.id, '../escape', 'sync'),
/Path not allowed/,
);
assert.throws(
() => resolver.resolveGrantPath(grant.id, 'a/../../b', 'sync'),
/Path not allowed/,
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('rejects an unknown grantId', async () => {
const { grantStore, resolver } = loadModules();
await grantStore.revalidateGrants();
assert.throws(
() => resolver.resolveGrantPath('deadbeef', 'main.json', 'sync'),
/Path not allowed/,
);
});
test('rejects a grant whose feature does not match the expected scope', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
const grant = await setupGrant(grantStore, syncDir, 'sync');
// A future 'background-image' grant must not be reusable in a 'sync' IPC.
// We can't construct one yet (only 'sync' is defined), so simulate by
// asking the resolver for the wrong feature via a forged TS-side cast.
assert.throws(
() => resolver.resolveGrantPath(grant.id, 'main.json', 'background-image'),
/Path not allowed/,
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('rejects a leaf that is a symlink (even pointing inside the root)', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
await fs.writeFile(path.join(syncDir, 'real.json'), '{}', 'utf8');
await fs.symlink(
path.join(syncDir, 'real.json'),
path.join(syncDir, 'link.json'),
'file',
);
const grant = await setupGrant(grantStore, syncDir);
assert.throws(
() => resolver.resolveGrantPath(grant.id, 'link.json', 'sync'),
/Path not allowed/,
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('rejects writing under a directory symlink that escapes the root', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-outside-'));
try {
await fs.symlink(outsideDir, path.join(syncDir, 'escape'), 'dir');
const grant = await setupGrant(grantStore, syncDir);
// 'escape/new.json' lexically lives inside the root, but writing there
// would land in outsideDir. Ancestor canonicalization catches this.
assert.throws(
() => resolver.resolveGrantPath(grant.id, 'escape/new.json', 'sync'),
/Path not allowed/,
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
test('allows writing under a directory symlink that stays inside the root', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
await fs.mkdir(path.join(syncDir, 'real-sub'), { recursive: true });
await fs.symlink(path.join(syncDir, 'real-sub'), path.join(syncDir, 'sub'), 'dir');
const grant = await setupGrant(grantStore, syncDir);
const result = resolver.resolveGrantPath(grant.id, 'sub/new.json', 'sync');
assert.equal(
result.absolutePath,
path.join(fsModule.realpathSync.native(syncDir), 'sub', 'new.json'),
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('rejects non-string inputs (fail-closed)', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
const grant = await setupGrant(grantStore, syncDir);
assert.throws(
() => resolver.resolveGrantPath(grant.id, undefined, 'sync'),
/Path not allowed/,
);
assert.throws(
() => resolver.resolveGrantPath(undefined, 'main.json', 'sync'),
/Path not allowed/,
);
assert.throws(
() => resolver.resolveGrantPath(grant.id, 42, 'sync'),
/Path not allowed/,
);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('error never includes the offending path', async () => {
const { grantStore, resolver } = loadModules();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
const grant = await setupGrant(grantStore, syncDir);
let captured;
try {
resolver.resolveGrantPath(grant.id, '/etc/passwd', 'sync');
} catch (e) {
captured = e;
}
assert.ok(captured);
assert.ok(!String(captured.message).includes('/etc/passwd'));
assert.equal(captured.name, 'PathNotAllowedError');
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});

116
electron/grant-resolver.ts Normal file
View file

@ -0,0 +1,116 @@
import * as fs from 'fs';
import * as path from 'path';
import { getGrant, type GrantFeature } from './grant-store';
/**
* Resolve a renderer-supplied (grantId, relativePath) pair to an absolute path
* the main process is allowed to touch.
*
* Layered defenses (any failure opaque deny, no path leaked back):
* 1. Grant exists and matches the feature scope expected by the caller.
* (A 'background-image' grant must not be reusable in a sync IPC.)
* 2. `relativePath` is a string, not absolute, and does not traverse out of
* the grant root (`path.relative(root, joined)` must not start with '..').
* 3. The leaf (if it already exists) is not a symlink. v1 policy is "refuse
* symlinks" simpler than O_NOFOLLOW-on-open and sufficient for the sync
* use case. The TOCTOU window between this check and the subsequent fs
* op is documented as a known limitation; replacing the path-based
* check with `open(O_NOFOLLOW)` is a follow-up.
* 4. The canonicalized resolved path is still inside the canonicalized root.
* Catches case-fold / 8.3-shortname aliases (existing concern, same
* reasoning as `electron/file-path-guard.ts`).
*/
const _denied = (): Error => {
const e = new Error('Path not allowed for this grant');
e.name = 'PathNotAllowedError';
delete (e as { stack?: string }).stack;
return e;
};
const _hasTraversal = (root: string, joined: string): boolean => {
const rel = path.relative(root, joined);
return rel === '' || rel.startsWith('..') || path.isAbsolute(rel);
};
export interface ResolvedGrantPath {
/** Absolute filesystem path the IPC handler may operate on. */
readonly absolutePath: string;
/** The granted root the path resolved under. Useful for error/log messages. */
readonly root: string;
}
/**
* Throws `PathNotAllowedError` if the (grantId, relativePath) pair does not
* resolve cleanly inside the grant root for `expectedFeature`. On success,
* returns the absolute path. The returned error never embeds path content.
*/
export const resolveGrantPath = (
grantId: string,
relativePath: string,
expectedFeature: GrantFeature,
): ResolvedGrantPath => {
if (typeof grantId !== 'string' || typeof relativePath !== 'string') {
throw _denied();
}
const grant = getGrant(grantId);
if (!grant || grant.feature !== expectedFeature) {
throw _denied();
}
if (path.isAbsolute(relativePath)) {
throw _denied();
}
const joined = path.resolve(grant.root, relativePath);
if (_hasTraversal(grant.root, joined)) {
throw _denied();
}
let leafLstat: fs.Stats | null = null;
try {
leafLstat = fs.lstatSync(joined);
} catch {
// Leaf may legitimately not exist yet (e.g. FILE_SYNC_SAVE target).
leafLstat = null;
}
if (leafLstat && leafLstat.isSymbolicLink()) {
throw _denied();
}
// Defense-in-depth: if the leaf exists, its canonical form must still resolve
// inside the canonical root. Catches case-fold/short-name aliases AND a
// symlinked intermediate directory (only the LEAF was lstat'd above).
if (leafLstat) {
let canonicalLeaf: string;
try {
canonicalLeaf = fs.realpathSync.native(joined);
} catch {
throw _denied();
}
if (_hasTraversal(grant.root, canonicalLeaf)) {
throw _denied();
}
} else {
// Leaf doesn't exist: check the deepest existing ancestor instead, so a
// symlinked directory in the middle of the path can't sneak the write
// outside the root.
let cursor = path.dirname(joined);
while (cursor !== path.dirname(cursor)) {
let realAncestor: string | null = null;
try {
realAncestor = fs.realpathSync.native(cursor);
} catch {
realAncestor = null;
}
if (realAncestor !== null) {
if (realAncestor !== grant.root && _hasTraversal(grant.root, realAncestor)) {
throw _denied();
}
break;
}
cursor = path.dirname(cursor);
}
}
return { absolutePath: joined, root: grant.root };
};

View file

@ -0,0 +1,210 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const { promises: fs } = require('node:fs');
const fsModule = require('node:fs');
const Module = require('node:module');
require('ts-node/register/transpile-only');
const originalModuleLoad = Module._load;
let userDataDir;
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
const grantStoreModulePath = path.resolve(__dirname, 'grant-store.ts');
const installMocks = () => {
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
getPath: (key) => {
assert.equal(key, 'userData');
return userDataDir;
},
},
};
}
if (request === 'electron-log/main') {
return {
log: () => {},
error: () => {},
};
}
return originalModuleLoad.call(this, request, parent, isMain);
};
};
const resetModules = () => {
delete require.cache[simpleStoreModulePath];
delete require.cache[grantStoreModulePath];
};
const loadGrantStore = () => {
resetModules();
return require(grantStoreModulePath);
};
test.beforeEach(async () => {
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-grant-store-'));
installMocks();
});
test.afterEach(async () => {
Module._load = originalModuleLoad;
resetModules();
await fs.rm(userDataDir, { recursive: true, force: true });
});
test('createGrant canonicalizes the path and persists the grant', async () => {
const grantStore = loadGrantStore();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
await grantStore.revalidateGrants();
const grant = await grantStore.createGrant('sync', syncDir);
assert.ok(grant, 'grant should be created');
assert.equal(grant.feature, 'sync');
assert.equal(grant.root, fsModule.realpathSync.native(syncDir));
assert.match(grant.id, /^[a-f0-9]{32}$/);
// Reload from disk: grant survives.
grantStore.__resetGrantCacheForTests();
await grantStore.revalidateGrants();
const reloaded = grantStore.getGrant(grant.id);
assert.ok(reloaded);
assert.equal(reloaded.root, grant.root);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('createGrant returns null for a non-existent path', async () => {
const grantStore = loadGrantStore();
await grantStore.revalidateGrants();
const grant = await grantStore.createGrant(
'sync',
path.join(userDataDir, 'does-not-exist'),
);
assert.equal(grant, null);
});
test('getGrant returns null for an unknown id (renderer cannot guess)', async () => {
const grantStore = loadGrantStore();
await grantStore.revalidateGrants();
assert.equal(grantStore.getGrant('deadbeef'), null);
assert.equal(grantStore.getGrant(''), null);
// Non-string defends against renderer-supplied bad input round-tripping JSON.
assert.equal(grantStore.getGrant(12345), null);
});
test('revokeGrant removes the grant and persists the removal', async () => {
const grantStore = loadGrantStore();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
try {
await grantStore.revalidateGrants();
const grant = await grantStore.createGrant('sync', syncDir);
assert.ok(grant);
await grantStore.revokeGrant(grant.id);
assert.equal(grantStore.getGrant(grant.id), null);
grantStore.__resetGrantCacheForTests();
await grantStore.revalidateGrants();
assert.equal(grantStore.getGrant(grant.id), null);
} finally {
await fs.rm(syncDir, { recursive: true, force: true });
}
});
test('revalidateGrants drops grants whose root no longer exists', async () => {
const grantStore = loadGrantStore();
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
await grantStore.revalidateGrants();
const grant = await grantStore.createGrant('sync', syncDir);
assert.ok(grant);
await fs.rm(syncDir, { recursive: true, force: true });
grantStore.__resetGrantCacheForTests();
await grantStore.revalidateGrants();
assert.equal(grantStore.getGrant(grant.id), null);
});
test('revalidateGrants drops grants whose root canonical path changed', async () => {
const grantStore = loadGrantStore();
const originalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
const replacementDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-other-'));
try {
await grantStore.revalidateGrants();
const grant = await grantStore.createGrant('sync', originalDir);
assert.ok(grant);
// Replace the original directory with a symlink to a different real dir.
await fs.rm(originalDir, { recursive: true, force: true });
await fs.symlink(replacementDir, originalDir, 'dir');
grantStore.__resetGrantCacheForTests();
await grantStore.revalidateGrants();
// Canonical root changed (symlink resolves elsewhere) → grant invalidated.
assert.equal(grantStore.getGrant(grant.id), null);
} finally {
await fs.rm(originalDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(replacementDir, { recursive: true, force: true });
}
});
test('listGrantsByFeature returns only matching grants', async () => {
const grantStore = loadGrantStore();
const a = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-a-'));
const b = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-b-'));
try {
await grantStore.revalidateGrants();
const ga = await grantStore.createGrant('sync', a);
const gb = await grantStore.createGrant('sync', b);
assert.ok(ga && gb);
const list = grantStore.listGrantsByFeature('sync');
assert.equal(list.length, 2);
} finally {
await fs.rm(a, { recursive: true, force: true });
await fs.rm(b, { recursive: true, force: true });
}
});
test('persisted store with corrupt grant entries is filtered, not crashed on', async () => {
const grantStore = loadGrantStore();
await grantStore.revalidateGrants();
// Forge a corrupt simpleSettings file mixing a valid-shape grant with junk.
const validRoot = fsModule.realpathSync.native(
await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-')),
);
try {
const corrupt = {
fileGrants: {
grants: [
{
id: 'abc',
feature: 'sync',
root: validRoot,
createdAt: 1,
},
{ id: 'bad', feature: 'unknown', root: '/x', createdAt: 1 },
null,
'not-an-object',
{ id: '', feature: 'sync', root: validRoot, createdAt: 1 },
],
},
};
await fs.writeFile(
path.join(userDataDir, 'simpleSettings'),
JSON.stringify(corrupt),
'utf8',
);
grantStore.__resetGrantCacheForTests();
await grantStore.revalidateGrants();
assert.ok(grantStore.getGrant('abc'));
assert.equal(grantStore.getGrant('bad'), null);
} finally {
await fs.rm(validRoot, { recursive: true, force: true });
}
});

167
electron/grant-store.ts Normal file
View file

@ -0,0 +1,167 @@
import { randomBytes } from 'crypto';
import * as fs from 'fs';
import { loadSimpleStoreAll, saveSimpleStore } from './simple-store';
/**
* Main-owned, persisted capability grants for filesystem access.
*
* Background: the renderer (and any plugin/XSS running inside it) can ask the
* main process to read/write files via the FILE_SYNC_* IPCs. Before grants
* those IPCs accepted any absolute path outside `userData` i.e. nearly the
* whole filesystem. A grant narrows that authority to a specific user-approved
* root directory and a specific feature (e.g. 'sync').
*
* The renderer only ever sees the opaque `id`; the canonicalized `root` never
* leaves the main process. Grants live in `simple-store` (inside userData),
* which is itself protected from renderer writes by `assertPathOutside`.
*/
export type GrantFeature = 'sync';
export interface FileGrant {
readonly id: string;
readonly feature: GrantFeature;
/**
* Canonicalized absolute path (resolved via `fs.realpathSync.native` at
* creation time so symlink/case-fold aliases cannot be slipped past the
* containment check at resolve time).
*/
readonly root: string;
readonly createdAt: number;
}
const SIMPLE_STORE_KEY = 'fileGrants';
interface PersistedGrants {
readonly grants: readonly FileGrant[];
}
const GRANT_ID_BYTES = 16;
let _cache: Map<string, FileGrant> | null = null;
let _loadOnce: Promise<void> | null = null;
const _isFileGrant = (v: unknown): v is FileGrant => {
if (typeof v !== 'object' || v === null) return false;
const g = v as Record<string, unknown>;
return (
typeof g.id === 'string' &&
g.id.length > 0 &&
g.feature === 'sync' &&
typeof g.root === 'string' &&
g.root.length > 0 &&
typeof g.createdAt === 'number'
);
};
const _loadIntoCache = async (): Promise<void> => {
if (_loadOnce) return _loadOnce;
_loadOnce = (async () => {
const all = await loadSimpleStoreAll();
const raw = all[SIMPLE_STORE_KEY] as PersistedGrants | undefined;
const list = Array.isArray(raw?.grants) ? raw!.grants.filter(_isFileGrant) : [];
_cache = new Map(list.map((g) => [g.id, g]));
})();
return _loadOnce;
};
const _persist = async (): Promise<void> => {
const grants = Array.from((_cache ?? new Map<string, FileGrant>()).values());
await saveSimpleStore(SIMPLE_STORE_KEY, { grants } satisfies PersistedGrants);
};
/**
* Drop grants whose root no longer resolves to the same canonical path it had
* at grant time (folder moved, deleted, replaced by a symlink to elsewhere).
* Called at startup; invalid grants force the user back through the picker.
*/
export const revalidateGrants = async (): Promise<void> => {
await _loadIntoCache();
if (!_cache) return;
const toRemove: string[] = [];
// Array.from() instead of `for...of map` because ts-node's transpile-only
// loader (used by *.test.cjs) does not downlevel Map iteration.
for (const [id, grant] of Array.from(_cache.entries())) {
let stillValid = false;
try {
const real = fs.realpathSync.native(grant.root);
stillValid = real === grant.root;
} catch {
stillValid = false;
}
if (!stillValid) toRemove.push(id);
}
if (toRemove.length === 0) return;
for (const id of toRemove) _cache.delete(id);
await _persist();
};
/**
* Create a grant for `absolutePath`. Caller must have proven user intent
* (e.g. the path came from a main-process file dialog). The path is
* canonicalized; if it does not exist or canonicalization fails, the grant
* is not created and `null` is returned.
*/
export const createGrant = async (
feature: GrantFeature,
absolutePath: string,
): Promise<FileGrant | null> => {
await _loadIntoCache();
if (!_cache) return null;
let canonical: string;
try {
canonical = fs.realpathSync.native(absolutePath);
} catch {
return null;
}
const grant: FileGrant = {
id: randomBytes(GRANT_ID_BYTES).toString('hex'),
feature,
root: canonical,
createdAt: Date.now(),
};
_cache.set(grant.id, grant);
await _persist();
return grant;
};
/**
* Look up a grant by id. Returns null when the id is unknown callers MUST
* treat null as a deny, never as "no constraint".
*/
export const getGrant = (id: string): FileGrant | null => {
if (!_cache) {
throw new Error('grant-store: revalidateGrants() must be called before getGrant()');
}
if (typeof id !== 'string' || id.length === 0) return null;
return _cache.get(id) ?? null;
};
/**
* Drop a grant by id. No-op if unknown.
*/
export const revokeGrant = async (id: string): Promise<void> => {
await _loadIntoCache();
if (!_cache) return;
if (!_cache.delete(id)) return;
await _persist();
};
/**
* Return all grants for a given feature. Used by callers that need to
* surface "current sync folder" in the UI without re-prompting the picker.
*/
export const listGrantsByFeature = (feature: GrantFeature): readonly FileGrant[] => {
if (!_cache) {
throw new Error(
'grant-store: revalidateGrants() must be called before listGrantsByFeature()',
);
}
return Array.from(_cache.values()).filter((g) => g.feature === feature);
};
/** Test-only: clear in-memory cache so a fresh `revalidateGrants()` re-reads disk. */
export const __resetGrantCacheForTests = (): void => {
_cache = null;
_loadOnce = null;
};