import { PERMISSION_DECISION_CODE_SNAPSHOT_DENIED, PERMISSION_EFFECT_ALLOW, PERMISSION_EFFECT_DENY, PERMISSION_EFFECT_INDETERMINATE, PERMISSION_FALLBACK_DENY } from '$libs/perm'; import type { StandardSchemaV1 } from '$libs/standard-schema'; import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http'; import { PERMISSION_ACTIVE_EVENT_BATCH, PERMISSION_ACTIVE_EVENT_HYDRATE, PERMISSION_ACTIVE_EVENT_INVALIDATE, PERMISSION_CLIENT_DIAGNOSTIC_EVENTS, PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS, PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS, PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS, PERMISSION_CLIENT_PATH_BATCH, PERMISSION_CLIENT_PATH_CHECK, PERMISSION_CLIENT_PATH_EXPLAIN, PERMISSION_CLIENT_PATH_WHAT, PERMISSION_CLIENT_KEY_SEPARATOR, PERMISSION_CLIENT_SCOPE_PREFIX, PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED, PERMISSION_DECISION_REASON_REMOTE_CHECK_FAILED, PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX, PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX, PERMISSION_HTTP_CREDENTIALS_INCLUDE, PERMISSION_METHOD_DECISION_KEY, PERMISSION_METHOD_CHECK, PERMISSION_METHOD_CAN, PERMISSION_METHOD_EXPLAIN, 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, PERMISSION_SNAPSHOT_GLOBAL_POLICY } from './consts.ts'; import { createPermissionClientDiagnostics, emitPermissionClientDiagnostic } from './diagnostics.ts'; import { PermDisposedError, PermRemoteRequestError } from './errors.ts'; import { disposedPermissionsMessage } from './helpers.ts'; import { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/perm'; import type { PermissionClient, PermissionClientBatchInput, PermissionClientCheckInput, PermissionClientOptions, PermissionSnapshot } from './types.ts'; import type { ExplainResult, PermissionDecision } from '$libs/perm'; const PERMISSION_JSON_PASSTHROUGH_SCHEMA: StandardSchemaV1 = { '~standard': { version: 1, vendor: 'active-perm', validate(value) { return { value }; } } }; interface CacheEntry { readonly decision: PermissionDecision; readonly expiresAt: number; } 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, schema: PERMISSION_JSON_PASSTHROUGH_SCHEMA }); if (response.ok) return response.value as T; const status = 'status' in response ? response.status : undefined; throw new PermRemoteRequestError( `${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${status ?? url}`, url, status ); } 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 PermRemoteRequestError( `${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`, url, response.status ); } return readPermissionJson(response, url); } async function readPermissionJson(response: Response, url: string): Promise { const contentType = response.headers.get(HTTP_HEADER_CONTENT_TYPE); if (contentType && !contentType.toLowerCase().includes(HTTP_CONTENT_TYPE_JSON)) { throw new PermRemoteRequestError( `${PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`, url, response.status ); } try { return (await response.json()) as T; } catch (_error) { throw new PermRemoteRequestError( `${PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`, url, response.status ); } } export function createPermissionClient(options: PermissionClientOptions): PermissionClient { const clock = options.clock ?? systemClock; const diagnostics = createPermissionClientDiagnostics(options.logger); 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(); const failures = new Map(); const pending = new Map>(); const listeners = new Set<(snapshot: PermissionSnapshot) => void>(); let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} }; let generation = 0; let disposed = false; 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 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; if (currentSnapshot.actor === undefined) return undefined; return stablePermissionStringify(currentSnapshot.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 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 remote = currentSnapshot.decisions?.[remoteDecisionKey(input)]; if (remote) return remote; 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( key: string, decision: PermissionDecision, requestGeneration = generation ): void { if (requestGeneration !== generation) return; const ttl = resolveDecisionTtl(decision); failures.delete(key); if (ttl <= 0) return; cache.set(key, { decision, expiresAt: now() + ttl }); currentSnapshot = { ...currentSnapshot, decisions: { ...(currentSnapshot.decisions ?? {}), [key]: decision } }; emit(); } 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 fallbackDecision(reason: string, error: unknown): PermissionDecision { return { effect: PERMISSION_EFFECT_INDETERMINATE, reason, fallback: PERMISSION_FALLBACK_DENY, errors: [error] }; } async function check(input: PermissionClientCheckInput): Promise { ensureLive(PERMISSION_METHOD_CHECK); const key = decisionKey(input); const requestGeneration = generation; const cached = cache.get(key); if (cached && cached.expiresAt > now()) return cached.decision; const failed = failures.get(key); if (failed && failed.expiresAt > now()) return failed.decision; const snapshotDecision = readSnapshotDecision(input); if (snapshotDecision) { const ttl = resolveDecisionTtl(snapshotDecision); if (ttl > 0) cache.set(key, { decision: snapshotDecision, expiresAt: now() + ttl }); 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(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 && remoteFailureBackoffMs > 0) { failures.set(key, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs }); } return fallback; }) .finally(() => { pending.delete(key); }); pending.set(key, request); return request; } async function batch( input: PermissionClientBatchInput ): Promise> { ensureLive(PERMISSION_ACTIVE_EVENT_BATCH); const requestGeneration = generation; const checks = input.checks.map((item) => ({ remoteKey: remoteDecisionKey(item), localKey: decisionKey(item) })); try { const result = await postJson<{ decisions: Record }>( options, PERMISSION_CLIENT_PATH_BATCH, { [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks } ); const decisions: Record = {}; 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 = {}; for (const { localKey } of checks) { const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED, error); decisions[localKey] = fallback; if (requestGeneration === generation && remoteFailureBackoffMs > 0) { failures.set(localKey, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs }); } } return decisions; } } async function what(input: { readonly resource?: PermissionClientCheckInput['resource']; readonly actions?: readonly string[]; readonly context?: PermissionClientCheckInput['context']; }): Promise> { ensureLive(PERMISSION_METHOD_WHAT); const scope = resolveScopeKey(); const requestGeneration = generation; 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( 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 { ensureLive(PERMISSION_METHOD_EXPLAIN); try { return await postJson(options, PERMISSION_CLIENT_PATH_EXPLAIN, input); } catch (error) { options.onError?.(error); return null; } } function hydrate(snapshot: PermissionSnapshot): void { ensureLive(PERMISSION_ACTIVE_EVENT_HYDRATE); generation += 1; pending.clear(); currentSnapshot = snapshot; cache.clear(); failures.clear(); for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) { const ttl = resolveDecisionTtl(decision); if (ttl > 0) cache.set(key, { decision, expiresAt: now() + ttl }); } emit(); } function invalidate(scope?: string): void { ensureLive(PERMISSION_ACTIVE_EVENT_INVALIDATE); generation += 1; if (!scope) { cache.clear(); failures.clear(); pending.clear(); currentSnapshot = { ...currentSnapshot, decisions: {} }; emit(); return; } const prefix = scopedKeyPrefix(scope); 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); 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(); } function scopedKeyPrefix(scope: string): string { return [PERMISSION_CLIENT_SCOPE_PREFIX, stablePermissionStringify(scope), ''].join( PERMISSION_CLIENT_KEY_SEPARATOR ); } 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 decisionKey(input); }, dispose() { if (disposed) return; disposed = true; cache.clear(); failures.clear(); pending.clear(); listeners.clear(); } }; } const systemClock = { now: () => Date.now() };