-
Notifications
You must be signed in to change notification settings - Fork 2
Compat Permissions
Cross-runtime permission checking with graceful degradation.
The Permissions module provides a unified interface for checking permissions across runtimes. It leverages Deno's permission system when available and gracefully assumes permissions are granted in Bun and Node.js.
| Feature | Bun | Deno | Node.js |
|---|---|---|---|
| Permission checks | ✅ (always granted) | ✅ (real checks) | ✅ (always granted) |
| Async checks | ✅ | ✅ | ✅ |
| Sync checks | ✅ | ✅ | ✅ |
| Fine-grained scopes | ➖ (N/A) | ✅ | ➖ (N/A) |
| Runtime-aware behavior | ✅ | ✅ | ✅ |
Deno:
deno add @tundralibs/compatBun:
bunx jsr add @tundralibs/compatNode.js:
npx jsr add @tundralibs/compat| Permission | Scope Property | Description | Deno Only |
|---|---|---|---|
env |
variable?: string |
Environment variable access | ✅ |
ffi |
path?: string | URL |
Foreign Function Interface | ✅ |
net |
host?: string |
Network access | ✅ |
read |
path?: string | URL |
File/directory read access | ✅ |
write |
path?: string | URL |
File/directory write access | ✅ |
run |
path?: string | URL |
Subprocess execution | ✅ |
sys |
- | System information access | ✅ |
import |
- | Dynamic import access | ✅ |
type PermissionObject<T extends PermissionName = PermissionName> =
& {
name: T;
}
& (T extends 'env' ? { variable?: string }
: T extends 'ffi' ? { path?: string | URL }
: T extends 'net' ? { host?: string }
: T extends 'read' | 'write' | 'run' ? { path?: string | URL }
: never);Gets the permission status for a given permission.
async function getPermissions(
options: PermissionObject,
): Promise<PermissionResponse>;
type PermissionResponse = 'GRANTED' | 'DENIED';Parameters:
-
options.name- The permission to check -
options.*- Permission-specific scope (path, host, variable, etc.)
Returns: Promise resolving to 'GRANTED' or 'DENIED'
Throws: TypeError if permission name is invalid
Runtime Behavior:
-
Deno: Queries
Deno.permissions.query() -
Bun/Node.js: Always returns
'GRANTED'
Example:
import { getPermissions } from '@tundralibs/compat/permissions';
const readStatus = await getPermissions({ name: 'read', path: './data' });
console.log(readStatus); // 'GRANTED' or 'DENIED'
const netStatus = await getPermissions({
name: 'net',
host: 'api.example.com',
});
const envStatus = await getPermissions({ name: 'env', variable: 'HOME' });Synchronous version of getPermissions().
function getPermissionsSync(
options: PermissionObject,
): PermissionResponse;Example:
import { getPermissionsSync } from '@tundralibs/compat/permissions';
const writeStatus = getPermissionsSync({ name: 'write', path: './logs' });
if (writeStatus === 'GRANTED') {
// Write to logs
}Checks if a permission is granted (convenience wrapper).
async function hasPermission(
options: PermissionObject,
): Promise<boolean>;Parameters:
-
options- Permission object
Returns: true if granted, false if denied
Example:
import { hasPermission } from '@tundralibs/compat/permissions';
if (await hasPermission({ name: 'net', host: 'github.com' })) {
// Make network request
await fetch('https://github.com/api');
}Synchronous version of hasPermission().
function hasPermissionSync(
options: PermissionObject,
): boolean;Example:
import { hasPermissionSync } from '@tundralibs/compat/permissions';
function readConfig() {
if (!hasPermissionSync({ name: 'read', path: './config.json' })) {
throw new Error('No read permission for config');
}
// Read config file
}import {
hasPermissionSync,
PermissionObject,
} from '@tundralibs/compat/permissions';
function ensurePermissions(permissions: PermissionObject[]): void {
for (const perm of permissions) {
if (!hasPermissionSync(perm)) {
const desc = JSON.stringify(perm);
throw new Error(`Missing permission: ${desc}`);
}
}
}
// Usage
ensurePermissions([
{ name: 'read', path: './data' },
{ name: 'write', path: './output' },
{ name: 'net', host: 'api.example.com' },
]);import { hasPermission } from '@tundralibs/compat/permissions';
import { getEnv } from '@tundralibs/compat/runtime';
import { readTextFile } from '@tundralibs/compat/file';
async function loadConfiguration() {
// Try environment variables first
if (await hasPermission({ name: 'env' })) {
const apiKey = getEnv()['API_KEY'];
if (apiKey) return { apiKey };
}
// Fall back to file
if (await hasPermission({ name: 'read', path: './secrets.json' })) {
const file = await readTextFile('./secrets.json');
return JSON.parse(file);
}
throw new Error('No configuration source available');
}
hasPermission({ name: 'env' })(and every other permission check) is alwaystrueon Bun/Node — there is no permission system to deny it. That makes aDeno.env.get(...)/Deno.readTextFile(...)call guarded only byhasPermissiondoubly wrong on those runtimes: the guard never blocks the call, and the call itself throwsReferenceError: Deno is not definedthere. Use the cross-runtime helpers (getEnv()from@tundralibs/compat/runtime,readTextFile()from@tundralibs/compat/file) for the actual read, and reserve the permission check for Deno-specific pre-flight gating.
import { isDeno } from '@tundralibs/compat/runtime';
import { hasPermissionSync } from '@tundralibs/compat/permissions';
import { pathExistsSync } from '@tundralibs/compat/file';
function canAccessPath(path: string): boolean {
// On Deno, ask the permission system directly — a 'GRANTED' read
// permission doesn't guarantee the path exists, but a denial means
// there's no point trying.
if (isDeno) {
return hasPermissionSync({ name: 'read', path });
}
// Bun/Node have no permission system to query — `pathExistsSync` is
// the closest cross-runtime proxy for "can I get at this path".
return pathExistsSync(path);
}The naive version of this example calls
Deno.statSync()directly in the non-Deno branch — that reads fine and even type-checks underdeno check(Deno's own ambient globals are in scope), butDenodoes not exist on Bun or Node and throwsReferenceError: Deno is not definedat runtime exactly on the branch meant to run there. Type-checking is not the same as running the code on the runtime it targets — use the compat helper (pathExistsSyncfrom@tundralibs/compat/file) instead of a runtime-specific global whenever the code must run everywhere.
import { hasPermission } from '@tundralibs/compat/permissions';
import { isDeno } from '@tundralibs/compat/runtime';
declare function createConnection(host: string): Promise<unknown>;
async function connectToDatabase(host: string) {
if (isDeno) {
// In Deno, verify network permission
if (!await hasPermission({ name: 'net', host })) {
console.warn(`No network permission for ${host}`);
return null;
}
}
// Proceed with connection
return await createConnection(host);
}import { describe, it } from '@tundralibs/compat/test';
import { hasPermissionSync } from '@tundralibs/compat/permissions';
// Assertions come from your preferred library (e.g. `@std/assert` on Deno).
declare function assert(expr: unknown, msg?: string): asserts expr;
declare function assertThrows(fn: () => unknown): void;
describe('File operations', () => {
it({
name: 'should read file',
// Only run in Deno with read permission
deno: hasPermissionSync({ name: 'read', path: './test-data' }),
bun: false,
node: false,
fn() {
const content = Deno.readTextFileSync('./test-data/sample.txt');
assert(content.length > 0);
},
});
it({
name: 'should handle no permission',
// Skip if we already have permission (testing the denial case)
ignore: hasPermissionSync({ name: 'write', path: '/root' }),
bun: false,
node: false,
fn() {
assertThrows(() => {
Deno.writeTextFileSync('/root/file.txt', 'data');
});
},
});
});import { hasPermission } from '@tundralibs/compat/permissions';
interface Features {
networking: boolean;
fileSystem: boolean;
environment: boolean;
subprocess: boolean;
}
async function detectFeatures(): Promise<Features> {
return {
networking: await hasPermission({ name: 'net' }),
fileSystem: await hasPermission({ name: 'read' }),
environment: await hasPermission({ name: 'env' }),
subprocess: await hasPermission({ name: 'run' }),
};
}
// Usage
const features = await detectFeatures();
if (features.networking) {
// Enable network-dependent features
}In Deno, permission checks use the actual permission system:
# Grant specific permissions
deno run --allow-read=./data --allow-write=./output script.ts
# Check permissions at runtime
deno run --prompt script.ts # Prompt when permission neededResult:
- Returns actual permission state
- Respects
--allow-*flags - Can be scoped to specific paths/hosts
In Bun and Node.js, permissions are always granted:
# No permission flags needed
bun run script.ts
node script.tsResult:
- Always returns
'GRANTED' - No runtime permission system
- Relies on OS-level permissions
- Check before using - Always check permissions before sensitive operations in Deno
- Handle gracefully - Provide fallbacks when permissions are denied
- Be specific - Use scoped permissions (path, host) when possible
- Don't over-check - Cache permission status if checking repeatedly
- Test both cases - Test with and without permissions granted
Example:
import { hasPermission } from '@tundralibs/compat/permissions';
declare const defaultConfig: Record<string, unknown>;
async function loadConfig() {
// ✅ Good - Specific scope
const canRead = await hasPermission({
name: 'read',
path: './config.json',
});
// ✅ Good - Graceful fallback
if (!canRead) {
console.warn('Using default config');
return defaultConfig;
}
// ❌ Bad - Too broad (checking all read permission)
const canReadAnything = await hasPermission({ name: 'read' });
// ❌ Bad - No fallback
if (!canReadAnything) {
throw new Error('Need read permission!'); // Unhelpful
}
}import {
getPermissions,
PermissionObject,
} from '@tundralibs/compat/permissions';
try {
// Invalid permission name
await getPermissions({ name: 'invalid' as any });
} catch (error) {
if (error instanceof TypeError) {
console.error('Invalid permission name');
}
}