Two more module renames flipping the direction of the previous pass: - arts/permissions, libs/permissions, svrs/permissions, libs/svrs/permissions.ts → arts/perm, libs/perm, svrs/perm, libs/svrs/perm.ts. Alias: $permissions → $perm. Constants: PERMISSION_* → PERM_*. Wire: 'permissions::*' → 'perm::*'. Module value: 'perm'. Class names: Permission*Error → Perm*Error. Helper functions: permissionDecisionKey → permDecisionKey (and similar). - arts/formats → arts/format (with the four sub-modules currency, numbers, units, dates carried along). Alias: $formats → $format. Constants: FORMATS_* → FORMAT_*. Wire: 'formats::*' → 'format::*'. Class names: Formats*Error → Format*Error. Both follow the auth/http/dom precedent: short word as the canonical name. The earlier full-word forms (permissions, formats) created asymmetric prefixes (PERMISSION_* singular, PERMISSIONS_REFRESH plural) that were already showing as drift in this commit's call sites. Plus a fix to sium error structure that was carried over from the previous audit round but never fully consolidated: - arts/sium/errors.ts now owns the full error infra: SIUM_ERR seed, all SIUM_ERR_* codes, SIUM_ERROR_MESSAGES catalog, error classes (SiumValidationError, SiumAsyncSchemaError, SiumDiscriminatedUnionError), guards and the SIUM_ERROR_MESSAGES type. The legacy SIUM_ERRORS string catalog stays for the few non-thrown sites until those are migrated. - arts/sium/consts.ts no longer carries error codes — only module identifier and diagnostic events. - arts/sium/core/types.ts no longer carries error classes — only schema types. - All call sites in arts/sium/core/* and arts/sium/types/* now import the error classes from `../errors` instead of `../core/types` / `../consts`. This is the canonical pattern documented in conventions.md rule 6: all of a module's error infrastructure lives in a single errors.ts file; consts.ts is for module configuration that has nothing to do with errors. Sium is now compliant; the rest of the modules will follow in subsequent commits. All 1334 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>master
parent
4db6bd2b3b
commit
7f2577c8da
@ -0,0 +1,3 @@
|
||||
export const FORMAT_MODULE = 'format';
|
||||
export const FORMAT_DEFAULT_LOCALE = 'en-US';
|
||||
export const FORMAT_AUTO_VALUE = 'auto';
|
||||
@ -1,10 +1,10 @@
|
||||
export const FORMATS_CURR_MODULE = 'formats.curr';
|
||||
export const FORMAT_CURR_MODULE = 'format.curr';
|
||||
|
||||
export const CURRENCY_DIAGNOSTIC_EVENTS = {
|
||||
RATES_PROVIDER_MISSING: 'formats.curr.rates_provider_missing',
|
||||
RATE_NOT_AVAILABLE: 'formats.curr.rate_not_available',
|
||||
INVALID_RATE: 'formats.curr.invalid_rate',
|
||||
RATE_FETCH_FAILED: 'formats.curr.rate_fetch_failed'
|
||||
RATES_PROVIDER_MISSING: 'format.curr.rates_provider_missing',
|
||||
RATE_NOT_AVAILABLE: 'format.curr.rate_not_available',
|
||||
INVALID_RATE: 'format.curr.invalid_rate',
|
||||
RATE_FETCH_FAILED: 'format.curr.rate_fetch_failed'
|
||||
} as const;
|
||||
|
||||
export const AUTO_CURRENCY = 'auto';
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Logger } from '$libs/logger';
|
||||
import type { AUTO_CURRENCY } from './consts';
|
||||
import type { FormatsLocaleInput, FormatsLocaleSource } from '../types';
|
||||
import type { FormatLocaleInput, FormatLocaleSource } from '../types';
|
||||
import type { EngineNumbers, NumbersCurrencyFormatOptions } from '../numbers';
|
||||
|
||||
export type CurrencyCode = string;
|
||||
export type CurrencyMode = typeof AUTO_CURRENCY | CurrencyCode;
|
||||
export type CurrencyLocaleInput = FormatsLocaleInput;
|
||||
export type CurrencyLocaleSource = FormatsLocaleSource;
|
||||
export type CurrencyLocaleInput = FormatLocaleInput;
|
||||
export type CurrencyLocaleSource = FormatLocaleSource;
|
||||
|
||||
export interface CurrencyFormatOptions extends NumbersCurrencyFormatOptions {}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const FORMAT_DATES_MODULE = 'format.dates';
|
||||
@ -1,4 +1,4 @@
|
||||
export { FORMATS_DATES_MODULE } from './consts';
|
||||
export { FORMAT_DATES_MODULE } from './consts';
|
||||
export { createActiveDates } from './active-dates.svelte';
|
||||
export { createEngineDates } from './engine-dates';
|
||||
export type {
|
||||
@ -0,0 +1,5 @@
|
||||
import { FORMAT_MODULE } from './consts.ts';
|
||||
|
||||
export const FORMAT_ERROR_MESSAGES = {
|
||||
INVALID_LOCALE: (locale: string): string => `[${FORMAT_MODULE}] Invalid locale: "${locale}".`
|
||||
} as const;
|
||||
@ -0,0 +1 @@
|
||||
export const FORMAT_NUMS_MODULE = 'format.nums';
|
||||
@ -1,4 +1,4 @@
|
||||
export { FORMATS_NUMS_MODULE } from './consts';
|
||||
export { FORMAT_NUMS_MODULE } from './consts';
|
||||
export { createActiveNumbers } from './active-numbers.svelte';
|
||||
export { createEngineNumbers } from './engine-numbers';
|
||||
export type {
|
||||
@ -1,20 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FORMATS_DEFAULT_LOCALE } from '../consts';
|
||||
import { createFormatsLocaleState } from '../locale-state';
|
||||
import { FORMAT_DEFAULT_LOCALE } from '../consts';
|
||||
import { createFormatLocaleState } from '../locale-state';
|
||||
|
||||
describe('createFormatsLocaleState()', () => {
|
||||
describe('createFormatLocaleState()', () => {
|
||||
it('normalizes locale strings and falls back to the default locale', () => {
|
||||
const state = createFormatsLocaleState('es_ES');
|
||||
const state = createFormatLocaleState('es_ES');
|
||||
|
||||
expect(state.getLocale()).toBe('es-ES');
|
||||
|
||||
state.setLocale('');
|
||||
expect(state.getLocale()).toBe(FORMATS_DEFAULT_LOCALE);
|
||||
expect(state.getLocale()).toBe(FORMAT_DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
it('tracks locale functions until a manual locale is set', () => {
|
||||
let locale = 'en_GB';
|
||||
const state = createFormatsLocaleState(() => locale);
|
||||
const state = createFormatLocaleState(() => locale);
|
||||
|
||||
expect(state.getLocale()).toBe('en-GB');
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type { LocaleSource } from '$locale';
|
||||
|
||||
export type FormatsLocaleInput = string | (() => string);
|
||||
export type FormatLocaleInput = string | (() => string);
|
||||
|
||||
/**
|
||||
* Reactive locale provider consumed by `ActiveFormats`. Re-exported as a
|
||||
* Reactive locale provider consumed by `ActiveFormat`. Re-exported as a
|
||||
* shared alias of `LocaleSource` so consumers can keep importing the local
|
||||
* name without breakage; new code should prefer `LocaleSource` directly.
|
||||
*/
|
||||
export type FormatsLocaleSource = LocaleSource;
|
||||
export type FormatLocaleSource = LocaleSource;
|
||||
|
||||
export interface FormatsDisposable {
|
||||
export interface FormatDisposable {
|
||||
dispose: () => void;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export const FORMAT_UNTS_MODULE = 'format.unts';
|
||||
|
||||
export const AUTO_UNIT_SYSTEM = 'auto';
|
||||
@ -1,4 +1,4 @@
|
||||
export { FORMATS_UNTS_MODULE, AUTO_UNIT_SYSTEM } from './consts';
|
||||
export { FORMAT_UNTS_MODULE, AUTO_UNIT_SYSTEM } from './consts';
|
||||
export { createActiveUnits } from './active-units.svelte';
|
||||
export { createEngineUnits } from './engine-units';
|
||||
export { convert } from './conversions';
|
||||
@ -1,3 +0,0 @@
|
||||
export const FORMATS_MODULE = 'formats';
|
||||
export const FORMATS_DEFAULT_LOCALE = 'en-US';
|
||||
export const FORMATS_AUTO_VALUE = 'auto';
|
||||
@ -1 +0,0 @@
|
||||
export const FORMATS_DATES_MODULE = 'formats.dates';
|
||||
@ -1,5 +0,0 @@
|
||||
import { FORMATS_MODULE } from './consts.ts';
|
||||
|
||||
export const FORMATS_ERROR_MESSAGES = {
|
||||
INVALID_LOCALE: (locale: string): string => `[${FORMATS_MODULE}] Invalid locale: "${locale}".`
|
||||
} as const;
|
||||
@ -1 +0,0 @@
|
||||
export const FORMATS_NUMS_MODULE = 'formats.nums';
|
||||
@ -1,3 +0,0 @@
|
||||
export const FORMATS_UNTS_MODULE = 'formats.unts';
|
||||
|
||||
export const AUTO_UNIT_SYSTEM = 'auto';
|
||||
@ -0,0 +1,92 @@
|
||||
import {
|
||||
PERM_EFFECT_ALLOW,
|
||||
PERM_EFFECT_INDETERMINATE
|
||||
} from '$libs/perm';
|
||||
import {
|
||||
PERM_CLIENT_DEFAULT_CACHE_TTL_MS,
|
||||
PERM_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS,
|
||||
PERM_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS
|
||||
} from './consts.ts';
|
||||
import type { PermDecision } from '$libs/perm';
|
||||
import type { PermClientClock, PermClientOptions } from './types.ts';
|
||||
|
||||
interface CacheEntry {
|
||||
readonly decision: PermDecision;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface PermClientCacheRuntime {
|
||||
read(key: string): PermDecision | undefined;
|
||||
writeDecision(key: string, decision: PermDecision): boolean;
|
||||
writeFailure(key: string, decision: PermDecision): void;
|
||||
hydrate(decisions: Record<string, PermDecision>): void;
|
||||
clear(): void;
|
||||
deleteByPrefix(prefix: string): void;
|
||||
}
|
||||
|
||||
export function createPermClientCache(
|
||||
options: PermClientOptions,
|
||||
clock: PermClientClock
|
||||
): PermClientCacheRuntime {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? PERM_CLIENT_DEFAULT_CACHE_TTL_MS;
|
||||
const nonAllowCacheTtlMs =
|
||||
options.nonAllowCacheTtlMs ?? PERM_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS;
|
||||
const remoteFailureBackoffMs =
|
||||
options.remoteFailureBackoffMs ?? PERM_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const failures = new Map<string, CacheEntry>();
|
||||
|
||||
function now(): number {
|
||||
return clock.now();
|
||||
}
|
||||
|
||||
function resolveDecisionTtl(decision: PermDecision): number {
|
||||
if (decision.effect === PERM_EFFECT_ALLOW) return decision.ttl ?? cacheTtlMs;
|
||||
if (decision.effect === PERM_EFFECT_INDETERMINATE) return 0;
|
||||
return Math.min(cacheTtlMs, nonAllowCacheTtlMs);
|
||||
}
|
||||
|
||||
function readEntry(entries: Map<string, CacheEntry>, key: string): PermDecision | undefined {
|
||||
const entry = entries.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
if (entry.expiresAt > now()) return entry.decision;
|
||||
entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function read(key: string): PermDecision | undefined {
|
||||
return readEntry(cache, key) ?? readEntry(failures, key);
|
||||
}
|
||||
|
||||
function writeDecision(key: string, decision: PermDecision): boolean {
|
||||
failures.delete(key);
|
||||
const ttl = resolveDecisionTtl(decision);
|
||||
if (ttl <= 0) return false;
|
||||
cache.set(key, { decision, expiresAt: now() + ttl });
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeFailure(key: string, decision: PermDecision): void {
|
||||
if (remoteFailureBackoffMs <= 0) return;
|
||||
failures.set(key, { decision, expiresAt: now() + remoteFailureBackoffMs });
|
||||
}
|
||||
|
||||
function hydrate(decisions: Record<string, PermDecision>): void {
|
||||
clear();
|
||||
for (const [key, decision] of Object.entries(decisions)) {
|
||||
writeDecision(key, decision);
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
cache.clear();
|
||||
failures.clear();
|
||||
}
|
||||
|
||||
function deleteByPrefix(prefix: string): void {
|
||||
for (const key of [...cache.keys()]) if (key.startsWith(prefix)) cache.delete(key);
|
||||
for (const key of [...failures.keys()]) if (key.startsWith(prefix)) failures.delete(key);
|
||||
}
|
||||
|
||||
return { read, writeDecision, writeFailure, hydrate, clear, deleteByPrefix };
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import {
|
||||
PERM_CLIENT_KEY_SEPARATOR,
|
||||
PERM_CLIENT_SCOPE_PREFIX
|
||||
} from './consts.ts';
|
||||
import { permDecisionKey, stablePermStringify } from '$libs/svrs/perm';
|
||||
import type {
|
||||
PermClientCheckInput,
|
||||
PermClientOptions,
|
||||
PermSnapshot
|
||||
} from './types.ts';
|
||||
|
||||
export interface PermClientKeyRuntime {
|
||||
remoteDecisionKey(input: PermClientCheckInput): string;
|
||||
resolveScopeKey(): string | undefined;
|
||||
decisionKeyForScope(input: PermClientCheckInput, scope: string | undefined): string;
|
||||
decisionKey(input: PermClientCheckInput): string;
|
||||
scopedKeyPrefix(scope: string): string;
|
||||
}
|
||||
|
||||
export function createPermClientKeyRuntime(
|
||||
options: PermClientOptions,
|
||||
readSnapshot: () => PermSnapshot
|
||||
): PermClientKeyRuntime {
|
||||
function remoteDecisionKey(input: PermClientCheckInput): string {
|
||||
return permDecisionKey(input);
|
||||
}
|
||||
|
||||
function resolveScopeKey(): string | undefined {
|
||||
const configured =
|
||||
typeof options.scopeKey === 'function' ? options.scopeKey() : options.scopeKey;
|
||||
if (configured !== undefined && configured.length > 0) return configured;
|
||||
const actor = readSnapshot().actor;
|
||||
if (actor === undefined) return undefined;
|
||||
return stablePermStringify(actor);
|
||||
}
|
||||
|
||||
function decisionKeyForScope(
|
||||
input: PermClientCheckInput,
|
||||
scope: string | undefined
|
||||
): string {
|
||||
const base = remoteDecisionKey(input);
|
||||
if (scope === undefined) return base;
|
||||
return [PERM_CLIENT_SCOPE_PREFIX, stablePermStringify(scope), base].join(
|
||||
PERM_CLIENT_KEY_SEPARATOR
|
||||
);
|
||||
}
|
||||
|
||||
function decisionKey(input: PermClientCheckInput): string {
|
||||
return decisionKeyForScope(input, resolveScopeKey());
|
||||
}
|
||||
|
||||
function scopedKeyPrefix(scope: string): string {
|
||||
return [PERM_CLIENT_SCOPE_PREFIX, stablePermStringify(scope), ''].join(
|
||||
PERM_CLIENT_KEY_SEPARATOR
|
||||
);
|
||||
}
|
||||
|
||||
return { remoteDecisionKey, resolveScopeKey, decisionKeyForScope, decisionKey, scopedKeyPrefix };
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import {
|
||||
PERM_DECISION_CODE_SNAPSHOT_DENIED,
|
||||
PERM_EFFECT_ALLOW,
|
||||
PERM_EFFECT_DENY
|
||||
} from '$libs/perm';
|
||||
import { PERM_SNAPSHOT_GLOBAL_POLICY } from './consts.ts';
|
||||
import type { PermClientKeyRuntime } from './client-keys.ts';
|
||||
import type { PermDecision } from '$libs/perm';
|
||||
import type { PermClientCheckInput, PermSnapshot } from './types.ts';
|
||||
|
||||
export function isPermSnapshotValid(snapshot: PermSnapshot, now: number): boolean {
|
||||
return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now;
|
||||
}
|
||||
|
||||
export function readPermSnapshotDecision(
|
||||
input: PermClientCheckInput,
|
||||
snapshot: PermSnapshot,
|
||||
now: number,
|
||||
keys: PermClientKeyRuntime
|
||||
): PermDecision | undefined {
|
||||
if (!isPermSnapshotValid(snapshot, now)) return undefined;
|
||||
const key = keys.decisionKey(input);
|
||||
const direct = snapshot.decisions?.[key];
|
||||
if (direct) return direct;
|
||||
const remote = snapshot.decisions?.[keys.remoteDecisionKey(input)];
|
||||
if (remote) return remote;
|
||||
const global = snapshot.global?.[input.action];
|
||||
if (typeof global === 'boolean') {
|
||||
return global
|
||||
? { effect: PERM_EFFECT_ALLOW, policy: PERM_SNAPSHOT_GLOBAL_POLICY }
|
||||
: {
|
||||
effect: PERM_EFFECT_DENY,
|
||||
code: PERM_DECISION_CODE_SNAPSHOT_DENIED,
|
||||
reason: PERM_SNAPSHOT_GLOBAL_POLICY
|
||||
};
|
||||
}
|
||||
return global;
|
||||
}
|
||||
@ -0,0 +1,292 @@
|
||||
import {
|
||||
PERM_EFFECT_ALLOW,
|
||||
PERM_EFFECT_INDETERMINATE,
|
||||
PERM_FALLBACK_DENY
|
||||
} from '$libs/perm';
|
||||
import {
|
||||
PERM_CLIENT_DIAGNOSTIC_EVENTS,
|
||||
PERM_CLIENT_PATH_BATCH,
|
||||
PERM_CLIENT_PATH_CHECK,
|
||||
PERM_CLIENT_PATH_EXPLAIN,
|
||||
PERM_CLIENT_PATH_WHAT,
|
||||
PERM_DECISION_REASON_REMOTE_BATCH_FAILED,
|
||||
PERM_DECISION_REASON_REMOTE_CHECK_FAILED,
|
||||
PERM_METHOD_BATCH,
|
||||
PERM_METHOD_CAN,
|
||||
PERM_METHOD_CHECK,
|
||||
PERM_METHOD_DECISION_KEY,
|
||||
PERM_METHOD_EXPLAIN,
|
||||
PERM_METHOD_HYDRATE,
|
||||
PERM_METHOD_INVALIDATE,
|
||||
PERM_METHOD_SUBSCRIBE,
|
||||
PERM_METHOD_WHAT,
|
||||
PERM_REQUEST_FIELD_ACTION,
|
||||
PERM_REQUEST_FIELD_CHECKS,
|
||||
PERM_REQUEST_FIELD_CONTEXT,
|
||||
PERM_REQUEST_FIELD_RESOURCE,
|
||||
PERM_RESPONSE_FIELD_ACTIONS,
|
||||
PERM_RESPONSE_FIELD_DECISIONS
|
||||
} from './consts.ts';
|
||||
import { createPermClientCache } from './client-cache.ts';
|
||||
import { postPermJson } from './client-http.ts';
|
||||
import { createPermClientKeyRuntime } from './client-keys.ts';
|
||||
import { readPermSnapshotDecision } from './client-snapshot.ts';
|
||||
import {
|
||||
createPermClientDiagnostics,
|
||||
emitPermClientDiagnostic
|
||||
} from './diagnostics.ts';
|
||||
import { PermDisposedError } from './errors.ts';
|
||||
import { disposedPermsMessage } from './helpers.ts';
|
||||
import type {
|
||||
PermClient,
|
||||
PermClientBatchInput,
|
||||
PermClientCheckInput,
|
||||
PermClientOptions,
|
||||
PermSnapshot
|
||||
} from './types.ts';
|
||||
import type { ExplainResult, PermDecision } from '$libs/perm';
|
||||
|
||||
export function createPermClient(options: PermClientOptions): PermClient {
|
||||
const clock = options.clock ?? systemClock;
|
||||
const diagnostics = createPermClientDiagnostics(options.logger);
|
||||
const cache = createPermClientCache(options, clock);
|
||||
const pending = new Map<string, Promise<PermDecision>>();
|
||||
const listeners = new Set<(snapshot: PermSnapshot) => void>();
|
||||
let currentSnapshot: PermSnapshot = options.initialSnapshot ?? { decisions: {} };
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
const keys = createPermClientKeyRuntime(options, () => currentSnapshot);
|
||||
|
||||
function now(): number {
|
||||
return clock.now();
|
||||
}
|
||||
|
||||
function ensureLive(method: string): void {
|
||||
if (disposed) throw new PermDisposedError(disposedPermsMessage(method));
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener(currentSnapshot);
|
||||
}
|
||||
|
||||
function readSnapshotDecision(input: PermClientCheckInput): PermDecision | undefined {
|
||||
return readPermSnapshotDecision(input, currentSnapshot, now(), keys);
|
||||
}
|
||||
|
||||
function setCached(
|
||||
key: string,
|
||||
decision: PermDecision,
|
||||
requestGeneration = generation
|
||||
): void {
|
||||
if (requestGeneration !== generation) return;
|
||||
if (!cache.writeDecision(key, decision)) return;
|
||||
currentSnapshot = {
|
||||
...currentSnapshot,
|
||||
decisions: {
|
||||
...(currentSnapshot.decisions ?? {}),
|
||||
[key]: decision
|
||||
}
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
function fallbackDecision(reason: string, error: unknown): PermDecision {
|
||||
return {
|
||||
effect: PERM_EFFECT_INDETERMINATE,
|
||||
reason,
|
||||
fallback: PERM_FALLBACK_DENY,
|
||||
errors: [error]
|
||||
};
|
||||
}
|
||||
|
||||
async function check(input: PermClientCheckInput): Promise<PermDecision> {
|
||||
ensureLive(PERM_METHOD_CHECK);
|
||||
const key = keys.decisionKey(input);
|
||||
const requestGeneration = generation;
|
||||
const cached = cache.read(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const snapshotDecision = readSnapshotDecision(input);
|
||||
if (snapshotDecision) {
|
||||
cache.writeDecision(key, snapshotDecision);
|
||||
return snapshotDecision;
|
||||
}
|
||||
|
||||
const inFlight = pending.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const request = postPermJson<PermDecision>(options, PERM_CLIENT_PATH_CHECK, {
|
||||
[PERM_REQUEST_FIELD_ACTION]: input.action,
|
||||
[PERM_REQUEST_FIELD_RESOURCE]: input.resource,
|
||||
[PERM_REQUEST_FIELD_CONTEXT]: input.context
|
||||
})
|
||||
.then((decision) => {
|
||||
setCached(key, decision, requestGeneration);
|
||||
return decision;
|
||||
})
|
||||
.catch((error) => {
|
||||
options.onError?.(error);
|
||||
emitPermClientDiagnostic(
|
||||
diagnostics,
|
||||
PERM_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_CHECK_FAILED,
|
||||
{ error, input }
|
||||
);
|
||||
const fallback = fallbackDecision(PERM_DECISION_REASON_REMOTE_CHECK_FAILED, error);
|
||||
if (requestGeneration === generation) cache.writeFailure(key, fallback);
|
||||
return fallback;
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(key);
|
||||
});
|
||||
pending.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function batch(
|
||||
input: PermClientBatchInput
|
||||
): Promise<Record<string, PermDecision>> {
|
||||
ensureLive(PERM_METHOD_BATCH);
|
||||
const requestGeneration = generation;
|
||||
const checks = input.checks.map((item) => ({
|
||||
remoteKey: keys.remoteDecisionKey(item),
|
||||
localKey: keys.decisionKey(item)
|
||||
}));
|
||||
try {
|
||||
const result = await postPermJson<{ decisions: Record<string, PermDecision> }>(
|
||||
options,
|
||||
PERM_CLIENT_PATH_BATCH,
|
||||
{ [PERM_REQUEST_FIELD_CHECKS]: input.checks }
|
||||
);
|
||||
const decisions: Record<string, PermDecision> = {};
|
||||
for (const { remoteKey, localKey } of checks) {
|
||||
const decision = result[PERM_RESPONSE_FIELD_DECISIONS][remoteKey];
|
||||
if (decision) {
|
||||
setCached(localKey, decision, requestGeneration);
|
||||
decisions[localKey] = decision;
|
||||
}
|
||||
}
|
||||
return decisions;
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
emitPermClientDiagnostic(
|
||||
diagnostics,
|
||||
PERM_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_BATCH_FAILED,
|
||||
{ error, input }
|
||||
);
|
||||
const decisions: Record<string, PermDecision> = {};
|
||||
for (const { localKey } of checks) {
|
||||
const fallback = fallbackDecision(PERM_DECISION_REASON_REMOTE_BATCH_FAILED, error);
|
||||
decisions[localKey] = fallback;
|
||||
if (requestGeneration === generation) cache.writeFailure(localKey, fallback);
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
}
|
||||
|
||||
async function what(input: {
|
||||
readonly resource?: PermClientCheckInput['resource'];
|
||||
readonly actions?: readonly string[];
|
||||
readonly context?: PermClientCheckInput['context'];
|
||||
}): Promise<Record<string, PermDecision>> {
|
||||
ensureLive(PERM_METHOD_WHAT);
|
||||
const scope = keys.resolveScopeKey();
|
||||
const requestGeneration = generation;
|
||||
try {
|
||||
const result = await postPermJson<{ actions: Record<string, PermDecision> }>(
|
||||
options,
|
||||
PERM_CLIENT_PATH_WHAT,
|
||||
input
|
||||
);
|
||||
for (const [action, decision] of Object.entries(result[PERM_RESPONSE_FIELD_ACTIONS])) {
|
||||
setCached(
|
||||
keys.decisionKeyForScope({ action, resource: input.resource, context: input.context }, scope),
|
||||
decision,
|
||||
requestGeneration
|
||||
);
|
||||
}
|
||||
return result[PERM_RESPONSE_FIELD_ACTIONS];
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
emitPermClientDiagnostic(
|
||||
diagnostics,
|
||||
PERM_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_WHAT_FAILED,
|
||||
{ error, input }
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function explain(input: PermClientCheckInput): Promise<ExplainResult | null> {
|
||||
ensureLive(PERM_METHOD_EXPLAIN);
|
||||
try {
|
||||
return await postPermJson<ExplainResult>(options, PERM_CLIENT_PATH_EXPLAIN, input);
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(snapshot: PermSnapshot): void {
|
||||
ensureLive(PERM_METHOD_HYDRATE);
|
||||
generation += 1;
|
||||
pending.clear();
|
||||
currentSnapshot = snapshot;
|
||||
cache.hydrate(snapshot.decisions ?? {});
|
||||
emit();
|
||||
}
|
||||
|
||||
function invalidate(scope?: string): void {
|
||||
ensureLive(PERM_METHOD_INVALIDATE);
|
||||
generation += 1;
|
||||
if (!scope) {
|
||||
cache.clear();
|
||||
pending.clear();
|
||||
currentSnapshot = { ...currentSnapshot, decisions: {} };
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
const prefix = keys.scopedKeyPrefix(scope);
|
||||
cache.deleteByPrefix(prefix);
|
||||
for (const key of [...pending.keys()]) if (key.startsWith(prefix)) pending.delete(key);
|
||||
const decisions = { ...(currentSnapshot.decisions ?? {}) };
|
||||
for (const key of Object.keys(decisions)) if (key.startsWith(prefix)) delete decisions[key];
|
||||
currentSnapshot = { ...currentSnapshot, decisions };
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
check,
|
||||
async can(input) {
|
||||
ensureLive(PERM_METHOD_CAN);
|
||||
return (await check(input)).effect === PERM_EFFECT_ALLOW;
|
||||
},
|
||||
batch,
|
||||
what,
|
||||
explain,
|
||||
hydrate,
|
||||
snapshot: () => currentSnapshot,
|
||||
invalidate,
|
||||
subscribe(listener) {
|
||||
ensureLive(PERM_METHOD_SUBSCRIBE);
|
||||
listeners.add(listener);
|
||||
listener(currentSnapshot);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
decisionKey(input) {
|
||||
ensureLive(PERM_METHOD_DECISION_KEY);
|
||||
return keys.decisionKey(input);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
pending.clear();
|
||||
listeners.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const systemClock = {
|
||||
now: () => Date.now()
|
||||
};
|
||||
@ -0,0 +1,95 @@
|
||||
import { errCode, type ErrCode } from '$libs/errs';
|
||||
import { PERM_ERR } from '$libs/perm';
|
||||
|
||||
export {
|
||||
PERM_CLIENT_PATH_BATCH,
|
||||
PERM_CLIENT_PATH_CHECK,
|
||||
PERM_CLIENT_PATH_EXPLAIN,
|
||||
PERM_CLIENT_PATH_WHAT,
|
||||
PERM_CLIENT_CONTEXT_EMPTY,
|
||||
PERM_CLIENT_KEY_GLOBAL,
|
||||
PERM_CLIENT_KEY_NONE,
|
||||
PERM_CLIENT_KEY_SEPARATOR,
|
||||
PERM_MODULE,
|
||||
PERM_HTTP_CREDENTIALS_INCLUDE,
|
||||
PERM_HTTP_STATUS_BAD_REQUEST,
|
||||
PERM_HTTP_STATUS_FORBIDDEN,
|
||||
PERM_HTTP_STATUS_METHOD_NOT_ALLOWED,
|
||||
PERM_HTTP_STATUS_NOT_FOUND,
|
||||
PERM_HTTP_STATUS_OK,
|
||||
PERM_HTTP_ERROR_METHOD_NOT_ALLOWED,
|
||||
PERM_HTTP_ERROR_METHOD_NOT_ALLOWED_MESSAGE,
|
||||
PERM_HTTP_ERROR_ROUTE_NOT_FOUND,
|
||||
PERM_HTTP_ERROR_ROUTE_NOT_FOUND_MESSAGE,
|
||||
PERM_REQUEST_FIELD_ACTION,
|
||||
PERM_REQUEST_FIELD_ACTIONS,
|
||||
PERM_REQUEST_FIELD_CHECKS,
|
||||
PERM_REQUEST_FIELD_CONTEXT,
|
||||
PERM_REQUEST_FIELD_RESOURCE,
|
||||
PERM_RESPONSE_FIELD_ACTIONS,
|
||||
PERM_RESPONSE_FIELD_DECISIONS,
|
||||
PERM_RESPONSE_FIELD_ERROR
|
||||
} from '$libs/svrs/perm';
|
||||
|
||||
export const PERM_CONTEXT_KEY = 'active.permissions';
|
||||
|
||||
export const PERM_CLIENT_DEFAULT_CACHE_TTL_MS = 30_000;
|
||||
export const PERM_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS = 5_000;
|
||||
export const PERM_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS = 1_000;
|
||||
export const PERM_SNAPSHOT_DECISIONS_KEY = 'decisions';
|
||||
export const PERM_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global';
|
||||
|
||||
export const PERM_CLIENT_SCOPE_PREFIX = 'scope';
|
||||
|
||||
export const PERM_CLIENT_DIAGNOSTIC_EVENTS = {
|
||||
REMOTE_BATCH_FAILED: 'perm.client.remote_batch_failed',
|
||||
REMOTE_CHECK_FAILED: 'perm.client.remote_check_failed',
|
||||
REMOTE_WHAT_FAILED: 'perm.client.remote_what_failed'
|
||||
} as const;
|
||||
|
||||
// Method labels used by `ensureLive(method)` for error messages. They are
|
||||
// scoped with the artifact prefix `perm.` so that aggregated diagnostic
|
||||
// streams do not collide with identically-named methods from other modules
|
||||
// (`cache.check`, `sess.refresh`, etc.).
|
||||
export const PERM_METHOD_CHECK = 'perm.check';
|
||||
export const PERM_METHOD_CAN = 'perm.can';
|
||||
export const PERM_METHOD_BATCH = 'perm.batch';
|
||||
export const PERM_METHOD_EXPLAIN = 'perm.explain';
|
||||
export const PERM_METHOD_WHAT = 'perm.what';
|
||||
export const PERM_METHOD_SUBSCRIBE = 'perm.subscribe';
|
||||
export const PERM_METHOD_CLEAR_ERROR = 'perm.clearError';
|
||||
export const PERM_METHOD_DECISION_KEY = 'perm.decisionKey';
|
||||
export const PERM_METHOD_HYDRATE = 'perm.hydrate';
|
||||
export const PERM_METHOD_INVALIDATE = 'perm.invalidate';
|
||||
|
||||
export const PERM_AUTO_INVALIDATE_NONE = 'none';
|
||||
export const PERM_AUTO_INVALIDATE_STANDARD = 'standard';
|
||||
export const PERM_AUTO_INVALIDATE_USER_IDENTITY_CHANGE = 'userIdentityChange';
|
||||
export const PERM_AUTO_INVALIDATE_PERMISSIONS_REFRESH = 'permissionsRefresh';
|
||||
export const PERM_AUTO_INVALIDATE_TENANT_SWITCHED = 'tenantSwitched';
|
||||
|
||||
export const PERM_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed';
|
||||
export const PERM_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed';
|
||||
export const PERM_LOG_MSG_REMOTE_WHAT_FAILED = 'remote authorization what failed';
|
||||
export const PERM_LOG_MSG_DECISION = 'authorization decision';
|
||||
export const PERM_LOG_MSG_DENIED = 'authorization denied';
|
||||
export const PERM_LOG_MSG_INDETERMINATE = 'authorization indeterminate';
|
||||
|
||||
export const PERM_DECISION_REASON_REMOTE_CHECK_FAILED = 'permission.remote.check_failed';
|
||||
export const PERM_DECISION_REASON_REMOTE_BATCH_FAILED = 'permission.remote.batch_failed';
|
||||
|
||||
export const PERM_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authorization request failed: ';
|
||||
export const PERM_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX =
|
||||
'Authorization response is not valid JSON: ';
|
||||
export const PERM_ERROR_MSG_NO_CONTEXT = 'Perm context is not available';
|
||||
export const PERM_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed permissions client';
|
||||
export const PERM_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED =
|
||||
'createActivePerms requires an endpoint';
|
||||
|
||||
// ── Error codes ────────────────────────────────────────────────────────
|
||||
|
||||
export const PERM_ERR_INVALID_ENDPOINT: ErrCode = errCode(PERM_ERR, 'invalid_endpoint');
|
||||
export const PERM_ERR_NO_CONTEXT: ErrCode = errCode(PERM_ERR, 'no_context');
|
||||
export const PERM_ERR_REMOTE_REQUEST: ErrCode = errCode(PERM_ERR, 'remote_request');
|
||||
export const PERM_ERR_CLIENT_DISPOSED: ErrCode = errCode(PERM_ERR, 'client_disposed');
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { PERM_CONTEXT_KEY, PERM_ERROR_MSG_NO_CONTEXT } from './consts.ts';
|
||||
import { PermNoContextError } from './errors.ts';
|
||||
import type { ActivePerms } from './types.ts';
|
||||
|
||||
const PERM_CONTEXT = Symbol(PERM_CONTEXT_KEY);
|
||||
|
||||
export function setPermsContext(client: ActivePerms): ActivePerms {
|
||||
setContext(PERM_CONTEXT, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getPermsContext(): ActivePerms {
|
||||
const client = getContext<ActivePerms | undefined>(PERM_CONTEXT);
|
||||
if (!client) throw new PermNoContextError(PERM_ERROR_MSG_NO_CONTEXT);
|
||||
return client;
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import {
|
||||
LogLevel,
|
||||
createCatalogDiagnostics,
|
||||
type DiagnosticCatalog,
|
||||
type DiagnosticEvent,
|
||||
type Diagnostics
|
||||
} from '$libs/logger';
|
||||
import {
|
||||
PERM_MODULE,
|
||||
PERM_CLIENT_DIAGNOSTIC_EVENTS,
|
||||
PERM_LOG_MSG_REMOTE_BATCH_FAILED,
|
||||
PERM_LOG_MSG_REMOTE_CHECK_FAILED,
|
||||
PERM_LOG_MSG_REMOTE_WHAT_FAILED
|
||||
} from './consts.ts';
|
||||
import type {
|
||||
PermClientBatchInput,
|
||||
PermClientCheckInput,
|
||||
PermClientOptions
|
||||
} from './types.ts';
|
||||
|
||||
export type PermClientDiagnosticType =
|
||||
(typeof PERM_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof PERM_CLIENT_DIAGNOSTIC_EVENTS];
|
||||
|
||||
export type PermClientDiagnosticMeta =
|
||||
| {
|
||||
readonly error: unknown;
|
||||
readonly input: PermClientCheckInput;
|
||||
}
|
||||
| {
|
||||
readonly error: unknown;
|
||||
readonly input: PermClientBatchInput;
|
||||
}
|
||||
| {
|
||||
readonly error: unknown;
|
||||
readonly input: {
|
||||
readonly resource?: PermClientCheckInput['resource'];
|
||||
readonly actions?: readonly string[];
|
||||
readonly context?: PermClientCheckInput['context'];
|
||||
};
|
||||
};
|
||||
|
||||
export type PermClientDiagnosticEvent = DiagnosticEvent<
|
||||
PermClientDiagnosticType,
|
||||
PermClientDiagnosticMeta
|
||||
>;
|
||||
export type PermClientDiagnostics = Diagnostics<PermClientDiagnosticEvent>;
|
||||
|
||||
const PERM_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog<PermClientDiagnosticEvent> = {
|
||||
[PERM_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_BATCH_FAILED]: {
|
||||
level: LogLevel.ERROR,
|
||||
message: PERM_LOG_MSG_REMOTE_BATCH_FAILED
|
||||
},
|
||||
[PERM_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_CHECK_FAILED]: {
|
||||
level: LogLevel.ERROR,
|
||||
message: PERM_LOG_MSG_REMOTE_CHECK_FAILED
|
||||
},
|
||||
[PERM_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_WHAT_FAILED]: {
|
||||
level: LogLevel.ERROR,
|
||||
message: PERM_LOG_MSG_REMOTE_WHAT_FAILED
|
||||
}
|
||||
};
|
||||
|
||||
export function createPermClientDiagnostics(
|
||||
logger?: PermClientOptions['logger']
|
||||
): PermClientDiagnostics {
|
||||
return createCatalogDiagnostics({
|
||||
logger,
|
||||
defaultCategory: PERM_MODULE,
|
||||
catalog: PERM_CLIENT_DIAGNOSTIC_LOGS
|
||||
});
|
||||
}
|
||||
|
||||
export function emitPermClientDiagnostic(
|
||||
diagnostics: PermClientDiagnostics,
|
||||
type: PermClientDiagnosticType,
|
||||
meta: PermClientDiagnosticMeta
|
||||
): void {
|
||||
diagnostics.emit({
|
||||
artifact: PERM_MODULE,
|
||||
type,
|
||||
meta
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { PERM_ERROR_MSG_DISPOSED_SUFFIX } from './consts.ts';
|
||||
|
||||
export function disposedPermsMessage(method: string): string {
|
||||
return `${method}${PERM_ERROR_MSG_DISPOSED_SUFFIX}`;
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
export { createActivePerms } from './active-permissions.svelte.ts';
|
||||
export { createPermClient } from './client.ts';
|
||||
export {
|
||||
createPermClientDiagnostics,
|
||||
emitPermClientDiagnostic
|
||||
} from './diagnostics.ts';
|
||||
export { getPermsContext, setPermsContext } from './context.ts';
|
||||
export { disposedPermsMessage } from './helpers.ts';
|
||||
export { permDecisionKey, stablePermStringify } from '$libs/svrs/perm';
|
||||
|
||||
export {
|
||||
PERM_MODULE,
|
||||
PERM_AUTO_INVALIDATE_NONE,
|
||||
PERM_AUTO_INVALIDATE_PERMISSIONS_REFRESH,
|
||||
PERM_AUTO_INVALIDATE_STANDARD,
|
||||
PERM_AUTO_INVALIDATE_TENANT_SWITCHED,
|
||||
PERM_AUTO_INVALIDATE_USER_IDENTITY_CHANGE,
|
||||
PERM_CLIENT_DIAGNOSTIC_EVENTS,
|
||||
PERM_CLIENT_CONTEXT_EMPTY,
|
||||
PERM_CLIENT_DEFAULT_CACHE_TTL_MS,
|
||||
PERM_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS,
|
||||
PERM_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS,
|
||||
PERM_CLIENT_KEY_GLOBAL,
|
||||
PERM_CLIENT_KEY_NONE,
|
||||
PERM_CLIENT_KEY_SEPARATOR,
|
||||
PERM_CLIENT_PATH_BATCH,
|
||||
PERM_CLIENT_PATH_CHECK,
|
||||
PERM_CLIENT_PATH_EXPLAIN,
|
||||
PERM_CLIENT_PATH_WHAT,
|
||||
PERM_CLIENT_SCOPE_PREFIX,
|
||||
PERM_CONTEXT_KEY,
|
||||
PERM_DECISION_REASON_REMOTE_BATCH_FAILED,
|
||||
PERM_DECISION_REASON_REMOTE_CHECK_FAILED,
|
||||
PERM_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED,
|
||||
PERM_ERROR_MSG_DISPOSED_SUFFIX,
|
||||
PERM_ERROR_MSG_NO_CONTEXT,
|
||||
PERM_ERROR_MSG_REQUEST_FAILED_PREFIX,
|
||||
PERM_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX,
|
||||
PERM_ERR_CLIENT_DISPOSED,
|
||||
PERM_ERR_INVALID_ENDPOINT,
|
||||
PERM_ERR_NO_CONTEXT,
|
||||
PERM_ERR_REMOTE_REQUEST,
|
||||
PERM_HTTP_CREDENTIALS_INCLUDE,
|
||||
PERM_HTTP_ERROR_METHOD_NOT_ALLOWED,
|
||||
PERM_HTTP_ERROR_METHOD_NOT_ALLOWED_MESSAGE,
|
||||
PERM_HTTP_ERROR_ROUTE_NOT_FOUND,
|
||||
PERM_HTTP_ERROR_ROUTE_NOT_FOUND_MESSAGE,
|
||||
PERM_HTTP_STATUS_BAD_REQUEST,
|
||||
PERM_HTTP_STATUS_FORBIDDEN,
|
||||
PERM_HTTP_STATUS_METHOD_NOT_ALLOWED,
|
||||
PERM_HTTP_STATUS_NOT_FOUND,
|
||||
PERM_HTTP_STATUS_OK,
|
||||
PERM_LOG_MSG_DECISION,
|
||||
PERM_LOG_MSG_DENIED,
|
||||
PERM_LOG_MSG_INDETERMINATE,
|
||||
PERM_LOG_MSG_REMOTE_BATCH_FAILED,
|
||||
PERM_LOG_MSG_REMOTE_CHECK_FAILED,
|
||||
PERM_LOG_MSG_REMOTE_WHAT_FAILED,
|
||||
PERM_METHOD_BATCH,
|
||||
PERM_METHOD_CAN,
|
||||
PERM_METHOD_CHECK,
|
||||
PERM_METHOD_CLEAR_ERROR,
|
||||
PERM_METHOD_DECISION_KEY,
|
||||
PERM_METHOD_EXPLAIN,
|
||||
PERM_METHOD_HYDRATE,
|
||||
PERM_METHOD_INVALIDATE,
|
||||
PERM_METHOD_SUBSCRIBE,
|
||||
PERM_METHOD_WHAT,
|
||||
PERM_REQUEST_FIELD_ACTION,
|
||||
PERM_REQUEST_FIELD_ACTIONS,
|
||||
PERM_REQUEST_FIELD_CHECKS,
|
||||
PERM_REQUEST_FIELD_CONTEXT,
|
||||
PERM_REQUEST_FIELD_RESOURCE,
|
||||
PERM_RESPONSE_FIELD_ACTIONS,
|
||||
PERM_RESPONSE_FIELD_DECISIONS,
|
||||
PERM_RESPONSE_FIELD_ERROR,
|
||||
PERM_SNAPSHOT_DECISIONS_KEY,
|
||||
PERM_SNAPSHOT_GLOBAL_POLICY
|
||||
} from './consts.ts';
|
||||
export {
|
||||
PermDisposedError,
|
||||
PermInvalidEndpointError,
|
||||
PermNoContextError,
|
||||
PermRemoteRequestError,
|
||||
isPermDisposedError,
|
||||
isPermInvalidEndpointError,
|
||||
isPermNoContextError,
|
||||
isPermRemoteRequestError
|
||||
} from './errors.ts';
|
||||
export type {
|
||||
PermClientDiagnosticEvent,
|
||||
PermClientDiagnosticMeta,
|
||||
PermClientDiagnosticType,
|
||||
PermClientDiagnostics
|
||||
} from './diagnostics.ts';
|
||||
export type {
|
||||
ActivePermError,
|
||||
ActivePerms,
|
||||
ActivePermsOptions,
|
||||
AdviceIR,
|
||||
AttributeProvider,
|
||||
DependencyKey,
|
||||
ExplainResult,
|
||||
ExprIR,
|
||||
ObligationIR,
|
||||
PermSchema,
|
||||
PermCheckInput,
|
||||
PermClient,
|
||||
PermClientBatchInput,
|
||||
PermClientClock,
|
||||
PermClientCheckInput,
|
||||
PermClientOptions,
|
||||
PermAutoInvalidateOn,
|
||||
PermAutoInvalidateTarget,
|
||||
PermDecision,
|
||||
PermEffect,
|
||||
PermFallback,
|
||||
PermFilterBuilder,
|
||||
PermProviders,
|
||||
PermSnapshot,
|
||||
PolicyIR,
|
||||
QueryCompiler,
|
||||
QueryPlan,
|
||||
RelationProvider,
|
||||
ResourceRef,
|
||||
ReverseQueryResult,
|
||||
SubjectRef
|
||||
} from './types.ts';
|
||||
|
||||
export {
|
||||
actionMatches,
|
||||
actionResource,
|
||||
actionsForResource,
|
||||
actor,
|
||||
allow,
|
||||
and,
|
||||
attr,
|
||||
audit,
|
||||
ctx,
|
||||
definePermSchema,
|
||||
definePolicies,
|
||||
deny,
|
||||
ExprBuilder,
|
||||
mask,
|
||||
not,
|
||||
or,
|
||||
PERM_EFFECT_ALLOW,
|
||||
PERM_EFFECT_DENY,
|
||||
PERM_EFFECT_INDETERMINATE,
|
||||
PERM_EFFECT_NOT_APPLICABLE,
|
||||
PERM_QUERY_TARGET_SQL,
|
||||
PolicyBuilder,
|
||||
redact,
|
||||
rel,
|
||||
RelationBuilder,
|
||||
requireMfa,
|
||||
resource,
|
||||
resourceKey,
|
||||
val,
|
||||
createSqlCompiler
|
||||
} from '$libs/perm';
|
||||
|
||||
export type {
|
||||
CreateSqlCompilerOptions,
|
||||
SqlCompileResult,
|
||||
SqlRelationCompiler,
|
||||
SqlRelationCompilerInput
|
||||
} from '$libs/perm';
|
||||
@ -0,0 +1,133 @@
|
||||
import type { Logger } from '$libs/logger';
|
||||
import type { AppEventBus } from '$libs/active-app/events';
|
||||
import type { ExplainResult, PermDecision, ResourceRef, SubjectRef } from '$libs/perm';
|
||||
import type { EngineHttp } from '$http';
|
||||
import type { ActiveChangeListener, ActiveEngine } from '$libs/active';
|
||||
import type {
|
||||
PERM_AUTO_INVALIDATE_NONE,
|
||||
PERM_AUTO_INVALIDATE_PERMISSIONS_REFRESH,
|
||||
PERM_AUTO_INVALIDATE_STANDARD,
|
||||
PERM_AUTO_INVALIDATE_TENANT_SWITCHED,
|
||||
PERM_AUTO_INVALIDATE_USER_IDENTITY_CHANGE
|
||||
} from './consts.ts';
|
||||
import type {
|
||||
PermDisposedError,
|
||||
PermInvalidEndpointError,
|
||||
PermNoContextError,
|
||||
PermRemoteRequestError
|
||||
} from './errors.ts';
|
||||
|
||||
export type {
|
||||
AdviceIR,
|
||||
AttributeProvider,
|
||||
DependencyKey,
|
||||
ExplainResult,
|
||||
ExprIR,
|
||||
ObligationIR,
|
||||
PermSchema,
|
||||
PermCheckInput,
|
||||
PermDecision,
|
||||
PermEffect,
|
||||
PermFallback,
|
||||
PermFilterBuilder,
|
||||
PermProviders,
|
||||
PolicyIR,
|
||||
QueryCompiler,
|
||||
QueryPlan,
|
||||
RelationProvider,
|
||||
ResourceRef,
|
||||
ReverseQueryResult,
|
||||
SubjectRef
|
||||
} from '$libs/perm';
|
||||
|
||||
export interface PermSnapshot {
|
||||
readonly actor?: SubjectRef;
|
||||
readonly version?: string;
|
||||
readonly decisions?: Record<string, PermDecision>;
|
||||
readonly global?: Record<string, boolean | PermDecision>;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface PermClientClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
export interface PermClientOptions {
|
||||
readonly endpoint: string;
|
||||
readonly fetcher?: typeof fetch;
|
||||
readonly http?: EngineHttp;
|
||||
readonly initialSnapshot?: PermSnapshot;
|
||||
readonly cacheTtlMs?: number;
|
||||
readonly nonAllowCacheTtlMs?: number;
|
||||
readonly remoteFailureBackoffMs?: number;
|
||||
readonly clock?: PermClientClock;
|
||||
readonly scopeKey?: string | (() => string | undefined);
|
||||
readonly logger?: Logger;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type PermAutoInvalidateTarget =
|
||||
| typeof PERM_AUTO_INVALIDATE_USER_IDENTITY_CHANGE
|
||||
| typeof PERM_AUTO_INVALIDATE_PERMISSIONS_REFRESH
|
||||
| typeof PERM_AUTO_INVALIDATE_TENANT_SWITCHED;
|
||||
|
||||
export type PermAutoInvalidateOn =
|
||||
| typeof PERM_AUTO_INVALIDATE_NONE
|
||||
| typeof PERM_AUTO_INVALIDATE_STANDARD
|
||||
| readonly PermAutoInvalidateTarget[];
|
||||
|
||||
export interface PermClientCheckInput {
|
||||
readonly action: string;
|
||||
readonly resource?: ResourceRef;
|
||||
readonly context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PermClientBatchInput {
|
||||
readonly checks: readonly PermClientCheckInput[];
|
||||
}
|
||||
|
||||
export interface PermClient {
|
||||
check(input: PermClientCheckInput): Promise<PermDecision>;
|
||||
can(input: PermClientCheckInput): Promise<boolean>;
|
||||
batch(input: PermClientBatchInput): Promise<Record<string, PermDecision>>;
|
||||
what(input: {
|
||||
readonly resource?: ResourceRef;
|
||||
readonly actions?: readonly string[];
|
||||
readonly context?: Record<string, unknown>;
|
||||
}): Promise<Record<string, PermDecision>>;
|
||||
explain(input: PermClientCheckInput): Promise<ExplainResult | null>;
|
||||
hydrate(snapshot: PermSnapshot): void;
|
||||
snapshot(): PermSnapshot;
|
||||
invalidate(scope?: string): void;
|
||||
subscribe(listener: (snapshot: PermSnapshot) => void): () => void;
|
||||
decisionKey(input: PermClientCheckInput): string;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type ActivePermError =
|
||||
| Error
|
||||
| PermDisposedError
|
||||
| PermInvalidEndpointError
|
||||
| PermNoContextError
|
||||
| PermRemoteRequestError;
|
||||
|
||||
export interface ActivePerms
|
||||
extends
|
||||
Omit<PermClient, 'subscribe'>,
|
||||
ActiveEngine<PermSnapshot, ActivePermError> {
|
||||
readonly currentSnapshot: PermSnapshot;
|
||||
readonly decisions: Record<string, PermDecision>;
|
||||
readonly size: number;
|
||||
readonly lastError: ActivePermError | null;
|
||||
clearError(): void;
|
||||
onChange(listener: ActiveChangeListener<PermSnapshot>): () => void;
|
||||
}
|
||||
|
||||
export interface ActivePermsOptions extends PermClientOptions {
|
||||
readonly bus?: AppEventBus;
|
||||
/**
|
||||
* Automatic reactions to public app events. Defaults to `'none'`; the
|
||||
* permissions client only clears its local cache when explicitly enabled.
|
||||
*/
|
||||
readonly autoInvalidateOn?: PermAutoInvalidateOn;
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
import {
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_INDETERMINATE
|
||||
} from '$libs/permissions';
|
||||
import {
|
||||
PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS,
|
||||
PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS,
|
||||
PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS
|
||||
} from './consts.ts';
|
||||
import type { PermissionDecision } from '$libs/permissions';
|
||||
import type { PermissionClientClock, PermissionClientOptions } from './types.ts';
|
||||
|
||||
interface CacheEntry {
|
||||
readonly decision: PermissionDecision;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface PermissionClientCacheRuntime {
|
||||
read(key: string): PermissionDecision | undefined;
|
||||
writeDecision(key: string, decision: PermissionDecision): boolean;
|
||||
writeFailure(key: string, decision: PermissionDecision): void;
|
||||
hydrate(decisions: Record<string, PermissionDecision>): void;
|
||||
clear(): void;
|
||||
deleteByPrefix(prefix: string): void;
|
||||
}
|
||||
|
||||
export function createPermissionClientCache(
|
||||
options: PermissionClientOptions,
|
||||
clock: PermissionClientClock
|
||||
): PermissionClientCacheRuntime {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS;
|
||||
const nonAllowCacheTtlMs =
|
||||
options.nonAllowCacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS;
|
||||
const remoteFailureBackoffMs =
|
||||
options.remoteFailureBackoffMs ?? PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const failures = new Map<string, CacheEntry>();
|
||||
|
||||
function now(): number {
|
||||
return clock.now();
|
||||
}
|
||||
|
||||
function resolveDecisionTtl(decision: PermissionDecision): number {
|
||||
if (decision.effect === PERMISSION_EFFECT_ALLOW) return decision.ttl ?? cacheTtlMs;
|
||||
if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) return 0;
|
||||
return Math.min(cacheTtlMs, nonAllowCacheTtlMs);
|
||||
}
|
||||
|
||||
function readEntry(entries: Map<string, CacheEntry>, key: string): PermissionDecision | undefined {
|
||||
const entry = entries.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
if (entry.expiresAt > now()) return entry.decision;
|
||||
entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function read(key: string): PermissionDecision | undefined {
|
||||
return readEntry(cache, key) ?? readEntry(failures, key);
|
||||
}
|
||||
|
||||
function writeDecision(key: string, decision: PermissionDecision): boolean {
|
||||
failures.delete(key);
|
||||
const ttl = resolveDecisionTtl(decision);
|
||||
if (ttl <= 0) return false;
|
||||
cache.set(key, { decision, expiresAt: now() + ttl });
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeFailure(key: string, decision: PermissionDecision): void {
|
||||
if (remoteFailureBackoffMs <= 0) return;
|
||||
failures.set(key, { decision, expiresAt: now() + remoteFailureBackoffMs });
|
||||
}
|
||||
|
||||
function hydrate(decisions: Record<string, PermissionDecision>): void {
|
||||
clear();
|
||||
for (const [key, decision] of Object.entries(decisions)) {
|
||||
writeDecision(key, decision);
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
cache.clear();
|
||||
failures.clear();
|
||||
}
|
||||
|
||||
function deleteByPrefix(prefix: string): void {
|
||||
for (const key of [...cache.keys()]) if (key.startsWith(prefix)) cache.delete(key);
|
||||
for (const key of [...failures.keys()]) if (key.startsWith(prefix)) failures.delete(key);
|
||||
}
|
||||
|
||||
return { read, writeDecision, writeFailure, hydrate, clear, deleteByPrefix };
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import {
|
||||
PERMISSION_CLIENT_KEY_SEPARATOR,
|
||||
PERMISSION_CLIENT_SCOPE_PREFIX
|
||||
} from './consts.ts';
|
||||
import { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/permissions';
|
||||
import type {
|
||||
PermissionClientCheckInput,
|
||||
PermissionClientOptions,
|
||||
PermissionSnapshot
|
||||
} from './types.ts';
|
||||
|
||||
export interface PermissionClientKeyRuntime {
|
||||
remoteDecisionKey(input: PermissionClientCheckInput): string;
|
||||
resolveScopeKey(): string | undefined;
|
||||
decisionKeyForScope(input: PermissionClientCheckInput, scope: string | undefined): string;
|
||||
decisionKey(input: PermissionClientCheckInput): string;
|
||||
scopedKeyPrefix(scope: string): string;
|
||||
}
|
||||
|
||||
export function createPermissionClientKeyRuntime(
|
||||
options: PermissionClientOptions,
|
||||
readSnapshot: () => PermissionSnapshot
|
||||
): PermissionClientKeyRuntime {
|
||||
function remoteDecisionKey(input: PermissionClientCheckInput): string {
|
||||
return permissionDecisionKey(input);
|
||||
}
|
||||
|
||||
function resolveScopeKey(): string | undefined {
|
||||
const configured =
|
||||
typeof options.scopeKey === 'function' ? options.scopeKey() : options.scopeKey;
|
||||
if (configured !== undefined && configured.length > 0) return configured;
|
||||
const actor = readSnapshot().actor;
|
||||
if (actor === undefined) return undefined;
|
||||
return stablePermissionStringify(actor);
|
||||
}
|
||||
|
||||
function decisionKeyForScope(
|
||||
input: PermissionClientCheckInput,
|
||||
scope: string | undefined
|
||||
): string {
|
||||
const base = remoteDecisionKey(input);
|
||||
if (scope === undefined) return base;
|
||||
return [PERMISSION_CLIENT_SCOPE_PREFIX, stablePermissionStringify(scope), base].join(
|
||||
PERMISSION_CLIENT_KEY_SEPARATOR
|
||||
);
|
||||
}
|
||||
|
||||
function decisionKey(input: PermissionClientCheckInput): string {
|
||||
return decisionKeyForScope(input, resolveScopeKey());
|
||||
}
|
||||
|
||||
function scopedKeyPrefix(scope: string): string {
|
||||
return [PERMISSION_CLIENT_SCOPE_PREFIX, stablePermissionStringify(scope), ''].join(
|
||||
PERMISSION_CLIENT_KEY_SEPARATOR
|
||||
);
|
||||
}
|
||||
|
||||
return { remoteDecisionKey, resolveScopeKey, decisionKeyForScope, decisionKey, scopedKeyPrefix };
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import {
|
||||
PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_DENY
|
||||
} from '$libs/permissions';
|
||||
import { PERMISSION_SNAPSHOT_GLOBAL_POLICY } from './consts.ts';
|
||||
import type { PermissionClientKeyRuntime } from './client-keys.ts';
|
||||
import type { PermissionDecision } from '$libs/permissions';
|
||||
import type { PermissionClientCheckInput, PermissionSnapshot } from './types.ts';
|
||||
|
||||
export function isPermissionSnapshotValid(snapshot: PermissionSnapshot, now: number): boolean {
|
||||
return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now;
|
||||
}
|
||||
|
||||
export function readPermissionSnapshotDecision(
|
||||
input: PermissionClientCheckInput,
|
||||
snapshot: PermissionSnapshot,
|
||||
now: number,
|
||||
keys: PermissionClientKeyRuntime
|
||||
): PermissionDecision | undefined {
|
||||
if (!isPermissionSnapshotValid(snapshot, now)) return undefined;
|
||||
const key = keys.decisionKey(input);
|
||||
const direct = snapshot.decisions?.[key];
|
||||
if (direct) return direct;
|
||||
const remote = snapshot.decisions?.[keys.remoteDecisionKey(input)];
|
||||
if (remote) return remote;
|
||||
const global = snapshot.global?.[input.action];
|
||||
if (typeof global === 'boolean') {
|
||||
return global
|
||||
? { effect: PERMISSION_EFFECT_ALLOW, policy: PERMISSION_SNAPSHOT_GLOBAL_POLICY }
|
||||
: {
|
||||
effect: PERMISSION_EFFECT_DENY,
|
||||
code: PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
|
||||
reason: PERMISSION_SNAPSHOT_GLOBAL_POLICY
|
||||
};
|
||||
}
|
||||
return global;
|
||||
}
|
||||
@ -1,292 +0,0 @@
|
||||
import {
|
||||
PERMISSION_EFFECT_ALLOW,
|
||||
PERMISSION_EFFECT_INDETERMINATE,
|
||||
PERMISSION_FALLBACK_DENY
|
||||
} from '$libs/permissions';
|
||||
import {
|
||||
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS,
|
||||
PERMISSION_CLIENT_PATH_BATCH,
|
||||
PERMISSION_CLIENT_PATH_CHECK,
|
||||
PERMISSION_CLIENT_PATH_EXPLAIN,
|
||||
PERMISSION_CLIENT_PATH_WHAT,
|
||||
PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED,
|
||||
PERMISSION_DECISION_REASON_REMOTE_CHECK_FAILED,
|
||||
PERMISSION_METHOD_BATCH,
|
||||
PERMISSION_METHOD_CAN,
|
||||
PERMISSION_METHOD_CHECK,
|
||||
PERMISSION_METHOD_DECISION_KEY,
|
||||
PERMISSION_METHOD_EXPLAIN,
|
||||
PERMISSION_METHOD_HYDRATE,
|
||||
PERMISSION_METHOD_INVALIDATE,
|
||||
PERMISSION_METHOD_SUBSCRIBE,
|
||||
PERMISSION_METHOD_WHAT,
|
||||
PERMISSION_REQUEST_FIELD_ACTION,
|
||||
PERMISSION_REQUEST_FIELD_CHECKS,
|
||||
PERMISSION_REQUEST_FIELD_CONTEXT,
|
||||
PERMISSION_REQUEST_FIELD_RESOURCE,
|
||||
PERMISSION_RESPONSE_FIELD_ACTIONS,
|
||||
PERMISSION_RESPONSE_FIELD_DECISIONS
|
||||
} from './consts.ts';
|
||||
import { createPermissionClientCache } from './client-cache.ts';
|
||||
import { postPermissionJson } from './client-http.ts';
|
||||
import { createPermissionClientKeyRuntime } from './client-keys.ts';
|
||||
import { readPermissionSnapshotDecision } from './client-snapshot.ts';
|
||||
import {
|
||||
createPermissionClientDiagnostics,
|
||||
emitPermissionClientDiagnostic
|
||||
} from './diagnostics.ts';
|
||||
import { PermDisposedError } from './errors.ts';
|
||||
import { disposedPermissionsMessage } from './helpers.ts';
|
||||
import type {
|
||||
PermissionClient,
|
||||
PermissionClientBatchInput,
|
||||
PermissionClientCheckInput,
|
||||
PermissionClientOptions,
|
||||
PermissionSnapshot
|
||||
} from './types.ts';
|
||||
import type { ExplainResult, PermissionDecision } from '$libs/permissions';
|
||||
|
||||
export function createPermissionClient(options: PermissionClientOptions): PermissionClient {
|
||||
const clock = options.clock ?? systemClock;
|
||||
const diagnostics = createPermissionClientDiagnostics(options.logger);
|
||||
const cache = createPermissionClientCache(options, clock);
|
||||
const pending = new Map<string, Promise<PermissionDecision>>();
|
||||
const listeners = new Set<(snapshot: PermissionSnapshot) => void>();
|
||||
let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} };
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
const keys = createPermissionClientKeyRuntime(options, () => currentSnapshot);
|
||||
|
||||
function now(): number {
|
||||
return clock.now();
|
||||
}
|
||||
|
||||
function ensureLive(method: string): void {
|
||||
if (disposed) throw new PermDisposedError(disposedPermissionsMessage(method));
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener(currentSnapshot);
|
||||
}
|
||||
|
||||
function readSnapshotDecision(input: PermissionClientCheckInput): PermissionDecision | undefined {
|
||||
return readPermissionSnapshotDecision(input, currentSnapshot, now(), keys);
|
||||
}
|
||||
|
||||
function setCached(
|
||||
key: string,
|
||||
decision: PermissionDecision,
|
||||
requestGeneration = generation
|
||||
): void {
|
||||
if (requestGeneration !== generation) return;
|
||||
if (!cache.writeDecision(key, decision)) return;
|
||||
currentSnapshot = {
|
||||
...currentSnapshot,
|
||||
decisions: {
|
||||
...(currentSnapshot.decisions ?? {}),
|
||||
[key]: decision
|
||||
}
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
function fallbackDecision(reason: string, error: unknown): PermissionDecision {
|
||||
return {
|
||||
effect: PERMISSION_EFFECT_INDETERMINATE,
|
||||
reason,
|
||||
fallback: PERMISSION_FALLBACK_DENY,
|
||||
errors: [error]
|
||||
};
|
||||
}
|
||||
|
||||
async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> {
|
||||
ensureLive(PERMISSION_METHOD_CHECK);
|
||||
const key = keys.decisionKey(input);
|
||||
const requestGeneration = generation;
|
||||
const cached = cache.read(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const snapshotDecision = readSnapshotDecision(input);
|
||||
if (snapshotDecision) {
|
||||
cache.writeDecision(key, snapshotDecision);
|
||||
return snapshotDecision;
|
||||
}
|
||||
|
||||
const inFlight = pending.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const request = postPermissionJson<PermissionDecision>(options, PERMISSION_CLIENT_PATH_CHECK, {
|
||||
[PERMISSION_REQUEST_FIELD_ACTION]: input.action,
|
||||
[PERMISSION_REQUEST_FIELD_RESOURCE]: input.resource,
|
||||
[PERMISSION_REQUEST_FIELD_CONTEXT]: input.context
|
||||
})
|
||||
.then((decision) => {
|
||||
setCached(key, decision, requestGeneration);
|
||||
return decision;
|
||||
})
|
||||
.catch((error) => {
|
||||
options.onError?.(error);
|
||||
emitPermissionClientDiagnostic(
|
||||
diagnostics,
|
||||
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_CHECK_FAILED,
|
||||
{ error, input }
|
||||
);
|
||||
const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_CHECK_FAILED, error);
|
||||
if (requestGeneration === generation) cache.writeFailure(key, fallback);
|
||||
return fallback;
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(key);
|
||||
});
|
||||
pending.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function batch(
|
||||
input: PermissionClientBatchInput
|
||||
): Promise<Record<string, PermissionDecision>> {
|
||||
ensureLive(PERMISSION_METHOD_BATCH);
|
||||
const requestGeneration = generation;
|
||||
const checks = input.checks.map((item) => ({
|
||||
remoteKey: keys.remoteDecisionKey(item),
|
||||
localKey: keys.decisionKey(item)
|
||||
}));
|
||||
try {
|
||||
const result = await postPermissionJson<{ decisions: Record<string, PermissionDecision> }>(
|
||||
options,
|
||||
PERMISSION_CLIENT_PATH_BATCH,
|
||||
{ [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks }
|
||||
);
|
||||
const decisions: Record<string, PermissionDecision> = {};
|
||||
for (const { remoteKey, localKey } of checks) {
|
||||
const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][remoteKey];
|
||||
if (decision) {
|
||||
setCached(localKey, decision, requestGeneration);
|
||||
decisions[localKey] = decision;
|
||||
}
|
||||
}
|
||||
return decisions;
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
emitPermissionClientDiagnostic(
|
||||
diagnostics,
|
||||
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_BATCH_FAILED,
|
||||
{ error, input }
|
||||
);
|
||||
const decisions: Record<string, PermissionDecision> = {};
|
||||
for (const { localKey } of checks) {
|
||||
const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED, error);
|
||||
decisions[localKey] = fallback;
|
||||
if (requestGeneration === generation) cache.writeFailure(localKey, fallback);
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
}
|
||||
|
||||
async function what(input: {
|
||||
readonly resource?: PermissionClientCheckInput['resource'];
|
||||
readonly actions?: readonly string[];
|
||||
readonly context?: PermissionClientCheckInput['context'];
|
||||
}): Promise<Record<string, PermissionDecision>> {
|
||||
ensureLive(PERMISSION_METHOD_WHAT);
|
||||
const scope = keys.resolveScopeKey();
|
||||
const requestGeneration = generation;
|
||||
try {
|
||||
const result = await postPermissionJson<{ actions: Record<string, PermissionDecision> }>(
|
||||
options,
|
||||
PERMISSION_CLIENT_PATH_WHAT,
|
||||
input
|
||||
);
|
||||
for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) {
|
||||
setCached(
|
||||
keys.decisionKeyForScope({ action, resource: input.resource, context: input.context }, scope),
|
||||
decision,
|
||||
requestGeneration
|
||||
);
|
||||
}
|
||||
return result[PERMISSION_RESPONSE_FIELD_ACTIONS];
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
emitPermissionClientDiagnostic(
|
||||
diagnostics,
|
||||
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_WHAT_FAILED,
|
||||
{ error, input }
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function explain(input: PermissionClientCheckInput): Promise<ExplainResult | null> {
|
||||
ensureLive(PERMISSION_METHOD_EXPLAIN);
|
||||
try {
|
||||
return await postPermissionJson<ExplainResult>(options, PERMISSION_CLIENT_PATH_EXPLAIN, input);
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(snapshot: PermissionSnapshot): void {
|
||||
ensureLive(PERMISSION_METHOD_HYDRATE);
|
||||
generation += 1;
|
||||
pending.clear();
|
||||
currentSnapshot = snapshot;
|
||||
cache.hydrate(snapshot.decisions ?? {});
|
||||
emit();
|
||||
}
|
||||
|
||||
function invalidate(scope?: string): void {
|
||||
ensureLive(PERMISSION_METHOD_INVALIDATE);
|
||||
generation += 1;
|
||||
if (!scope) {
|
||||
cache.clear();
|
||||
pending.clear();
|
||||
currentSnapshot = { ...currentSnapshot, decisions: {} };
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
const prefix = keys.scopedKeyPrefix(scope);
|
||||
cache.deleteByPrefix(prefix);
|
||||
for (const key of [...pending.keys()]) if (key.startsWith(prefix)) pending.delete(key);
|
||||
const decisions = { ...(currentSnapshot.decisions ?? {}) };
|
||||
for (const key of Object.keys(decisions)) if (key.startsWith(prefix)) delete decisions[key];
|
||||
currentSnapshot = { ...currentSnapshot, decisions };
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
check,
|
||||
async can(input) {
|
||||
ensureLive(PERMISSION_METHOD_CAN);
|
||||
return (await check(input)).effect === PERMISSION_EFFECT_ALLOW;
|
||||
},
|
||||
batch,
|
||||
what,
|
||||
explain,
|
||||
hydrate,
|
||||
snapshot: () => currentSnapshot,
|
||||
invalidate,
|
||||
subscribe(listener) {
|
||||
ensureLive(PERMISSION_METHOD_SUBSCRIBE);
|
||||
listeners.add(listener);
|
||||
listener(currentSnapshot);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
decisionKey(input) {
|
||||
ensureLive(PERMISSION_METHOD_DECISION_KEY);
|
||||
return keys.decisionKey(input);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
pending.clear();
|
||||
listeners.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const systemClock = {
|
||||
now: () => Date.now()
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue