import { PERMISSION_DECISION_CODE_SNAPSHOT_DENIED, PERMISSION_EFFECT_ALLOW, PERMISSION_EFFECT_DENY, PERMISSION_EFFECT_INDETERMINATE, PERMISSION_FALLBACK_DENY } from '$libs/perm'; import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http'; import { permissionDecisionKey } from './keys.ts'; import { LOGGER_CATEGORY, PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS, PERMISSION_CLIENT_PATH_BATCH, PERMISSION_CLIENT_PATH_CHECK, PERMISSION_CLIENT_PATH_EXPLAIN, PERMISSION_CLIENT_PATH_WHAT, PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX, PERMISSION_HTTP_CREDENTIALS_INCLUDE, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, PERMISSION_REQUEST_FIELD_ACTION, PERMISSION_REQUEST_FIELD_CHECKS, PERMISSION_REQUEST_FIELD_CONTEXT, PERMISSION_REQUEST_FIELD_RESOURCE, PERMISSION_RESPONSE_FIELD_ACTIONS, PERMISSION_RESPONSE_FIELD_DECISIONS, PERMISSION_SNAPSHOT_GLOBAL_POLICY } from './consts.ts'; import type { PermissionClient, PermissionClientBatchInput, PermissionClientCheckInput, PermissionClientOptions, PermissionSnapshot } from './types.ts'; import type { ExplainResult, PermissionDecision } from '$libs/perm'; interface CacheEntry { readonly decision: PermissionDecision; readonly expiresAt: number; } function now(): number { return Date.now(); } function joinUrl(base: string, path: string): string { return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; } async function postJson( options: PermissionClientOptions, path: string, body: unknown ): Promise { const url = joinUrl(options.endpoint, path); if (options.http) { const response = await options.http.post(url, { body: body as Record }); if (response.ok) return response.value as T; throw new Error(`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${url}`); } const fetcher = options.fetcher ?? fetch.bind(globalThis); const response = await fetcher(url, { method: HTTP_METHOD_POST, headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON }, credentials: PERMISSION_HTTP_CREDENTIALS_INCLUDE, body: JSON.stringify(body) }); if (!response.ok) { throw new Error( `${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}` ); } return (await response.json()) as T; } export function createPermissionClient(options: PermissionClientOptions): PermissionClient { const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS; const cache = new Map(); const pending = new Map>(); const listeners = new Set<(snapshot: PermissionSnapshot) => void>(); let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} }; function emit(): void { for (const listener of listeners) listener(currentSnapshot); } function decisionKey(input: PermissionClientCheckInput): string { return permissionDecisionKey(input); } function snapshotStillValid(snapshot: PermissionSnapshot): boolean { return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now(); } function readSnapshotDecision(input: PermissionClientCheckInput): PermissionDecision | undefined { if (!snapshotStillValid(currentSnapshot)) return undefined; const key = decisionKey(input); const direct = currentSnapshot.decisions?.[key]; if (direct) return direct; const global = currentSnapshot.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; } function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void { const key = decisionKey(input); const ttl = decision.effect === PERMISSION_EFFECT_ALLOW && decision.ttl ? decision.ttl : cacheTtlMs; cache.set(key, { decision, expiresAt: now() + ttl }); currentSnapshot = { ...currentSnapshot, decisions: { ...(currentSnapshot.decisions ?? {}), [key]: decision } }; emit(); } async function check(input: PermissionClientCheckInput): Promise { const key = decisionKey(input); const cached = cache.get(key); if (cached && cached.expiresAt > now()) return cached.decision; const snapshotDecision = readSnapshotDecision(input); if (snapshotDecision) { cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs }); return snapshotDecision; } const inFlight = pending.get(key); if (inFlight) return inFlight; const request = postJson(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(input, decision); return decision; }) .catch((error) => { options.onError?.(error); options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, { error, context: { input } }); return { effect: PERMISSION_EFFECT_INDETERMINATE, reason: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, fallback: PERMISSION_FALLBACK_DENY, errors: [error] } satisfies PermissionDecision; }) .finally(() => { pending.delete(key); }); pending.set(key, request); return request; } async function batch( input: PermissionClientBatchInput ): Promise> { try { const result = await postJson<{ decisions: Record }>( options, PERMISSION_CLIENT_PATH_BATCH, { [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks } ); for (const item of input.checks) { const key = decisionKey(item); const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key]; if (decision) setCached(item, decision); } return result[PERMISSION_RESPONSE_FIELD_DECISIONS]; } catch (error) { options.onError?.(error); options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, { error, context: { input } }); const decisions: Record = {}; for (const item of input.checks) { decisions[decisionKey(item)] = { effect: PERMISSION_EFFECT_INDETERMINATE, reason: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, fallback: PERMISSION_FALLBACK_DENY, errors: [error] }; } return decisions; } } async function what(input: { readonly resource?: PermissionClientCheckInput['resource']; readonly actions?: readonly string[]; readonly context?: PermissionClientCheckInput['context']; }): Promise> { try { const result = await postJson<{ actions: Record }>( options, PERMISSION_CLIENT_PATH_WHAT, input ); for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) { setCached({ action, resource: input.resource, context: input.context }, decision); } return result[PERMISSION_RESPONSE_FIELD_ACTIONS]; } catch (error) { options.onError?.(error); options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, { error, context: { input } }); return {}; } } async function explain(input: PermissionClientCheckInput): Promise { try { return await postJson(options, PERMISSION_CLIENT_PATH_EXPLAIN, input); } catch (error) { options.onError?.(error); return null; } } function hydrate(snapshot: PermissionSnapshot): void { currentSnapshot = snapshot; cache.clear(); for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) { cache.set(key, { decision, expiresAt: now() + cacheTtlMs }); } emit(); } function invalidate(scope?: string): void { if (!scope) { cache.clear(); currentSnapshot = { ...currentSnapshot, decisions: {} }; emit(); return; } for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key); const decisions = { ...(currentSnapshot.decisions ?? {}) }; for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key]; currentSnapshot = { ...currentSnapshot, decisions }; emit(); } return { check, async can(input) { return (await check(input)).effect === PERMISSION_EFFECT_ALLOW; }, batch, what, explain, hydrate, snapshot: () => currentSnapshot, invalidate, subscribe(listener) { listeners.add(listener); listener(currentSnapshot); return () => listeners.delete(listener); }, decisionKey }; }