Rename permissions→perm, formats→format; consolidate sium error infra

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
dev 5 months ago
parent 4db6bd2b3b
commit 7f2577c8da

@ -32,7 +32,7 @@ interface ActiveEngine<TSnapshot, TError> {
Conventions:
- Use direct getters (`Auth.current`, `Cache.loading`, `Permissions.lastError`),
- Use direct getters (`Auth.current`, `Cache.loading`, `Perms.lastError`),
not a module-specific `.state` object.
- Use `loading`, never `pending`, for in-flight work.
- Use `onChange()` for snapshot subscriptions. Lower-level clients may expose
@ -111,7 +111,7 @@ errors are typed.
| [`lang`](./lang/README.md) | `EngineLang`, `ActiveLang`, `ActiveMonoLang` | i18n: type-safe translations, BCP 47 resolution, plurals, refs, JSON round-trip | — |
| [`logr`](./logr/README.md) | `EngineLogger` | Structured logger: levels, transports, filters, vitals, dispose | — |
| [`timr`](./timr/README.md) | `EngineTimers`, `ActiveTimers` | Deterministic timer scheduler: clock injection, one-shots, intervals, cancellation, snapshots, backoff | `$libs/timers`, `$logr` (optional) |
| [`fmts`](./fmts/README.md) | `EngineFormats`, `ActiveFormats` | Localized formatting: numbers, currency, units, dates | `$logr` (currency) |
| [`fmts`](./fmts/README.md) | `EngineFormat`, `ActiveFormat` | Localized formatting: numbers, currency, units, dates | `$logr` (currency) |
| [`adom`](./adom/README.md) | `ActiveDom` | Reactive DOM service: viewport, breakpoints, attribute writes, scroll lock | `$libs/dom`, `$reactive` |
| [`fend`](./fend/README.md) | `ActiveFrontend` | Frontend preferences: theme, mode, dir, density, applied via DOM attrs | `$adom` |
| [`sium`](./sium/README.md) | `EngineSium` | Validation contracts: schemas, issues, introspection, Standard Schema interop | `$lang` (optional), `$logr` (optional), `$libs/days`, `$libs/color` |
@ -120,9 +120,9 @@ errors are typed.
| [`sess`](./sess/README.md) | `EngineSession`, `ActiveSession` | Session lifecycle: adopt/revoke/refresh, auto-refresh, 401-rescue hook, SvelteKit SSR via `adoptServer` + cookie reader | `$stor`, `$timr`, `$http`, `$logr` (optional) |
| [`conn`](./conn/README.md) | `EngineConnections`, `ActiveConnections` | Realtime connection registry: transports, reconnect, heartbeat, request/reply, channels, session bridge | `$timr`, `$logr` (optional), `$sess` bridge (optional) |
| [`auth`](./auth/README.md) | `ActiveAuth` (`EngineAuth` in `$svrs/auth`) | Authentication: password flows, CSRF, current session reflector, devices, logout, server-authoritative auth handlers | `$libs/auth`, `$http`, `$cach`, `$svrs/auth` |
| [`perm`](./perm/README.md) | `ActivePermissions` (`EnginePermissions` in `$svrs/perm`) | Authorization: policy runtime adapter, HTTP client/handlers, cache snapshot, `<Can />` guard | `$libs/perm`, `$libs/svrs`, `$http`, `$logr` (optional) |
| [`perm`](./perm/README.md) | `ActivePerms` (`EnginePerms` in `$svrs/perm`) | Authorization: policy runtime adapter, HTTP client/handlers, cache snapshot, `<Can />` guard | `$libs/perm`, `$libs/svrs`, `$http`, `$logr` (optional) |
| [`cach`](./cach/README.md) | `ActiveCache` (`EngineCache` in `$svrs/cache`) | Data cache: deterministic keys, policies, scopes, stale/revalidate, tags, memory/storage adapters | `$libs/cach`, `$stor` (adapter), `$logr` (optional) |
| [`aapp`](./aapp/README.md) | `ActiveApp` | App composition: wires Logger + Lang + Formats + Frontend + Dom + Storage + Http + Timers + Cache; factories for Sess, Conn, Auth, Perm, Sium | every artifact above |
| [`aapp`](./aapp/README.md) | `ActiveApp` | App composition: wires Logger + Lang + Format + Frontend + Dom + Storage + Http + Timers + Cache; factories for Sess, Conn, Auth, Perm, Sium | every artifact above |
## Composition
@ -138,17 +138,17 @@ const App = createActiveApp({
});
App.Lang.t('common.ok');
App.Formats.currency.format(99.5);
App.setLocale('es-MX'); // propagates to Lang, Formats, Frontend
App.Format.currency.format(99.5);
App.setLocale('es-MX'); // propagates to Lang, Format, Frontend
```
Every member of `App` is **always present**. When the caller does not
configure `lang`/`logger`/`formats`, App provides a structurally identical
adapter (mono lang, console logger, default-locale formats). Call sites stay
uniform: `App.Lang.t(...)` and `App.Formats.currency.format(...)` work
uniform: `App.Lang.t(...)` and `App.Format.currency.format(...)` work
whether or not i18n was configured.
`Sium`, `Session`, `Connections`, `Auth` and `Permissions` are exposed as
`Sium`, `Session`, `Connections`, `Auth` and `Perms` are exposed as
**factories** because they are feature/page-scoped: App injects shared
services, but construction is explicit at the call site.
@ -156,7 +156,7 @@ services, but construction is explicit at the call site.
const sium = createEngineSium({ lang: App.Lang, logger: App.Logger });
const Connections = App.createActiveConnections();
const Auth = App.createActiveAuth({ initial: data.auth });
const Permissions = App.createActivePermissions({ endpoint: '/permissions' });
const Perms = App.createActivePerms({ endpoint: '/permissions' });
```
See `aapp/README.md` for the full composition contract.

@ -20,7 +20,7 @@ const App = createActiveApp({
App.setLocale('es-MX');
App.Lang.t('common.ok');
App.Formats.currency.format(12.5);
App.Format.currency.format(12.5);
App.Frontend.setTheme('forest');
App.Logger.info('boot', 'app ready');
@ -33,7 +33,7 @@ App.dispose();
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `App.Logger` | yes | engine default — `level: WARN` + `consoleTransport()`. Pass `{ level: LogLevel.NONE, transports: [] }` for silence |
| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, `t('a.b\|Fallback')` returns `'Fallback'`, and DEV warns once per unresolved path through Logger under `lang.mono` |
| `App.Formats` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) |
| `App.Format` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) |
| `App.Frontend` | yes | real with default theme/mode/density |
| `App.Dom` | yes | real with default breakpoints |
| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; storage diagnostics are wired through the shared Logger |
@ -43,12 +43,12 @@ App.dispose();
| `App.Cache` | yes | `ActiveCache` backed by memory by default. Configure `cache: { adapter, policies, scopeResolver, autoInvalidateOn }` for persistence, policies or app-event reactions |
| `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic |
| `App.Auth` | no | created lazily through `App.createActiveAuth(...)`. App injects `Http`, `Cache` and `Logger`; the server authority remains `$svrs/auth` |
| `App.Permissions` | no | created lazily through `App.createActivePermissions(...)`. App injects `Http`, `Logger` and `Bus`; automatic invalidation uses `autoInvalidateOn` |
| `App.Perms` | no | created lazily through `App.createActivePerms(...)`. App injects `Http`, `Logger` and `Bus`; automatic invalidation uses `autoInvalidateOn` |
| `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and Bus; automatic reauth uses `autoReauthOn` plus each connection's own `session` option |
Server-authoritative engines that have a browser reflector live under
`$svrs/*`: use `$svrs/auth` for `createEngineAuth()` and auth HTTP handlers,
`$svrs/perm` for `createEnginePermissions()` and authorization handlers, and
`$svrs/perm` for `createEnginePerms()` and authorization handlers, and
`$svrs/cache` for `createEngineCache()` in services, repositories or server
load code. `aapp` composes only the active/client side.
@ -175,7 +175,7 @@ not from the event payload.
## What it solves
- **Single locale source.** `App.setLocale('es-MX')` propagates to `Lang`,
`Formats` and `Frontend` through a shared `LocaleSource`. No bridge code per
`Format` and `Frontend` through a shared `LocaleSource`. No bridge code per
call site.
- **Single logger.** Built once and piped into `Lang.setLogger` so every
artifact emits structured entries through the same transports (console,
@ -183,7 +183,7 @@ not from the event payload.
- **Single event bus.** `App.Bus` carries app-level facts. App translates
module events to `app.*`; cache, permissions and connections only mutate
themselves when their own `auto*On` options opt in.
- **Uniform call sites.** `App.Lang.t(label)` and `App.Formats.*` always work,
- **Uniform call sites.** `App.Lang.t(label)` and `App.Format.*` always work,
whether or not the caller configured i18n or fmts. No null checks.
- **Single lifecycle.** `App.dispose()` tears down the optional session,
bus, timers, persistence bridge, frontend, dom, formats, storage, lang and
@ -198,7 +198,7 @@ not from the event payload.
Logger.
3. **Storage** — built next so Frontend can read persisted preferences
before construction. Storage diagnostics are wired to the shared Logger.
4. **Formats** — built with a `localeSource` derived from Lang.
4. **Format** — built with a `localeSource` derived from Lang.
5. **Dom** — built before Frontend.
6. **Frontend** — receives Dom and the same `localeSource`. When
`frontend.persist` is configured, persisted values seed the initial
@ -221,7 +221,7 @@ not from the event payload.
12. **Connections** — created lazily via `App.createActiveConnections(...)`.
App injects Logger, Timers and Bus; `autoReauthOn` controls app-event
reactions.
13. **Permissions** — created lazily via `App.createActivePermissions(...)`.
13. **Perms** — created lazily via `App.createActivePerms(...)`.
App injects Logger, Http and Bus; endpoint/defaults can be provided either
in `createActiveApp({ permissions })` or at the factory call site.
14. **Auth** — created lazily via `App.createActiveAuth(...)`. App injects
@ -249,7 +249,7 @@ const App = createActiveApp({
frontend: { theme: 'base' }
});
App.Formats.currency.format(99.5); // "99,50 €" with default locale
App.Format.currency.format(99.5); // "99,50 €" with default locale
```
`App.Lang` is mono — components calling `App.Lang.t('actions.save|Save')`
@ -268,7 +268,7 @@ tracking, attribute writes) no-op in SSR.
## Locale flow
Lang is the single source of truth. Formats and Frontend subscribe to it via a
Lang is the single source of truth. Format and Frontend subscribe to it via a
common `LocaleSource` (`$locale`). The locale value is BCP 47:
```ts
@ -281,7 +281,7 @@ See `$lang/README.md` for the BCP 47 resolution rules in `Lang.t()` /
`Lang.ts()`.
When `lang` is not configured, `App.setLocale` still updates the mono lang's
internal locale and notifies Formats/Frontend — locale switching keeps working.
internal locale and notifies Format/Frontend — locale switching keeps working.
### SSR locale resolution + hydration
@ -511,7 +511,7 @@ export const cart = {
```
Pages and components import `cart` directly. The "infrastructure" artifacts
(Logger, Lang, Formats, Frontend, Dom) live in App because they are
(Logger, Lang, Format, Frontend, Dom) live in App because they are
cross-cutting and need uniform configuration. Domain state (current user,
cart, session, feature flags) is application-specific — putting it under
`App.Stores` would couple the framework to a bucket of unrelated nouns.
@ -551,7 +551,7 @@ appended — both sinks receive every entry.
interface ActiveAppOptions<S extends LangNode> {
logger?: LoggerOptions;
lang?: { schema: S; defaultLocale?: SupportedLocale; fallbackChain?: SupportedLocale[] };
formats?: Omit<ActiveFormatsOptions, 'locale' | 'localeSource'>;
formats?: Omit<ActiveFormatOptions, 'locale' | 'localeSource'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'> & {
persist?: FrontendPersist;
};
@ -562,7 +562,7 @@ interface ActiveAppOptions<S extends LangNode> {
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
cache?: Omit<ActiveCacheOptions, 'logger' | 'bus'>;
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session' | 'bus'>;
permissions?: Omit<ActivePermissionsOptions, 'logger' | 'http' | 'bus'>;
permissions?: Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>;
auth?: Omit<ActiveAuthOptions, 'http' | 'cache' | 'logger'>;
orchestration?: 'standard' | 'silent' | false | readonly ActiveAppOrchestrationTranslator[];
}
@ -570,7 +570,7 @@ interface ActiveAppOptions<S extends LangNode> {
interface ActiveApp<S extends LangNode = LangNode> {
readonly Logger: EngineLogger;
readonly Lang: ActiveLang<S>;
readonly Formats: ActiveFormats;
readonly Format: ActiveFormat;
readonly Frontend: ActiveFrontend;
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
@ -590,13 +590,13 @@ interface ActiveApp<S extends LangNode = LangNode> {
createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session' | 'bus'>
): ActiveConnections<TConnections>;
createActivePermissions(
options?: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http' | 'bus'>>
): ActivePermissions;
createActivePerms(
options?: Partial<Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>>
): ActivePerms;
createActiveAuth(options?: Omit<ActiveAuthOptions, 'http' | 'cache' | 'logger'>): ActiveAuth;
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
readonly Permissions: ActivePermissions | undefined;
readonly Perms: ActivePerms | undefined;
readonly Auth: ActiveAuth | undefined;
dispose(): void;
@ -608,7 +608,7 @@ interface ActiveApp<S extends LangNode = LangNode> {
Sium is a validation library that reaches into per-page data — login forms,
profile editors, signup wizards. Putting it in App would force every page
(including those without forms) to load the entire schema/types/issues machinery
just to use Lang or Formats. Keeping Sium page-scoped means:
just to use Lang or Format. Keeping Sium page-scoped means:
- Pages without validation pay nothing for it.
- Each form can use a sium engine tuned to its own needs (custom logger

@ -7,15 +7,15 @@ import { createActiveCache } from '$cache/active-cache.svelte';
import { createActiveConnections as createActiveConnectionsRegistry } from '$connection/active-connections.svelte';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$connection/types';
import { createActiveFrontend } from '$frontend/active-frontend.svelte';
import { createActiveFormats } from '$formats/active-formats.svelte';
import { createActiveFormat } from '$format/active-formats.svelte';
import { createEngineHttp } from '$http/engine-http';
import type { ActiveLang, LangNode } from '$libs/lang';
import { createActiveLang } from '$lang/active-lang.svelte';
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
import { createEngineLogger } from '$logger/engine-logger';
import { createActivePermissions as createActivePermissionsClient } from '$permissions/active-permissions.svelte';
import { PermInvalidEndpointError } from '$permissions/errors';
import type { ActivePermissions, ActivePermissionsOptions } from '$permissions/types';
import { createActivePerms as createActivePermsClient } from '$perm/active-permissions.svelte';
import { PermInvalidEndpointError } from '$perm/errors';
import type { ActivePerms, ActivePermsOptions } from '$perm/types';
import { createActiveSession } from '$session/active-session.svelte';
import { SessionAlreadyCreatedError } from '$session/errors';
import type { ActiveSession, EngineSessionOptions } from '$session/types';
@ -35,13 +35,13 @@ import {
APP_ERROR_ALREADY_CREATED_PERMISSIONS,
APP_ERROR_ALREADY_CREATED_AUTH,
APP_ERROR_ALREADY_CREATED_SESSION,
APP_ERROR_CREATE_PERMISSION_ENDPOINT_REQUIRED,
APP_ERROR_CREATE_PERM_ENDPOINT_REQUIRED,
APP_ORCHESTRATION_SILENT,
APP_ORCHESTRATION_STANDARD,
APP_ORCHESTRATION_TRANSLATOR_CONNECTIVITY,
APP_ORCHESTRATION_TRANSLATOR_DISPOSE,
APP_ORCHESTRATION_TRANSLATOR_IDENTITY,
APP_ORCHESTRATION_TRANSLATOR_PERMISSION_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED,
APP_MODULE
} from './consts.ts';
@ -86,7 +86,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
onLocaleChange: (fn: (locale: string) => void) => Lang.onLocaleChange(fn)
};
const Formats = createActiveFormats({
const Format = createActiveFormat({
...options.formats,
localeSource
});
@ -133,7 +133,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
let disposed = false;
let Sess: ActiveSession<unknown, unknown, unknown> | undefined;
let Permissions: ActivePermissions | undefined;
let Perms: ActivePerms | undefined;
let Auth: ActiveAuth | undefined;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- disposal registry is not rendered state.
const connectionRegistries = new Set<ActiveConnections>();
@ -141,7 +141,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
const app: ActiveApp<S> = {
Logger,
Lang,
Formats,
Format,
Frontend,
Dom,
Storage,
@ -154,8 +154,8 @@ export function createActiveApp<S extends LangNode = LangNode>(
return Sess;
},
get Permissions() {
return Permissions;
get Perms() {
return Perms;
},
get Auth() {
@ -206,10 +206,10 @@ export function createActiveApp<S extends LangNode = LangNode>(
return built;
},
createActivePermissions(
permissionOptions: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http' | 'bus'>> = {}
): ActivePermissions {
if (Permissions !== undefined) {
createActivePerms(
permissionOptions: Partial<Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>> = {}
): ActivePerms {
if (Perms !== undefined) {
throw new AappAlreadyCreatedError(APP_ERROR_ALREADY_CREATED_PERMISSIONS);
}
const merged = {
@ -217,16 +217,16 @@ export function createActiveApp<S extends LangNode = LangNode>(
...permissionOptions
};
if (!merged.endpoint) {
throw new PermInvalidEndpointError(APP_ERROR_CREATE_PERMISSION_ENDPOINT_REQUIRED);
throw new PermInvalidEndpointError(APP_ERROR_CREATE_PERM_ENDPOINT_REQUIRED);
}
const built = createActivePermissionsClient({
const built = createActivePermsClient({
...merged,
endpoint: merged.endpoint,
logger: Logger,
http: Http,
bus: Bus
});
Permissions = built;
Perms = built;
return built;
},
@ -243,7 +243,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
http: createEngineHttpAuthClient(Http),
cache: createAuthCacheInvalidator({
cache: Cache,
permissions: () => Permissions
permissions: () => Perms
})
});
Auth = built;
@ -258,8 +258,8 @@ export function createActiveApp<S extends LangNode = LangNode>(
}
Auth?.dispose();
Auth = undefined;
Permissions?.dispose();
Permissions = undefined;
Perms?.dispose();
Perms = undefined;
for (const registry of connectionRegistries) registry.dispose();
connectionRegistries.clear();
detachSessionTranslator?.();
@ -271,7 +271,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
teardownPersistence();
Frontend.dispose();
Dom.dispose();
Formats.dispose();
Format.dispose();
Storage.dispose();
Lang.dispose();
Logger.dispose();
@ -295,7 +295,7 @@ function resolveActiveAppOrchestration(
const STANDARD_ORCHESTRATION_TRANSLATORS = [
APP_ORCHESTRATION_TRANSLATOR_IDENTITY,
APP_ORCHESTRATION_TRANSLATOR_PERMISSION_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED,
APP_ORCHESTRATION_TRANSLATOR_CONNECTIVITY,
APP_ORCHESTRATION_TRANSLATOR_DISPOSE

@ -9,7 +9,7 @@ export const APP_CONNECTION_CLOSE_REASON_IDENTITY_CLEARED = 'identity_cleared';
export const APP_ORCHESTRATION_STANDARD = 'standard';
export const APP_ORCHESTRATION_SILENT = 'silent';
export const APP_ORCHESTRATION_TRANSLATOR_IDENTITY = 'identity';
export const APP_ORCHESTRATION_TRANSLATOR_PERMISSION_REFRESH = 'permissions-refresh';
export const APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH = 'permissions-refresh';
export const APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED = 'tenant-switched';
export const APP_ORCHESTRATION_TRANSLATOR_CONNECTIVITY = 'connectivity';
export const APP_ORCHESTRATION_TRANSLATOR_DISPOSE = 'dispose';
@ -20,10 +20,10 @@ export const APP_ERROR_ALREADY_CREATED_SESSION =
`[${APP_MODULE}] App.createActiveSession() called twice — only one session per App.`;
export const APP_ERROR_ALREADY_CREATED_PERMISSIONS =
`[${APP_MODULE}] App.createActivePermissions() called twice — only one permissions client per App.`;
`[${APP_MODULE}] App.createActivePerms() called twice — only one permissions client per App.`;
export const APP_ERROR_ALREADY_CREATED_AUTH =
`[${APP_MODULE}] App.createActiveAuth() called twice — only one active auth client per App.`;
export const APP_ERROR_CREATE_PERMISSION_ENDPOINT_REQUIRED =
`[${APP_MODULE}] App.createActivePermissions() requires endpoint via options.permissions or argument.`;
export const APP_ERROR_CREATE_PERM_ENDPOINT_REQUIRED =
`[${APP_MODULE}] App.createActivePerms() requires endpoint via options.permissions or argument.`;

@ -5,7 +5,7 @@ export {
APP_ORCHESTRATION_TRANSLATOR_CONNECTIVITY,
APP_ORCHESTRATION_TRANSLATOR_DISPOSE,
APP_ORCHESTRATION_TRANSLATOR_IDENTITY,
APP_ORCHESTRATION_TRANSLATOR_PERMISSION_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED,
APP_MODULE
} from './consts';

@ -1,11 +1,11 @@
import { CACHE_SCOPE_PUBLIC } from '$libs/cache';
import type { AuthClientCachePort } from '$libs/auth/contracts';
import type { ActiveCache } from '$cache';
import type { ActivePermissions } from '$permissions';
import type { ActivePerms } from '$perm';
export function createAuthCacheInvalidator(input: {
readonly cache: ActiveCache;
readonly permissions: () => ActivePermissions | undefined;
readonly permissions: () => ActivePerms | undefined;
}): AuthClientCachePort {
return {
async invalidate(event) {

@ -2,7 +2,7 @@
* aapp — composition smoke tests.
*
* Verifies that createActiveApp() wires every artifact correctly:
* - shared locale flows from Lang to Formats and Frontend
* - shared locale flows from Lang to Format and Frontend
* - logger reaches lang's setLogger (via the adapter)
* - mono lang activates when no schema is provided and warns via the
* shared logger (once per unresolved path)
@ -39,8 +39,8 @@ import {
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES
} from '$libs/auth';
import { PERMISSION_EFFECT_ALLOW } from '$libs/permissions';
import { PERMISSION_AUTO_INVALIDATE_STANDARD } from '$permissions';
import { PERM_EFFECT_ALLOW } from '$libs/perm';
import { PERM_AUTO_INVALIDATE_STANDARD } from '$perm';
import { SESSION_EVENT_LIFECYCLE_REVOKED } from '$session/consts';
const schema = {
@ -70,18 +70,18 @@ describe('createActiveApp — composition', () => {
'Bus',
'Cache',
'Dom',
'Formats',
'Format',
'Frontend',
'Http',
'Lang',
'Logger',
'Permissions',
'Perms',
'Sess',
'Storage',
'Timers',
'createActiveAuth',
'createActiveConnections',
'createActivePermissions',
'createActivePerms',
'createActiveSession',
'createSiumEngine',
'dispose',
@ -92,7 +92,7 @@ describe('createActiveApp — composition', () => {
expect(App.Logger).toBeDefined();
expect(App.Lang).toBeDefined();
expect(App.Formats).toBeDefined();
expect(App.Format).toBeDefined();
expect(App.Frontend).toBeDefined();
expect(App.Dom).toBeDefined();
expect(App.Storage).toBeDefined();
@ -101,7 +101,7 @@ describe('createActiveApp — composition', () => {
expect(App.Bus).toBeDefined();
expect(App.Cache).toBeDefined();
expect(App.Sess).toBeUndefined();
expect(App.Permissions).toBeUndefined();
expect(App.Perms).toBeUndefined();
expect(App.Auth).toBeUndefined();
App.dispose();
@ -151,7 +151,7 @@ describe('createActiveApp — composition', () => {
'ts'
]);
expect(surface(App.Formats)).toEqual([
expect(surface(App.Format)).toEqual([
'currency',
'dates',
'dispose',
@ -160,7 +160,7 @@ describe('createActiveApp — composition', () => {
'setLocale',
'units'
]);
expect(surface(App.Formats.numbers)).toEqual([
expect(surface(App.Format.numbers)).toEqual([
'clearDecimalSeparator',
'clearGroupSeparator',
'clearGrouping',
@ -185,7 +185,7 @@ describe('createActiveApp — composition', () => {
'setGrouping',
'setLocale'
]);
expect(surface(App.Formats.currency)).toEqual([
expect(surface(App.Format.currency)).toEqual([
'clearCurrency',
'convert',
'convertAs',
@ -201,7 +201,7 @@ describe('createActiveApp — composition', () => {
'setCurrency',
'setLocale'
]);
expect(surface(App.Formats.units)).toEqual([
expect(surface(App.Format.units)).toEqual([
'clearSystem',
'convert',
'convertToDefault',
@ -218,7 +218,7 @@ describe('createActiveApp — composition', () => {
'setLocale',
'setSystem'
]);
expect(surface(App.Formats.dates)).toEqual([
expect(surface(App.Format.dates)).toEqual([
'clearDateOrder',
'clearHourCycle',
'dispose',
@ -353,7 +353,7 @@ describe('createActiveApp — composition', () => {
});
const Session = App.createActiveSession();
const Connections = App.createActiveConnections();
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: 'https://active.test/permissions'
});
const Auth = App.createActiveAuth({
@ -407,7 +407,7 @@ describe('createActiveApp — composition', () => {
'size',
'states'
]);
expect(surface(Permissions)).toEqual([
expect(surface(Perms)).toEqual([
'batch',
'can',
'check',
@ -512,7 +512,7 @@ describe('createActiveApp — composition', () => {
App.dispose();
});
it('exposes Logger, Lang, Formats, Frontend, Dom (no Sium)', () => {
it('exposes Logger, Lang, Format, Frontend, Dom (no Sium)', () => {
const App = createActiveApp({
lang: { schema, defaultLocale: 'es' },
logger: { level: LogLevel.NONE, transports: [] }
@ -520,7 +520,7 @@ describe('createActiveApp — composition', () => {
expect(App.Logger).toBeDefined();
expect(App.Lang).toBeDefined();
expect(App.Formats).toBeDefined();
expect(App.Format).toBeDefined();
expect(App.Frontend).toBeDefined();
expect(App.Dom).toBeDefined();
expect((App as unknown as { Sium?: unknown }).Sium).toBeUndefined();
@ -624,13 +624,13 @@ describe('createActiveApp — composition', () => {
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] }
});
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: 'https://active.test/permissions',
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
Permissions.hydrate({
Perms.hydrate({
decisions: {
'post.read:p1': { effect: PERMISSION_EFFECT_ALLOW, policy: 'test.allow' }
'post.read:p1': { effect: PERM_EFFECT_ALLOW, policy: 'test.allow' }
}
});
let sizeSeenDuringDispose = -1;
@ -641,7 +641,7 @@ describe('createActiveApp — composition', () => {
identity: { from: 'identified', to: 'none' },
cause: APP_USER_IDENTITY_CAUSE_SESSION_REVOKED
});
sizeSeenDuringDispose = Permissions.size;
sizeSeenDuringDispose = Perms.size;
});
App.dispose();
@ -710,9 +710,9 @@ describe('createActiveApp — composition', () => {
retry: { limit: 0 }
}
});
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: 'https://active.test/permissions',
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
App.Bus.on(APP_EVENT_DISPOSE_STARTING, () => {
void Promise.resolve().then(() => {
@ -729,7 +729,7 @@ describe('createActiveApp — composition', () => {
});
});
const pendingPermission = Permissions.check({
const pendingPerm = Perms.check({
action: 'post.read',
resource: { type: 'post', id: 'p1' },
context: {}
@ -743,19 +743,19 @@ describe('createActiveApp — composition', () => {
await Promise.resolve();
App.dispose();
permissionReply.resolve({ effect: PERMISSION_EFFECT_ALLOW, policy: 'test.allow' });
permissionReply.resolve({ effect: PERM_EFFECT_ALLOW, policy: 'test.allow' });
cacheReply.resolve({ actorId: 'actor-ada' });
await expect(pendingPermission).resolves.toMatchObject({
effect: PERMISSION_EFFECT_ALLOW
await expect(pendingPerm).resolves.toMatchObject({
effect: PERM_EFFECT_ALLOW
});
await expect(pendingCache).resolves.toEqual({ actorId: 'actor-ada' });
await Promise.resolve();
expect(Permissions.snapshot().decisions).toEqual({});
expect(Perms.snapshot().decisions).toEqual({});
expect(cacheAdapter.inspect().entries).toHaveLength(0);
expect(deferredPublishError).toBeDefined();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
});
it('creates App-wired connection registries', async () => {
@ -850,7 +850,7 @@ describe('createActiveApp — composition', () => {
});
}
if (path === '/permissions/check') {
return json({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' });
return json({ effect: PERM_EFFECT_ALLOW, policy: 'remote.allow' });
}
return new Response(null, { status: 404 });
}) as typeof fetch;
@ -863,17 +863,17 @@ describe('createActiveApp — composition', () => {
}
}
});
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: 'https://active.test/permissions'
});
const Auth = App.createActiveAuth();
await Permissions.check({ action: 'post.read', resource: { type: 'post', id: 'p1' } });
expect(Permissions.size).toBe(1);
await Perms.check({ action: 'post.read', resource: { type: 'post', id: 'p1' } });
expect(Perms.size).toBe(1);
await Auth.signInPassword({ identifier: 'ada@example.com', password: 'correct horse' });
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(invalidatedTags).toEqual([
AUTH_CACHE_TAGS.AUTH_CURRENT,
AUTH_CACHE_TAGS.AUTH_DEVICES,
@ -947,7 +947,7 @@ describe('createActiveApp — opt-in defaults', () => {
const App = createActiveApp();
expect(App.Logger).toBeDefined();
expect(App.Lang).toBeDefined();
expect(App.Formats).toBeDefined();
expect(App.Format).toBeDefined();
expect(App.Frontend).toBeDefined();
expect(App.Dom).toBeDefined();
App.dispose();
@ -1043,28 +1043,28 @@ describe('createActiveApp — opt-in defaults', () => {
App.dispose();
});
it('Formats receives the mono locale via localeSource', () => {
it('Format receives the mono locale via localeSource', () => {
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] }
});
// Default mono locale is 'en'; Formats picks it up.
expect(App.Formats.getLocale()).toBe('en');
// Default mono locale is 'en'; Format picks it up.
expect(App.Format.getLocale()).toBe('en');
App.setLocale('es-ES');
expect(App.Lang.getLocale()).toBe('es-ES');
expect(App.Formats.getLocale()).toBe('es-ES');
expect(App.Format.getLocale()).toBe('es-ES');
App.dispose();
});
it('Formats accepts its own options (e.g. fixed currency) without lang', () => {
it('Format accepts its own options (e.g. fixed currency) without lang', () => {
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
formats: { currency: { currency: 'EUR' } }
});
expect(App.Formats.currency.getCurrency()).toBe('EUR');
expect(App.Format.currency.getCurrency()).toBe('EUR');
App.dispose();
});
});

@ -38,9 +38,9 @@ import {
} from '$libs/auth';
import { LogLevel, type LogEntry } from '$logger';
import {
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_NOT_APPLICABLE,
PERMISSION_AUTO_INVALIDATE_STANDARD,
PERM_EFFECT_ALLOW,
PERM_EFFECT_NOT_APPLICABLE,
PERM_AUTO_INVALIDATE_STANDARD,
actor,
allow,
and,
@ -50,22 +50,22 @@ import {
rel,
type ResourceRef,
type SubjectRef
} from '$permissions';
import { createEnginePermissions, createPermissionHttpHandlers } from '$svrs/permissions';
} from '$perm';
import { createEnginePerms, createPermHttpHandlers } from '$svrs/perm';
import { createMemoryAdapter } from '$storage';
import { SESSION_EVENT_LIFECYCLE_ADOPTED } from '$session/consts';
import {
APP_EVENT_USER_IDENTITY_CHANGED,
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED,
APP_USER_IDENTITY_CAUSE_SESSION_REVOKED,
onAppPermissionsRefreshRequested,
publishAppPermissionsRefreshRequested,
onAppPermsRefreshRequested,
publishAppPermsRefreshRequested,
publishAppTenantSwitched,
publishAppUserIdentityChanged
} from '$libs/active-app/events';
import { SESSION_EVENT_LIFECYCLE_REVOKED } from '$session/consts';
const PERMISSION_ENDPOINT = 'https://ecosystem.test/api/permissions';
const PERM_ENDPOINT = 'https://ecosystem.test/api/permissions';
const HTTP_PROJECT_PATH = '/api/demo/project';
const TENANT_ID = 'tenant-acme';
const SECOND_TENANT_ID = 'tenant-umbrella';
@ -84,8 +84,8 @@ const CHAT_CONNECTION_NAME = 'chat';
const LOG_CATEGORY = 'test.ecosystem';
const LOG_MESSAGE_BOOT = 'ecosystem.boot';
const ACK_FRAME_TYPE = 'test.ack';
const BACKEND_PERMISSION_CHANGED_FRAME_TYPE = 'permissions.changed';
const BACKEND_PERMISSION_CHANGED_CAUSE = 'websocket:permissions.changed';
const BACKEND_PERM_CHANGED_FRAME_TYPE = 'perm.changed';
const BACKEND_PERM_CHANGED_CAUSE = 'websocket:permissions.changed';
const BACKEND_SESSION_REVOKED_FRAME_TYPE = 'session.revoked';
const TOKEN_ADA = 'token-ada';
const TOKEN_LINUS = 'token-linus';
@ -143,8 +143,8 @@ describe('ActiveApp — total ecosystem integration', () => {
tenantId: TENANT_ID,
locked: false
};
const permissionEngine = createPermissionEngine();
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionEngine = createPermEngine();
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
let projectFetches = 0;
const cacheEvents: CacheEvent[] = [];
@ -239,7 +239,7 @@ describe('ActiveApp — total ecosystem integration', () => {
expect(App.Lang.t('demo.title')).toBe('Demo');
App.setLocale('ar');
expect(App.Frontend.getDir()).toBe('rtl');
expect(App.Formats.currency.format(1200).length).toBeGreaterThan(0);
expect(App.Format.currency.format(1200).length).toBeGreaterThan(0);
expect(App.Dom.resolve({ base: 'mobile', md: 'desktop' })).toBeDefined();
const storageEntry = App.Storage.entry('draft', () => ({ title: 'Atlas' }));
@ -268,29 +268,29 @@ describe('ActiveApp — total ecosystem integration', () => {
});
expect(Sess.current?.user?.id).toBe(ACTOR_ID);
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${ACTOR_ID}:${actorRole}`
});
const decision = await Permissions.check({
const decision = await Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
});
expect(decision.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(decision.effect).toBe(PERM_EFFECT_ALLOW);
actorRole = ROLE_VIEWER;
Permissions.invalidate();
const viewerDecision = await Permissions.check({
Perms.invalidate();
const viewerDecision = await Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
});
expect(viewerDecision.effect).toBe(PERMISSION_EFFECT_NOT_APPLICABLE);
expect(Permissions.size).toBeGreaterThan(0);
expect(viewerDecision.effect).toBe(PERM_EFFECT_NOT_APPLICABLE);
expect(Perms.size).toBeGreaterThan(0);
actorRole = ROLE_ADMIN;
Permissions.invalidate();
Perms.invalidate();
const project = await App.Cache.query<ProjectPayload>({
key: ['project', PROJECT_ID],
@ -359,7 +359,7 @@ describe('ActiveApp — total ecosystem integration', () => {
await Auth.signOut();
expect(Auth.authenticated).toBe(false);
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(cacheEvents.some((event) => event.type === CACHE_EVENT_INVALIDATE)).toBe(true);
} finally {
offCacheEvents();
@ -405,14 +405,14 @@ describe('ActiveApp — total ecosystem integration', () => {
it('keeps translators and consumer reactions separated', async () => {
let activeActorId = ACTOR_ID;
const permissionEngine = createPermissionEngine();
const permissionEngine = createPermEngine();
const actorRef = (): SubjectRef => ({
type: 'user',
id: activeActorId,
role: ROLE_ADMIN,
tenantIds: [TENANT_ID]
});
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const appFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const path = requestPath(input);
if (path.startsWith('/api/permissions')) {
@ -458,10 +458,10 @@ describe('ActiveApp — total ecosystem integration', () => {
const Sess = App.createActiveSession<DemoUser, DemoCredential>({
storage: { adapter: createMemoryAdapter(), key: 'session' }
});
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => activeActorId,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -491,7 +491,7 @@ describe('ActiveApp — total ecosystem integration', () => {
return { actorId: activeActorId, fetches };
}
});
await Permissions.check({
await Perms.check({
action: PROJECT_ACTION_UPDATE,
resource: {
type: 'project',
@ -512,7 +512,7 @@ describe('ActiveApp — total ecosystem integration', () => {
await drainMicrotasks();
expect(appIdentityEvents).toHaveLength(0);
expect(Permissions.size).toBe(1);
expect(Perms.size).toBe(1);
expect(Chat.state).toBe(CONNECTION_STATE_OPEN);
await expect(
App.Cache.query({
@ -535,7 +535,7 @@ describe('ActiveApp — total ecosystem integration', () => {
await drainMicrotasks();
expect(appIdentityEvents).toHaveLength(1);
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(Chat.state).toBe(CONNECTION_STATE_CLOSED);
await expect(
App.Cache.query({
@ -572,8 +572,8 @@ describe('ActiveApp — total ecosystem integration', () => {
role: activeRole,
tenantIds: [TENANT_ID]
});
const permissionEngine = createPermissionEngine();
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionEngine = createPermEngine();
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const appFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const path = requestPath(input);
if (path.startsWith('/api/permissions')) {
@ -657,10 +657,10 @@ describe('ActiveApp — total ecosystem integration', () => {
});
expect(JSON.stringify(appIdentityEvents[0])).not.toContain(TOKEN_ADA);
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${activeActorId}:${activeRole}`,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -696,13 +696,13 @@ describe('ActiveApp — total ecosystem integration', () => {
await expect(pendingConnect).resolves.toMatchObject({ ok: true });
expect(Chat.state).toBe(CONNECTION_STATE_OPEN);
const adminDecision = await Permissions.check({
const adminDecision = await Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
});
expect(adminDecision.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(Permissions.size).toBe(1);
expect(adminDecision.effect).toBe(PERM_EFFECT_ALLOW);
expect(Perms.size).toBe(1);
const cachedPresence = await App.Cache.query({
key: ['chat', 'presence'],
@ -740,7 +740,7 @@ describe('ActiveApp — total ecosystem integration', () => {
});
await drainMicrotasks();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(sessionEvents.some((event) => event.event === SESSION_EVENT_LIFECYCLE_ADOPTED)).toBe(true);
expect(appIdentityEvents).toHaveLength(2);
expect(appIdentityEvents[1]).toMatchObject({
@ -776,19 +776,19 @@ describe('ActiveApp — total ecosystem integration', () => {
false
);
const viewerDecision = await Permissions.check({
const viewerDecision = await Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
});
expect(viewerDecision.effect).toBe(PERMISSION_EFFECT_NOT_APPLICABLE);
expect(Permissions.size).toBe(1);
expect(viewerDecision.effect).toBe(PERM_EFFECT_NOT_APPLICABLE);
expect(Perms.size).toBe(1);
await Sess.revoke();
await drainMicrotasks();
expect(Sess.current).toBeNull();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(Chat.state).toBe(CONNECTION_STATE_CLOSED);
expect(appIdentityEvents).toHaveLength(3);
expect(appIdentityEvents[2]).toMatchObject({
@ -822,7 +822,7 @@ describe('ActiveApp — total ecosystem integration', () => {
}
});
it('propagates Auth sign-in and sign-out through Sess, Bus, Cache, Permissions and Connections', async () => {
it('propagates Auth sign-in and sign-out through Sess, Bus, Cache, Perms and Connections', async () => {
let activeActorId = ANONYMOUS_ACTOR_ID;
let activeRole = ROLE_VIEWER;
let chatPresenceFetches = 0;
@ -839,8 +839,8 @@ describe('ActiveApp — total ecosystem integration', () => {
role: activeRole,
tenantIds: [TENANT_ID]
});
const permissionEngine = createPermissionEngine();
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionEngine = createPermEngine();
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const appFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const path = requestPath(input);
if (path.startsWith('/api/permissions')) {
@ -959,10 +959,10 @@ describe('ActiveApp — total ecosystem integration', () => {
});
});
});
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${activeActorId}:${activeRole}`,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -1009,13 +1009,13 @@ describe('ActiveApp — total ecosystem integration', () => {
await expect(pendingConnect).resolves.toMatchObject({ ok: true });
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_ALLOW });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_ALLOW });
expect(Perms.size).toBe(1);
const adaPresence = await App.Cache.query({
key: ['auth', 'chat', 'presence'],
@ -1036,7 +1036,7 @@ describe('ActiveApp — total ecosystem integration', () => {
expect(Auth.current.actor?.actorId).toBe(NEXT_ACTOR_ID);
expect(Sess.current?.user?.id).toBe(NEXT_ACTOR_ID);
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(appIdentityEvents).toHaveLength(2);
expect(appIdentityEvents[1]).toMatchObject({
event: SESSION_EVENT_LIFECYCLE_ADOPTED,
@ -1066,13 +1066,13 @@ describe('ActiveApp — total ecosystem integration', () => {
})
).resolves.toEqual({ actorId: NEXT_ACTOR_ID, fetch: 2 });
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_NOT_APPLICABLE });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_NOT_APPLICABLE });
expect(Perms.size).toBe(1);
await Auth.signOut();
await authBridgeWork;
@ -1080,7 +1080,7 @@ describe('ActiveApp — total ecosystem integration', () => {
expect(Auth.authenticated).toBe(false);
expect(Sess.current).toBeNull();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(Chat.state).toBe(CONNECTION_STATE_CLOSED);
expect(appIdentityEvents).toHaveLength(3);
expect(appIdentityEvents[2]).toMatchObject({
@ -1125,8 +1125,8 @@ describe('ActiveApp — total ecosystem integration', () => {
let activeActorId = ANONYMOUS_ACTOR_ID;
let activeRole = ROLE_VIEWER;
let chatPresenceFetches = 0;
let delayNextPermission = false;
const stalePermission = createDeferred<Record<string, unknown>>();
let delayNextPerm = false;
const stalePerm = createDeferred<Record<string, unknown>>();
const stalePresence = createDeferred<{ readonly actorId: string; readonly fetch: number }>();
const resource: ProjectResource = {
type: 'project',
@ -1140,15 +1140,15 @@ describe('ActiveApp — total ecosystem integration', () => {
role: activeRole,
tenantIds: [TENANT_ID]
});
const permissionEngine = createPermissionEngine();
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionEngine = createPermEngine();
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const appFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const path = requestPath(input);
if (path.startsWith('/api/permissions')) {
const body = init?.body === undefined ? {} : JSON.parse(String(init.body));
if (delayNextPermission) {
delayNextPermission = false;
return jsonResponse(await stalePermission.promise);
if (delayNextPerm) {
delayNextPerm = false;
return jsonResponse(await stalePerm.promise);
}
const request = {
method: init?.method ?? 'POST',
@ -1243,10 +1243,10 @@ describe('ActiveApp — total ecosystem integration', () => {
});
});
});
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${activeActorId}:${activeRole}`,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -1278,8 +1278,8 @@ describe('ActiveApp — total ecosystem integration', () => {
ackFrame(transport, latestFrame(transport));
await expect(pendingConnect).resolves.toMatchObject({ ok: true });
delayNextPermission = true;
const oldPermission = Permissions.check({
delayNextPerm = true;
const oldPerm = Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
@ -1299,7 +1299,7 @@ describe('ActiveApp — total ecosystem integration', () => {
await drainMicrotasks();
expect(Sess.current?.user?.id).toBe(NEXT_ACTOR_ID);
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
await waitForSentCount(transport, 2);
const nextAuth = latestFrame(transport);
expect(nextAuth).toMatchObject({
@ -1308,28 +1308,28 @@ describe('ActiveApp — total ecosystem integration', () => {
});
ackFrame(transport, nextAuth);
stalePermission.resolve({
effect: PERMISSION_EFFECT_ALLOW,
stalePerm.resolve({
effect: PERM_EFFECT_ALLOW,
reason: 'stale-ada-response',
ttl: 60_000
});
stalePresence.resolve({ actorId: ACTOR_ID, fetch: 1 });
await expect(oldPermission).resolves.toMatchObject({
effect: PERMISSION_EFFECT_ALLOW,
await expect(oldPerm).resolves.toMatchObject({
effect: PERM_EFFECT_ALLOW,
reason: 'stale-ada-response'
});
await expect(oldPresence).resolves.toEqual({ actorId: ACTOR_ID, fetch: 1 });
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_NOT_APPLICABLE });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_NOT_APPLICABLE });
expect(Perms.size).toBe(1);
await expect(
App.Cache.query({
@ -1357,14 +1357,14 @@ describe('ActiveApp — total ecosystem integration', () => {
const activeRole = ROLE_ADMIN;
let dashboardFetches = 0;
const cacheAdapter = memoryCacheAdapter({ suppressProductionWarning: true });
const permissionEngine = createPermissionEngine();
const permissionEngine = createPermEngine();
const actorRef = (): SubjectRef => ({
type: 'user',
id: activeActorId,
role: activeRole,
tenantIds: [activeTenantId]
});
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const resourceForTenant = (): ProjectResource => ({
type: 'project',
id: PROJECT_ID,
@ -1429,10 +1429,10 @@ describe('ActiveApp — total ecosystem integration', () => {
issuedAt: 1,
expiresAt: Date.now() + 60_000
});
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${activeTenantId}:${activeActorId}:${activeRole}`,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -1459,13 +1459,13 @@ describe('ActiveApp — total ecosystem integration', () => {
expect(transport.sentMessages()).toHaveLength(1);
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource: resourceForTenant(),
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_ALLOW });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_ALLOW });
expect(Perms.size).toBe(1);
const firstDashboard = await App.Cache.query({
key: ['tenant', 'dashboard'],
@ -1489,13 +1489,13 @@ describe('ActiveApp — total ecosystem integration', () => {
});
expect(cacheAdapter.inspect().entries).toHaveLength(1);
publishAppPermissionsRefreshRequested(App.Bus, {
publishAppPermsRefreshRequested(App.Bus, {
cause: 'policy-published',
generation: 2
});
await drainMicrotasks();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(transport.sentMessages()).toHaveLength(1);
expect(cacheAdapter.inspect().entries).toHaveLength(1);
await expect(
@ -1512,13 +1512,13 @@ describe('ActiveApp — total ecosystem integration', () => {
).resolves.toEqual(firstDashboard);
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource: resourceForTenant(),
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_ALLOW });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_ALLOW });
expect(Perms.size).toBe(1);
activeTenantId = SECOND_TENANT_ID;
publishAppTenantSwitched(App.Bus, {
@ -1528,7 +1528,7 @@ describe('ActiveApp — total ecosystem integration', () => {
});
await drainMicrotasks();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(transport.sentMessages()).toHaveLength(1);
expect(cacheAdapter.inspect().entries).toHaveLength(0);
const secondTenantDashboard = await App.Cache.query({
@ -1589,19 +1589,19 @@ describe('ActiveApp — total ecosystem integration', () => {
const activeActorId = ACTOR_ID;
let activeRole = ROLE_ADMIN;
let dashboardFetches = 0;
let delayNextPermission = false;
let delayNextPerm = false;
const permissionRefreshCauses: string[] = [];
const permissionRefreshPayloads: unknown[] = [];
const delayedPermission = createDeferred<Record<string, unknown>>();
const delayedPerm = createDeferred<Record<string, unknown>>();
const cacheAdapter = memoryCacheAdapter({ suppressProductionWarning: true });
const permissionEngine = createPermissionEngine();
const permissionEngine = createPermEngine();
const actorRef = (): SubjectRef => ({
type: 'user',
id: activeActorId,
role: activeRole,
tenantIds: [activeTenantId]
});
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const resource: ProjectResource = {
type: 'project',
id: PROJECT_ID,
@ -1612,9 +1612,9 @@ describe('ActiveApp — total ecosystem integration', () => {
const path = requestPath(input);
if (path.startsWith('/api/permissions')) {
const body = init?.body === undefined ? {} : JSON.parse(String(init.body));
if (delayNextPermission) {
delayNextPermission = false;
return jsonResponse(await delayedPermission.promise);
if (delayNextPerm) {
delayNextPerm = false;
return jsonResponse(await delayedPerm.promise);
}
const request = {
method: init?.method ?? 'POST',
@ -1658,7 +1658,7 @@ describe('ActiveApp — total ecosystem integration', () => {
}
}
});
const offPermissionRefresh = onAppPermissionsRefreshRequested(App.Bus, (event) => {
const offPermRefresh = onAppPermsRefreshRequested(App.Bus, (event) => {
permissionRefreshCauses.push(event.payload.cause);
permissionRefreshPayloads.push(event.payload);
});
@ -1675,10 +1675,10 @@ describe('ActiveApp — total ecosystem integration', () => {
issuedAt: 1,
expiresAt: Date.now() + 60_000
});
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${activeTenantId}:${activeActorId}:${activeRole}`,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -1698,12 +1698,12 @@ describe('ActiveApp — total ecosystem integration', () => {
session: { enabled: true, reauthOnChange: true, reauthOnRefresh: true }
});
offChatWebhook = Chat.onAny((frame) => {
if (frame.type !== BACKEND_PERMISSION_CHANGED_FRAME_TYPE) return;
if (frame.type !== BACKEND_PERM_CHANGED_FRAME_TYPE) return;
const payload = frame.payload as { generation?: unknown } | undefined;
const generation =
typeof payload?.generation === 'number' ? payload.generation : undefined;
void publishAppPermissionsRefreshRequested(App.Bus, {
cause: BACKEND_PERMISSION_CHANGED_CAUSE,
void publishAppPermsRefreshRequested(App.Bus, {
cause: BACKEND_PERM_CHANGED_CAUSE,
generation
});
});
@ -1715,13 +1715,13 @@ describe('ActiveApp — total ecosystem integration', () => {
expect(transport.sentMessages()).toHaveLength(1);
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_ALLOW });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_ALLOW });
expect(Perms.size).toBe(1);
const cachedDashboard = await App.Cache.query({
key: ['backend-webhook', 'dashboard'],
@ -1746,8 +1746,8 @@ describe('ActiveApp — total ecosystem integration', () => {
tenantId: activeTenantId,
locked: false
};
delayNextPermission = true;
const stalePermission = Permissions.check({
delayNextPerm = true;
const stalePerm = Perms.check({
action: PROJECT_ACTION_UPDATE,
resource: delayedResource,
context: { risk: { mfa: true } }
@ -1758,23 +1758,23 @@ describe('ActiveApp — total ecosystem integration', () => {
transport.emitMessage(
JSON.stringify(
createFrame({
type: BACKEND_PERMISSION_CHANGED_FRAME_TYPE,
type: BACKEND_PERM_CHANGED_FRAME_TYPE,
payload: { generation: 2 }
})
)
);
await drainMicrotasks();
delayedPermission.resolve({
effect: PERMISSION_EFFECT_ALLOW,
delayedPerm.resolve({
effect: PERM_EFFECT_ALLOW,
reason: 'stale-pre-webhook-permission',
ttl: 60_000
});
expect(permissionRefreshCauses).toEqual([BACKEND_PERMISSION_CHANGED_CAUSE]);
expect(Permissions.size).toBe(0);
expect(permissionRefreshCauses).toEqual([BACKEND_PERM_CHANGED_CAUSE]);
expect(Perms.size).toBe(0);
expect(transport.sentMessages()).toHaveLength(1);
expect(cacheAdapter.inspect().entries).toHaveLength(1);
expect(JSON.stringify(decodedFrames(transport))).not.toContain(BACKEND_PERMISSION_CHANGED_CAUSE);
expect(JSON.stringify(decodedFrames(transport))).not.toContain(BACKEND_PERM_CHANGED_CAUSE);
expect(JSON.stringify(permissionRefreshPayloads)).not.toContain(TOKEN_ADA);
await expect(
App.Cache.query({
@ -1788,23 +1788,23 @@ describe('ActiveApp — total ecosystem integration', () => {
}
})
).resolves.toEqual(cachedDashboard);
await expect(stalePermission).resolves.toMatchObject({
effect: PERMISSION_EFFECT_ALLOW,
await expect(stalePerm).resolves.toMatchObject({
effect: PERM_EFFECT_ALLOW,
reason: 'stale-pre-webhook-permission'
});
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource: delayedResource,
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_NOT_APPLICABLE });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_NOT_APPLICABLE });
expect(Perms.size).toBe(1);
} finally {
offChatWebhook?.();
offPermissionRefresh.unsubscribe();
offPermRefresh.unsubscribe();
App.dispose();
permissionEngine.dispose();
}
@ -1818,14 +1818,14 @@ describe('ActiveApp — total ecosystem integration', () => {
let remoteRevokeWork: Promise<unknown> = Promise.resolve();
const appIdentityEvents: unknown[] = [];
const cacheAdapter = memoryCacheAdapter({ suppressProductionWarning: true });
const permissionEngine = createPermissionEngine();
const permissionEngine = createPermEngine();
const actorRef = (): SubjectRef => ({
type: 'user',
id: activeActorId,
role: activeRole,
tenantIds: [activeTenantId]
});
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
const permissionHandlers = createPermHttpHandlers(permissionEngine, actorRef);
const resource: ProjectResource = {
type: 'project',
id: PROJECT_ID,
@ -1894,10 +1894,10 @@ describe('ActiveApp — total ecosystem integration', () => {
issuedAt: 1,
expiresAt: Date.now() + 60_000
});
const Permissions = App.createActivePermissions({
endpoint: PERMISSION_ENDPOINT,
const Perms = App.createActivePerms({
endpoint: PERM_ENDPOINT,
scopeKey: () => `${activeTenantId}:${activeActorId}:${activeRole}`,
autoInvalidateOn: PERMISSION_AUTO_INVALIDATE_STANDARD
autoInvalidateOn: PERM_AUTO_INVALIDATE_STANDARD
});
const Connections = App.createActiveConnections({
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
@ -1938,13 +1938,13 @@ describe('ActiveApp — total ecosystem integration', () => {
expect(Chat.state).toBe(CONNECTION_STATE_OPEN);
await expect(
Permissions.check({
Perms.check({
action: PROJECT_ACTION_UPDATE,
resource,
context: { risk: { mfa: true } }
})
).resolves.toMatchObject({ effect: PERMISSION_EFFECT_ALLOW });
expect(Permissions.size).toBe(1);
).resolves.toMatchObject({ effect: PERM_EFFECT_ALLOW });
expect(Perms.size).toBe(1);
await expect(
App.Cache.query({
@ -1973,7 +1973,7 @@ describe('ActiveApp — total ecosystem integration', () => {
await drainMicrotasks();
expect(Sess.current).toBeNull();
expect(Permissions.size).toBe(0);
expect(Perms.size).toBe(0);
expect(Chat.state).toBe(CONNECTION_STATE_CLOSED);
expect(cacheAdapter.inspect().entries).toHaveLength(0);
expect(appIdentityEvents).toHaveLength(2);
@ -2008,7 +2008,7 @@ describe('ActiveApp — total ecosystem integration', () => {
});
});
function createPermissionEngine() {
function createPermEngine() {
const schema = definePermSchema({
actors: {
user: {
@ -2035,7 +2035,7 @@ function createPermissionEngine() {
}
});
return createEnginePermissions({
return createEnginePerms({
schema,
policies: definePolicies(schema, [
allow(PROJECT_ACTION_UPDATE).when(

@ -22,7 +22,7 @@ describe('createTestApp', () => {
const App = createTestApp();
expect(App.Logger).toBeDefined();
expect(App.Lang).toBeDefined();
expect(App.Formats).toBeDefined();
expect(App.Format).toBeDefined();
expect(App.Frontend).toBeDefined();
expect(App.Dom).toBeDefined();
expect(App.entries).toEqual([]);
@ -111,7 +111,7 @@ describe('createTestApp', () => {
});
expect(App.Lang.t('greeting')).toBe('Hola');
expect(App.Formats.currency.getCurrency()).toBe('EUR');
expect(App.Format.currency.getCurrency()).toBe('EUR');
App.setLocale('en');
expect(App.Lang.t('greeting')).toBe('Hello');

@ -5,12 +5,12 @@ import type { ActiveCache, ActiveCacheOptions } from '$cache';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$connection';
import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend';
import type { FrontendPreferenceKey } from '$frontend';
import type { ActiveFormats, ActiveFormatsOptions } from '$formats';
import type { ActiveFormat, ActiveFormatOptions } from '$format';
import type { EngineHttp, EngineHttpOptions } from '$http';
import type { AppEventMap } from '$libs/active-app/events';
import type { ActiveLang, LangNode, SupportedLocale } from '$libs/lang';
import type { EngineLogger, LoggerOptions } from '$logger';
import type { ActivePermissions, ActivePermissionsOptions } from '$permissions';
import type { ActivePerms, ActivePermsOptions } from '$perm';
import type { ActiveSession, EngineSessionOptions, SessEventMap } from '$session';
import type { EngineSium } from '$sium';
import type { ActiveStorage, SyncStorageAdapter } from '$storage';
@ -22,7 +22,7 @@ import type {
APP_ORCHESTRATION_TRANSLATOR_CONNECTIVITY,
APP_ORCHESTRATION_TRANSLATOR_DISPOSE,
APP_ORCHESTRATION_TRANSLATOR_IDENTITY,
APP_ORCHESTRATION_TRANSLATOR_PERMISSION_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH,
APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED
} from './consts.ts';
@ -78,7 +78,7 @@ export type ActiveAppOrchestrationPreset =
export type ActiveAppOrchestrationTranslator =
| typeof APP_ORCHESTRATION_TRANSLATOR_IDENTITY
| typeof APP_ORCHESTRATION_TRANSLATOR_PERMISSION_REFRESH
| typeof APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH
| typeof APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED
| typeof APP_ORCHESTRATION_TRANSLATOR_CONNECTIVITY
| typeof APP_ORCHESTRATION_TRANSLATOR_DISPOSE;
@ -94,9 +94,9 @@ export type ActiveAppOrchestrationOptions =
* - `logger` defaults to the engine's `level: WARN` + `consoleTransport()`.
* Pass `{ level: LogLevel.NONE, transports: [] }` for silence.
* - `lang` defaults to a mono lang when absent (single-language passthrough,
* reactive locale still owned so Formats/Frontend stay in sync).
* reactive locale still owned so Format/Frontend stay in sync).
* - `formats` is always built; sub-engines accept their own knobs (currency,
* date order, etc.). When absent the locale falls back to `FORMATS_DEFAULT_LOCALE`.
* date order, etc.). When absent the locale falls back to `FORMAT_DEFAULT_LOCALE`.
*
* Sium is intentionally **not** part of App — validation is page-scoped.
* Pages that need it construct an `EngineSium` directly:
@ -113,7 +113,7 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
defaultLocale?: SupportedLocale;
fallbackChain?: SupportedLocale[];
};
formats?: Omit<ActiveFormatsOptions, 'locale' | 'localeSource'>;
formats?: Omit<ActiveFormatOptions, 'locale' | 'localeSource'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'> & {
/**
* Persist user preferences (theme, mode, density, ...) through
@ -163,12 +163,12 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
*/
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session' | 'bus'>;
/**
* Defaults for `App.createActivePermissions()`. App injects Logger, Http
* Defaults for `App.createActivePerms()`. App injects Logger, Http
* and Bus automatically; the endpoint remains explicit because the client
* only reflects server decisions. Automatic app-event invalidation is
* controlled by `autoInvalidateOn`.
*/
permissions?: Omit<ActivePermissionsOptions, 'logger' | 'http' | 'bus'>;
permissions?: Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>;
/**
* Defaults for `App.createActiveAuth()`. App injects Http and Cache
* automatically; the authoritative auth engine still lives server-side
@ -189,7 +189,7 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
* Composed application surface.
*
* Every member is **always** present so call sites can use `App.Lang.t(...)`
* or `App.Formats.currency.format(...)` without null checks. When the caller
* or `App.Format.currency.format(...)` without null checks. When the caller
* did not configure an artifact, App provides a structurally identical
* adapter:
*
@ -197,14 +197,14 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
* | -------- | ---------- | ------------------------------------------------ |
* | Logger | real | `level: WARN` + `consoleTransport()` (engine default; pass `{ level: NONE, transports: [] }` for silence) |
* | Lang | real | mono — returns paths and `\|fallback` literals; warns once per path in DEV via Logger under `lang.mono` |
* | Formats | real | real with locale = `FORMATS_DEFAULT_LOCALE` (`'en-US'`) |
* | Format | real | real with locale = `FORMAT_DEFAULT_LOCALE` (`'en-US'`) |
* | Frontend | real | real with default theme/mode/density |
* | Dom | real | real with default breakpoints |
*/
export interface ActiveApp<S extends LangNode = LangNode> {
readonly Logger: EngineLogger;
readonly Lang: ActiveLang<S>;
readonly Formats: ActiveFormats;
readonly Format: ActiveFormat;
readonly Frontend: ActiveFrontend;
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
@ -286,12 +286,12 @@ export interface ActiveApp<S extends LangNode = LangNode> {
/**
* Build the App-scoped reactive permissions client. The authoritative
* runtime is `createEnginePermissions()` from `$svrs/permissions`; this client
* runtime is `createEnginePerms()` from `$svrs/perm`; this client
* is only for UI/UX reflection, snapshots and cache.
*/
createActivePermissions(
options?: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http' | 'bus'>>
): ActivePermissions;
createActivePerms(
options?: Partial<Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>>
): ActivePerms;
/**
* Build the App-scoped active authentication client. The server-side
@ -307,7 +307,7 @@ export interface ActiveApp<S extends LangNode = LangNode> {
* with `if (App.Sess) { ... }`.
*/
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
readonly Permissions: ActivePermissions | undefined;
readonly Perms: ActivePerms | undefined;
readonly Auth: ActiveAuth | undefined;
/** Tear down owned instances in reverse construction order. Idempotent. */

@ -324,7 +324,7 @@ deviceId;
`perm` puede usar ese contexto para decidir:
```ts
Permissions.can({
Perms.can({
actor,
action: 'project:update',
resource

@ -368,7 +368,7 @@ const Bus = createSvelteEngineBus({
clock: Timers.clock
});
const App = { Logger, Lang, Formats, Frontend, Dom, Storage, Http, Timers, Bus, Cache };
const App = { Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers, Bus, Cache };
```
Modules accept `EngineBus` or `EventPublisher` from `$buss` and use
@ -676,7 +676,7 @@ const Cache = App.createActiveCache({
autoInvalidateOn: 'standard'
});
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: '/permissions',
autoInvalidateOn: 'standard'
});
@ -691,7 +691,7 @@ Defaults for `'standard'`:
| Consumer | Reacts to (`'standard'`) | Action |
| --------------- | ----------------------------------------------------------------------- | ---------------------------- |
| `Cache` | `userIdentityChanged`, `tenantSwitched` | `Cache.invalidate()` |
| `Permissions` | `userIdentityChanged`, `permissionsRefresh`, `tenantSwitched` | `Permissions.invalidate()` |
| `Perms` | `userIdentityChanged`, `permissionsRefresh`, `tenantSwitched` | `Perms.invalidate()` |
| `Connections` | `userIdentityChanged` | `reauthenticateAll()` |
Override values: `'standard'` (table above), `'none'` (no auto-reactions),
@ -894,7 +894,7 @@ Implemented:
- `libs/aapp/events.ts` app-event constants, runtime metadata,
`publishApp*` helpers, `onApp*` helpers and unsafe-payload guard.
- App-level session translator and dispose-starting publisher.
- Per-consumer reactions for Cache, Permissions and Connections.
- Per-consumer reactions for Cache, Perms and Connections.
## Deferred work
@ -915,7 +915,7 @@ Out of scope for the current cut:
- `BUS_*` — generic bus constants (live in `arts/buss`).
- `APP_EVENT_*` — public app event names (live in `libs/aapp/events.ts`).
- `<MOD>_EVENT_*` (`SESSION_EVENT_*`, `AUTH_EVENT_*`, `CACHE_EVENT_*`,
`PERMISSION_EVENT_*`, `CONNECTION_EVENT_*`) — module event names, live in their
`PERM_EVENT_*`, `CONNECTION_EVENT_*`) — module event names, live in their
owning artifact's `consts.ts`.
- Diagnostic constants are **full-prefixed strings**, never bare names:
`'bus.event.published'`, not `'event_published'`.

@ -72,7 +72,7 @@ const App = createActiveApp({
scopeResolver: () => ({
tenantId: App.Sess?.current?.data?.tenantId,
actorId: App.Sess?.current?.user?.id,
permissionHash: App.Permissions?.currentSnapshot.version,
permissionHash: App.Perms?.currentSnapshot.version,
locale: App.getLocale()
})
}

@ -1,6 +1,6 @@
# Formats
# Format
`Formats` es la API publica del artefacto `fmts`. Su responsabilidad es
`Format` es la API publica del artefacto `fmts`. Su responsabilidad es
centralizar todos los formatos que dependen de `locale`: numeros, moneda,
unidades y fechas. No traduce textos, no decide idioma y no depende de `Lang`;
solo consume una fuente de locale cuando se quiere reactividad.
@ -10,17 +10,17 @@ El objetivo es que una app tenga una sola verdad:
```ts
App.setLocale('es-AR');
App.Formats.numbers.format(1234.5);
App.Formats.currency.getCurrency(); // ARS
App.Formats.units.getSystem(); // metric
App.Formats.dates.getDateOrder();
App.Format.numbers.format(1234.5);
App.Format.currency.getCurrency(); // ARS
App.Format.units.getSystem(); // metric
App.Format.dates.getDateOrder();
```
## Mapa del modulo
| Pieza | Factory | Responsabilidad |
| --- | --- | --- |
| `formats` | `createEngineFormats`, `createActiveFormats` | Agrega `numbers`, `currency`, `units` y `dates` bajo un mismo locale. |
| `formats` | `createEngineFormat`, `createActiveFormat` | Agrega `numbers`, `currency`, `units` y `dates` bajo un mismo locale. |
| `numbers` | `createEngineNumbers`, `createActiveNumbers` | Formato y parseo numerico, separadores, porcentajes, compact, currency/unit via `Intl.NumberFormat`. |
| `currency` | `createEngineCurrency`, `createActiveCurrency` | Moneda por region, formato de moneda, conversion y cache de rates. |
| `units` | `createEngineUnits`, `createActiveUnits` | Sistema metrico/imperial, unidades por defecto y conversiones. |
@ -37,9 +37,9 @@ Todos los submodulos siguen el mismo patron:
## Uso rapido
```ts
import { createEngineFormats } from '$fmts';
import { createEngineFormat } from '$fmts';
const formats = createEngineFormats({ locale: 'es-ES' });
const formats = createEngineFormat({ locale: 'es-ES' });
formats.numbers.format(1234.5); // "1234,5" segun Intl del runtime
formats.currency.format(12.5); // "12,50 EUR"
@ -50,22 +50,22 @@ formats.dates.formatDateTime(new Date());
En una app Svelte:
```ts
import { createActiveFormats } from '$fmts';
import { createActiveFormat } from '$fmts';
const Formats = createActiveFormats({
const Format = createActiveFormat({
locale: 'en-US'
});
Formats.setLocale('fr-FR');
Formats.currency.format(99.5);
Format.setLocale('fr-FR');
Format.currency.format(99.5);
```
Bajo `createActiveApp(...)` normalmente no se crea a mano: `App.Formats` recibe
Bajo `createActiveApp(...)` normalmente no se crea a mano: `App.Format` recibe
el `localeSource` de la app y se mueve con `App.setLocale(...)`.
## LocaleSource
`Formats` consume el alias compartido `LocaleSource` de `$locale`:
`Format` consume el alias compartido `LocaleSource` de `$locale`:
```ts
import type { LocaleSource } from '$locale';
@ -76,7 +76,7 @@ interface LocaleSource<L extends string = string> {
}
```
`createActiveFormats({ localeSource })` suscribe esa fuente y propaga los cambios
`createActiveFormat({ localeSource })` suscribe esa fuente y propaga los cambios
a todos los submotores. Si el source no tiene `onLocaleChange`, el engine puede
leer `getLocale()` cuando se formatea, pero no recibe notificaciones activas.
@ -175,7 +175,7 @@ const rates = createRates({
}
});
const formats = createEngineFormats({
const formats = createEngineFormat({
locale: 'es-ES',
currency: { rates }
});
@ -233,15 +233,15 @@ Los wrappers activos exponen listeners para que una UI o un modulo superior
pueda reaccionar sin inspeccionar internals:
```ts
const offLocale = Formats.numbers.onLocaleChange((locale) => {
const offLocale = Format.numbers.onLocaleChange((locale) => {
console.log('locale changed', locale);
});
const offPrefs = Formats.dates.onPreferenceChange(() => {
const offPrefs = Format.dates.onPreferenceChange(() => {
console.log('date preferences changed');
});
const offCurrency = Formats.currency.onCurrencyChange((currency) => {
const offCurrency = Format.currency.onCurrencyChange((currency) => {
console.log('currency changed', currency);
});
```
@ -250,7 +250,7 @@ const offCurrency = Formats.currency.onCurrencyChange((currency) => {
## Integracion recomendada
En `aapp`, `Formats` debe compartir locale con `Lang` y `Frontend`:
En `aapp`, `Format` debe compartir locale con `Lang` y `Frontend`:
```ts
const App = createActiveApp({
@ -261,13 +261,13 @@ const App = createActiveApp({
});
App.setLocale('ar');
App.Formats.dates.formatDate(new Date());
App.Format.dates.formatDate(new Date());
```
Si un modulo necesita solo formatos sin toda la app, usa el engine aislado:
```ts
const formats = createEngineFormats({
const formats = createEngineFormat({
locale: () => requestLocale
});
```
@ -278,5 +278,5 @@ Tests utiles:
- `npx vitest run src/arts/fmts`
- `/test/fmts` para validar locale compartido, auto/manual y UI.
- `/test/ecosystem` para validar `Lang + Formats + Frontend + Dom` juntos.
- `/test/ecosystem` para validar `Lang + Format + Frontend + Dom` juntos.

@ -2,23 +2,23 @@ import { createActiveCurrency } from './currency/active-currency.svelte';
import { createActiveDates } from './dates/active-dates.svelte';
import { createActiveNumbers } from './numbers/active-numbers.svelte';
import { createActiveUnits } from './units/active-units.svelte';
import { createActiveFormatsLocaleSource } from './active-runtime.svelte';
import { createActiveFormatLocaleSource } from './active-runtime.svelte';
import type { ActiveCurrency, ActiveCurrencyOptions } from './currency';
import type { ActiveDates, ActiveDatesOptions } from './dates';
import type { ActiveNumbers, ActiveNumbersOptions } from './numbers';
import type { ActiveUnits, ActiveUnitsOptions } from './units';
import type { FormatsLocaleSource } from './types';
import type { FormatLocaleSource } from './types';
export interface ActiveFormatsOptions {
export interface ActiveFormatOptions {
locale?: string;
localeSource?: FormatsLocaleSource;
localeSource?: FormatLocaleSource;
numbers?: Omit<ActiveNumbersOptions, 'locale' | 'localeSource'>;
currency?: Omit<ActiveCurrencyOptions, 'locale' | 'localeSource' | 'numbers'>;
units?: Omit<ActiveUnitsOptions, 'locale' | 'localeSource' | 'numbers'>;
dates?: Omit<ActiveDatesOptions, 'locale' | 'localeSource'>;
}
export interface ActiveFormats {
export interface ActiveFormat {
readonly numbers: ActiveNumbers;
readonly currency: ActiveCurrency;
readonly units: ActiveUnits;
@ -28,8 +28,8 @@ export interface ActiveFormats {
dispose: () => void;
}
export function createActiveFormats(options: ActiveFormatsOptions = {}): ActiveFormats {
const localeState = createActiveFormatsLocaleSource(options);
export function createActiveFormat(options: ActiveFormatOptions = {}): ActiveFormat {
const localeState = createActiveFormatLocaleSource(options);
const localeSource = localeState.source;
const numbers = createActiveNumbers({ ...options.numbers, localeSource });

@ -1,23 +1,23 @@
import type { FormatsLocaleSource } from './types';
import type { FormatLocaleSource } from './types';
export interface ActiveFormatsLocaleSourceOptions {
export interface ActiveFormatLocaleSourceOptions {
locale?: string;
localeSource?: FormatsLocaleSource;
localeSource?: FormatLocaleSource;
}
export interface ActiveFormatsLocaleSource {
readonly source: FormatsLocaleSource;
export interface ActiveFormatLocaleSource {
readonly source: FormatLocaleSource;
getLocale: () => string;
setLocale: (locale: string) => void;
}
export interface ActiveFormatsRuntimeOptions {
localeSource?: FormatsLocaleSource;
export interface ActiveFormatRuntimeOptions {
localeSource?: FormatLocaleSource;
getLocale: () => string;
setLocale: (locale: string) => void;
}
export interface ActiveFormatsRuntime {
export interface ActiveFormatRuntime {
read: () => void;
notifyChange: () => void;
syncLocale: (locale: string) => void;
@ -26,11 +26,11 @@ export interface ActiveFormatsRuntime {
dispose: () => void;
}
export function createActiveFormatsLocaleSource(
options: ActiveFormatsLocaleSourceOptions
): ActiveFormatsLocaleSource {
export function createActiveFormatLocaleSource(
options: ActiveFormatLocaleSourceOptions
): ActiveFormatLocaleSource {
let currentLocale = options.localeSource?.getLocale() ?? options.locale;
const source: FormatsLocaleSource = {
const source: FormatLocaleSource = {
getLocale: () => currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? '',
onLocaleChange: (fn) => {
const unsubscribe = options.localeSource?.onLocaleChange?.((locale) => {
@ -50,9 +50,9 @@ export function createActiveFormatsLocaleSource(
};
}
export function createActiveFormatsRuntime(
options: ActiveFormatsRuntimeOptions
): ActiveFormatsRuntime {
export function createActiveFormatRuntime(
options: ActiveFormatRuntimeOptions
): ActiveFormatRuntime {
let version = $state(0);
const changeListeners = new Set<() => void>();
const localeListeners = new Set<(locale: string) => void>();
@ -98,7 +98,7 @@ export function createActiveFormatsRuntime(
}
export function readFrom<TArgs extends unknown[], TResult>(
runtime: Pick<ActiveFormatsRuntime, 'read'>,
runtime: Pick<ActiveFormatRuntime, 'read'>,
fn: (...args: TArgs) => TResult
): (...args: TArgs) => TResult {
return (...args) => {
@ -108,7 +108,7 @@ export function readFrom<TArgs extends unknown[], TResult>(
}
export function writeTo<TArgs extends unknown[], TResult>(
runtime: Pick<ActiveFormatsRuntime, 'notifyChange'>,
runtime: Pick<ActiveFormatRuntime, 'notifyChange'>,
fn: (...args: TArgs) => TResult
): (...args: TArgs) => TResult {
return (...args) => {

@ -0,0 +1,3 @@
export const FORMAT_MODULE = 'format';
export const FORMAT_DEFAULT_LOCALE = 'en-US';
export const FORMAT_AUTO_VALUE = 'auto';

@ -1,4 +1,4 @@
import { createActiveFormatsRuntime, readFrom } from '../active-runtime.svelte';
import { createActiveFormatRuntime, readFrom } from '../active-runtime.svelte';
import { createEngineCurrency } from './engine-currency';
import type {
ActiveCurrency,
@ -28,7 +28,7 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ
if (before !== after) notifyCurrency(after);
}
const runtime = createActiveFormatsRuntime({
const runtime = createActiveFormatRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale(locale) {

@ -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';

@ -6,7 +6,7 @@ import {
type Diagnostics,
type Logger
} from '$libs/logger';
import { CURRENCY_DIAGNOSTIC_EVENTS, FORMATS_CURR_MODULE } from './consts';
import { CURRENCY_DIAGNOSTIC_EVENTS, FORMAT_CURR_MODULE } from './consts';
import { CURRENCY_ERROR_MESSAGES } from './errors';
import type { CurrencyCode } from './types';
@ -52,7 +52,7 @@ const CURRENCY_DIAGNOSTIC_LOGS: DiagnosticCatalog<CurrencyDiagnosticEvent> = {
export function createCurrencyDiagnostics(logger?: Logger): CurrencyDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: FORMATS_CURR_MODULE,
defaultCategory: FORMAT_CURR_MODULE,
catalog: CURRENCY_DIAGNOSTIC_LOGS
});
}
@ -63,7 +63,7 @@ export function emitCurrencyDiagnostic(
meta: CurrencyDiagnosticMeta
): void {
diagnostics.emit({
artifact: FORMATS_CURR_MODULE,
artifact: FORMAT_CURR_MODULE,
type,
meta
});

@ -6,7 +6,7 @@ import {
DEFAULT_CURRENCY_SIGN,
} from './consts';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import { createFormatLocaleState } from '../locale-state';
import { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics';
import { normalizeCurrencyCode, normalizeCurrencyMode } from './helpers';
import { resolveCurrency } from './locale-defaults';
@ -49,7 +49,7 @@ function getCurrencyFormatter(
}
export function createEngineCurrency(options: EngineCurrencyOptions = {}): EngineCurrency {
const { getLocale, setLocale } = createFormatsLocaleState(options.locale);
const { getLocale, setLocale } = createFormatLocaleState(options.locale);
const currencyState = createAutoState<CurrencyMode, CurrencyCode>({
initial: options.currency,
auto: AUTO_CURRENCY,

@ -3,7 +3,7 @@
// or locale currency tables they never reach.
export {
FORMATS_CURR_MODULE,
FORMAT_CURR_MODULE,
AUTO_CURRENCY,
CURRENCY_DIAGNOSTIC_EVENTS,
DEFAULT_CURRENCY,

@ -3,7 +3,7 @@ import {
AUTO_CURRENCY,
CURRENCY_ERROR_MESSAGES,
DEFAULT_CURRENCY,
FORMATS_CURR_MODULE,
FORMAT_CURR_MODULE,
createEngineCurrency,
createRates,
resolveCurrency
@ -14,7 +14,7 @@ describe('curr public barrel', () => {
expect(createEngineCurrency).toBeTypeOf('function');
expect(createRates).toBeTypeOf('function');
expect(resolveCurrency).toBeTypeOf('function');
expect(FORMATS_CURR_MODULE).toBe('formats.curr');
expect(FORMAT_CURR_MODULE).toBe('format.curr');
expect(AUTO_CURRENCY).toBe('auto');
expect(DEFAULT_CURRENCY).toBe('USD');
expect(CURRENCY_ERROR_MESSAGES.RATES_PROVIDER_MISSING).toContain('[fmts.curr]');

@ -3,7 +3,7 @@ import { createEngineLogger, LogLevel } from '$logger';
import {
AUTO_CURRENCY,
CURRENCY_ERROR_MESSAGES,
FORMATS_CURR_MODULE,
FORMAT_CURR_MODULE,
createEngineCurrency,
createRates,
type EngineCurrency
@ -79,7 +79,7 @@ describe('createEngineCurrency()', () => {
await expect(curr.convert(10, 'USD')).resolves.toBeUndefined();
const logs = logger.getLogs({ category: FORMATS_CURR_MODULE, level: LogLevel.DEBUG });
const logs = logger.getLogs({ category: FORMAT_CURR_MODULE, level: LogLevel.DEBUG });
expect(logs).toHaveLength(1);
expect(logs[0].message).toBe(CURRENCY_ERROR_MESSAGES.RATES_PROVIDER_MISSING);
});

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { createEngineLogger, LogLevel } from '$logger';
import { CURRENCY_ERROR_MESSAGES, FORMATS_CURR_MODULE, createRates } from '..';
import { CURRENCY_ERROR_MESSAGES, FORMAT_CURR_MODULE, createRates } from '..';
describe('createRates()', () => {
it('caches direct and inverse rates', async () => {
@ -73,7 +73,7 @@ describe('createRates()', () => {
await expect(rates.getRate('EUR', 'USD')).resolves.toBeUndefined();
await expect(rates.getRate('USD', 'EUR')).resolves.toBeUndefined();
expect(logger.getLogs({ category: FORMATS_CURR_MODULE, level: LogLevel.WARN })[0].message).toBe(
expect(logger.getLogs({ category: FORMAT_CURR_MODULE, level: LogLevel.WARN })[0].message).toBe(
CURRENCY_ERROR_MESSAGES.INVALID_RATE('EUR', 'USD', 0)
);
});

@ -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 {}

@ -1,4 +1,4 @@
import { createActiveFormatsRuntime, readFrom, writeTo } from '../active-runtime.svelte';
import { createActiveFormatRuntime, readFrom, writeTo } from '../active-runtime.svelte';
import { createEngineDates } from './engine-dates';
import type { ActiveDates, ActiveDatesOptions } from './types';
@ -9,7 +9,7 @@ export function createActiveDates(options: ActiveDatesOptions = {}): ActiveDates
locale: localeSource?.getLocale() ?? options.locale
});
const runtime = createActiveFormatsRuntime({
const runtime = createActiveFormatRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale: (locale) => engine.setLocale(locale)

@ -0,0 +1 @@
export const FORMAT_DATES_MODULE = 'format.dates';

@ -1,6 +1,6 @@
import { getCachedDateFormat, resolveDateOrder, resolveHourCycle } from '$libs/days';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import { createFormatLocaleState } from '../locale-state';
import type {
DateOrder,
DateOrderMode,
@ -23,7 +23,7 @@ function withHourCycle(
}
export function createEngineDates(options: EngineDatesOptions = {}): EngineDates {
const { getLocale, setLocale } = createFormatsLocaleState(options.locale);
const { getLocale, setLocale } = createFormatLocaleState(options.locale);
const dateOrder = createAutoState<DateOrderMode, DateOrder>({
initial: options.dateOrder,
auto: 'auto'

@ -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 {

@ -1,5 +1,5 @@
import type { DateOrder, HourCycle } from '$libs/days';
import type { FormatsLocaleInput, FormatsLocaleSource } from '../types';
import type { FormatLocaleInput, FormatLocaleSource } from '../types';
export type { DateOrder, HourCycle };
@ -9,7 +9,7 @@ export type HourCycleMode = 'auto' | HourCycle;
export interface DatesFormatOptions extends Intl.DateTimeFormatOptions {}
export interface EngineDatesOptions {
locale?: FormatsLocaleInput;
locale?: FormatLocaleInput;
dateOrder?: DateOrderMode;
hourCycle?: HourCycleMode;
}
@ -32,7 +32,7 @@ export interface EngineDates {
export interface ActiveDatesOptions extends Omit<EngineDatesOptions, 'locale'> {
locale?: string;
localeSource?: FormatsLocaleSource;
localeSource?: FormatLocaleSource;
}
export interface ActiveDates extends EngineDates {

@ -2,22 +2,22 @@ import { createEngineCurrency } from './currency';
import { createEngineDates } from './dates';
import { createEngineNumbers } from './numbers';
import { createEngineUnits } from './units';
import { createFormatsLocaleState } from './locale-state';
import { createFormatLocaleState } from './locale-state';
import type { EngineCurrency, EngineCurrencyOptions } from './currency';
import type { EngineDates, EngineDatesOptions } from './dates';
import type { EngineNumbers, EngineNumbersOptions } from './numbers';
import type { EngineUnits, EngineUnitsOptions } from './units';
import type { FormatsLocaleInput } from './types';
import type { FormatLocaleInput } from './types';
export interface EngineFormatsOptions {
locale?: FormatsLocaleInput;
export interface EngineFormatOptions {
locale?: FormatLocaleInput;
numbers?: Omit<EngineNumbersOptions, 'locale'>;
currency?: Omit<EngineCurrencyOptions, 'locale' | 'numbers'>;
units?: Omit<EngineUnitsOptions, 'locale' | 'numbers'>;
dates?: Omit<EngineDatesOptions, 'locale'>;
}
export interface EngineFormats {
export interface EngineFormat {
readonly numbers: EngineNumbers;
readonly currency: EngineCurrency;
readonly units: EngineUnits;
@ -31,8 +31,8 @@ export interface EngineFormats {
dispose: () => void;
}
export function createEngineFormats(options: EngineFormatsOptions = {}): EngineFormats {
const localeState = createFormatsLocaleState(options.locale);
export function createEngineFormat(options: EngineFormatOptions = {}): EngineFormat {
const localeState = createFormatLocaleState(options.locale);
const locale = localeState.getLocale;
const numbers = createEngineNumbers({ ...options.numbers, locale });

@ -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;

@ -1,5 +1,5 @@
import { FORMATS_DEFAULT_LOCALE } from './consts';
import type { FormatsLocaleInput } from './types';
import { FORMAT_DEFAULT_LOCALE } from './consts';
import type { FormatLocaleInput } from './types';
const REGION_CODE_PATTERN = /^[A-Z]{2}$|^\d{3}$/;
@ -19,12 +19,12 @@ export function getLocaleRegion(locale: string): string | undefined {
return undefined;
}
export function resolveLocaleInput(input?: FormatsLocaleInput): {
export function resolveLocaleInput(input?: FormatLocaleInput): {
getter?: () => string;
value: string;
} {
if (typeof input === 'function') {
return { getter: input, value: FORMATS_DEFAULT_LOCALE };
return { getter: input, value: FORMAT_DEFAULT_LOCALE };
}
return { value: normalizeLocaleTag(input ?? FORMATS_DEFAULT_LOCALE) || FORMATS_DEFAULT_LOCALE };
return { value: normalizeLocaleTag(input ?? FORMAT_DEFAULT_LOCALE) || FORMAT_DEFAULT_LOCALE };
}

@ -1,18 +1,18 @@
// fmts public surface. Named re-exports so the bundler can drop sub-formats
// the consumer never reaches. The four sub-artifacts (`currency`, `dates`,
// `numbers`, `units`) are also exposed as namespaces for the
// `App.Formats.currency.format(...)` style; with `sideEffects: false` in
// `App.Format.currency.format(...)` style; with `sideEffects: false` in
// package.json the namespace pattern still tree-shakes member access.
export { FORMATS_MODULE, FORMATS_DEFAULT_LOCALE, FORMATS_AUTO_VALUE } from './consts';
export { createEngineFormats } from './engine-formats';
export { createActiveFormats } from './active-formats.svelte';
export { FORMATS_ERROR_MESSAGES } from './errors';
export { FORMAT_MODULE, FORMAT_DEFAULT_LOCALE, FORMAT_AUTO_VALUE } from './consts';
export { createEngineFormat } from './engine-formats';
export { createActiveFormat } from './active-formats.svelte';
export { FORMAT_ERROR_MESSAGES } from './errors';
export { normalizeLocaleTag, getLocaleRegion, resolveLocaleInput } from './helpers';
export type { EngineFormatsOptions, EngineFormats } from './engine-formats';
export type { ActiveFormatsOptions, ActiveFormats } from './active-formats.svelte';
export type { FormatsLocaleInput, FormatsLocaleSource, FormatsDisposable } from './types';
export type { EngineFormatOptions, EngineFormat } from './engine-formats';
export type { ActiveFormatOptions, ActiveFormat } from './active-formats.svelte';
export type { FormatLocaleInput, FormatLocaleSource, FormatDisposable } from './types';
// Per-domain factories — direct named imports so consumers can pull only
// what they use.
@ -21,7 +21,7 @@ export { createActiveDates, createEngineDates } from './dates';
export { createActiveNumbers, createEngineNumbers } from './numbers';
export { createActiveUnits, createEngineUnits } from './units';
// Per-domain namespaces — for `import { currency } from '$formats'` ergonomics.
// Per-domain namespaces — for `import { currency } from '$format'` ergonomics.
export * as currency from './currency';
export * as dates from './dates';
export * as numbers from './numbers';

@ -1,13 +1,13 @@
import { FORMATS_DEFAULT_LOCALE } from './consts';
import { FORMAT_DEFAULT_LOCALE } from './consts';
import { normalizeLocaleTag, resolveLocaleInput } from './helpers';
import type { FormatsLocaleInput } from './types';
import type { FormatLocaleInput } from './types';
export interface FormatsLocaleState {
export interface FormatLocaleState {
getLocale: () => string;
setLocale: (locale: string) => void;
}
export function createFormatsLocaleState(input?: FormatsLocaleInput): FormatsLocaleState {
export function createFormatLocaleState(input?: FormatLocaleInput): FormatLocaleState {
const localeInput = resolveLocaleInput(input);
let localeGetter = localeInput.getter;
let currentLocale = localeInput.value;
@ -15,12 +15,12 @@ export function createFormatsLocaleState(input?: FormatsLocaleInput): FormatsLoc
return {
getLocale() {
const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale);
return locale || FORMATS_DEFAULT_LOCALE;
return locale || FORMAT_DEFAULT_LOCALE;
},
setLocale(locale) {
localeGetter = undefined;
currentLocale = normalizeLocaleTag(locale) || FORMATS_DEFAULT_LOCALE;
currentLocale = normalizeLocaleTag(locale) || FORMAT_DEFAULT_LOCALE;
}
};
}

@ -1,4 +1,4 @@
import { createActiveFormatsRuntime, readFrom, writeTo } from '../active-runtime.svelte';
import { createActiveFormatRuntime, readFrom, writeTo } from '../active-runtime.svelte';
import { createEngineNumbers } from './engine-numbers';
import type { ActiveNumbers, ActiveNumbersOptions } from './types';
@ -9,7 +9,7 @@ export function createActiveNumbers(options: ActiveNumbersOptions = {}): ActiveN
locale: localeSource?.getLocale() ?? options.locale
});
const runtime = createActiveFormatsRuntime({
const runtime = createActiveFormatRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale: (locale) => engine.setLocale(locale)

@ -0,0 +1 @@
export const FORMAT_NUMS_MODULE = 'format.nums';

@ -1,6 +1,6 @@
import { FORMATS_AUTO_VALUE } from '../consts';
import { FORMAT_AUTO_VALUE } from '../consts';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import { createFormatLocaleState } from '../locale-state';
import {
getCachedNumberFormat,
getNumberFormatPart,
@ -18,18 +18,18 @@ import type {
} from './types';
export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineNumbers {
const { getLocale, setLocale } = createFormatsLocaleState(options.locale);
const { getLocale, setLocale } = createFormatLocaleState(options.locale);
const decimalSeparator = createAutoState<NumbersSeparatorMode, string>({
initial: options.decimalSeparator,
auto: FORMATS_AUTO_VALUE
auto: FORMAT_AUTO_VALUE
});
const groupSeparator = createAutoState<NumbersSeparatorMode, string>({
initial: options.groupSeparator,
auto: FORMATS_AUTO_VALUE
auto: FORMAT_AUTO_VALUE
});
const grouping = createAutoState<NumbersGroupingMode, boolean>({
initial: options.grouping,
auto: FORMATS_AUTO_VALUE
auto: FORMAT_AUTO_VALUE
});
const defaultFormat = options.format ?? {};

@ -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,7 +1,7 @@
import type { FORMATS_AUTO_VALUE } from '../consts';
import type { FormatsLocaleInput, FormatsLocaleSource } from '../types';
import type { FORMAT_AUTO_VALUE } from '../consts';
import type { FormatLocaleInput, FormatLocaleSource } from '../types';
export type NumbersAuto = typeof FORMATS_AUTO_VALUE;
export type NumbersAuto = typeof FORMAT_AUTO_VALUE;
export type NumbersSeparatorMode = NumbersAuto | string;
export type NumbersGroupingMode = NumbersAuto | boolean;
@ -23,7 +23,7 @@ export interface NumbersUnitFormatOptions extends Omit<NumbersFormatOptions, 'st
}
export interface EngineNumbersOptions {
locale?: FormatsLocaleInput;
locale?: FormatLocaleInput;
decimalSeparator?: NumbersSeparatorMode;
groupSeparator?: NumbersSeparatorMode;
grouping?: NumbersGroupingMode;
@ -59,7 +59,7 @@ export interface EngineNumbers {
export interface ActiveNumbersOptions extends Omit<EngineNumbersOptions, 'locale'> {
locale?: string;
localeSource?: FormatsLocaleSource;
localeSource?: FormatLocaleSource;
}
export interface ActiveNumbers extends EngineNumbers {

@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { createActiveFormats, createEngineFormats } from '..';
import { createActiveFormat, createEngineFormat } from '..';
describe('createEngineFormats()', () => {
describe('createEngineFormat()', () => {
it('composes number, currency, units and dates format engines', async () => {
const formats = createEngineFormats({
const formats = createEngineFormat({
locale: 'en-US',
currency: {
rates: undefined
@ -27,9 +27,9 @@ describe('createEngineFormats()', () => {
});
});
describe('createActiveFormats()', () => {
describe('createActiveFormat()', () => {
it('fans out locale changes to every active format module', () => {
const formats = createActiveFormats({ locale: 'en-US' });
const formats = createActiveFormat({ locale: 'en-US' });
expect(formats.getLocale()).toBe('en-US');
expect(formats.currency.getCurrency()).toBe('USD');

@ -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;
}

@ -1,4 +1,4 @@
import { createActiveFormatsRuntime, readFrom, writeTo } from '../active-runtime.svelte';
import { createActiveFormatRuntime, readFrom, writeTo } from '../active-runtime.svelte';
import { createEngineUnits } from './engine-units';
import type { ActiveUnits, ActiveUnitsOptions } from './types';
@ -9,7 +9,7 @@ export function createActiveUnits(options: ActiveUnitsOptions = {}): ActiveUnits
locale: localeSource?.getLocale() ?? options.locale
});
const runtime = createActiveFormatsRuntime({
const runtime = createActiveFormatRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale: (locale) => engine.setLocale(locale)

@ -0,0 +1,3 @@
export const FORMAT_UNTS_MODULE = 'format.unts';
export const AUTO_UNIT_SYSTEM = 'auto';

@ -5,7 +5,7 @@ import { UnitsUnknownUnitError } from './errors';
import { resolveUnitSystem } from './locale-defaults';
import { UNIT_DEFINITIONS } from './unit-definitions';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import { createFormatLocaleState } from '../locale-state';
import type {
EngineUnits,
EngineUnitsOptions,
@ -16,7 +16,7 @@ import type {
} from './types';
export function createEngineUnits(options: EngineUnitsOptions = {}): EngineUnits {
const { getLocale, setLocale } = createFormatsLocaleState(options.locale);
const { getLocale, setLocale } = createFormatLocaleState(options.locale);
const unitSystem = createAutoState<UnitSystemMode, UnitSystem>({
initial: options.system,
auto: AUTO_UNIT_SYSTEM

@ -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,5 +1,5 @@
import type { AUTO_UNIT_SYSTEM } from './consts';
import type { FormatsLocaleInput, FormatsLocaleSource } from '../types';
import type { FormatLocaleInput, FormatLocaleSource } from '../types';
import type { EngineNumbers, NumbersUnitFormatOptions } from '../numbers';
export type UnitSystem = 'metric' | 'imperial';
@ -16,7 +16,7 @@ export interface UnitDefinition {
}
export interface EngineUnitsOptions {
locale?: FormatsLocaleInput;
locale?: FormatLocaleInput;
system?: UnitSystemMode;
numbers?: Pick<EngineNumbers, 'formatUnit'>;
}
@ -38,7 +38,7 @@ export interface EngineUnits {
export interface ActiveUnitsOptions extends Omit<EngineUnitsOptions, 'locale'> {
locale?: string;
localeSource?: FormatsLocaleSource;
localeSource?: FormatLocaleSource;
}
export interface ActiveUnits extends EngineUnits {

@ -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';

@ -67,7 +67,7 @@ App.Frontend.setTheme('forest');
```
La regla importante es que `Lang` sigue siendo la fuente unica de locale. Si
`App.setLocale(...)` cambia el idioma, `Formats` y `Frontend` reaccionan desde
`App.setLocale(...)` cambia el idioma, `Format` y `Frontend` reaccionan desde
la misma fuente, sin duplicar estado.
## Contrato Auto / Manual

@ -362,7 +362,7 @@ lang.ts('plain text'); // 'plain text'
lang.ts({ es: 'Hola', en: 'Hello' }); // 'Hola' (first non-empty entry)
```
The locale is held as `$state` so consumers (Formats, Frontend) that
The locale is held as `$state` so consumers (Format, Frontend) that
subscribe via `onLocaleChange` still react to `setLocale`.
In DEV the mono lang warns once per unresolved path via the supplied logger

@ -24,7 +24,7 @@ export { LANG_MONO_LANG_CATEGORY } from '$libs/lang';
* Default initial locale for `createActiveMonoLang()`. The mono lang does not
* translate, so the value is mostly cosmetic — it determines what
* `getLocale()` returns until `setLocale()` is called and what consumers
* (Formats, Frontend) see when they subscribe to `onLocaleChange`.
* (Format, Frontend) see when they subscribe to `onLocaleChange`.
*/
export const DEFAULT_MONO_LOCALE: SupportedLocale = 'en';
@ -43,7 +43,7 @@ export const DEFAULT_MONO_LOCALE: SupportedLocale = 'en';
* - `ts('#?common.ok|Save')` → `'Save'`.
* - `ts({ es: 'Hola', en: 'Hello' })` → first non-empty entry.
*
* The locale is held as `$state` so `Formats` and `Frontend`, which subscribe
* The locale is held as `$state` so `Format` and `Frontend`, which subscribe
* to `onLocaleChange`, still see updates from `setLocale`.
*
* In DEV the mono lang warns (deduplicated by path, once per path) when

@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ResourceRef } from '$libs/permissions';
import { getPermissionsContext } from './context.ts';
import type { ResourceRef } from '$libs/perm';
import { getPermsContext } from './context.ts';
interface Props {
action: string;
@ -23,7 +23,7 @@
optimistic = true
}: Props = $props();
const permissions = getPermissionsContext();
const permissions = getPermsContext();
let allowed = $state(false);
let pending = $state(true);

@ -13,10 +13,10 @@ The most important rule is:
> The server decides. The client reflects.
Use `createEnginePermissions()` from `$svrs/perm` in the authoritative runtime:
Use `createEnginePerms()` from `$svrs/perm` in the authoritative runtime:
server routes, server actions, API handlers, command handlers, job processors.
Use `createActivePermissions()` in Svelte/UI code only to improve UX: hide buttons, show
Use `createActivePerms()` in Svelte/UI code only to improve UX: hide buttons, show
disabled states, hydrate snapshots, cache remote checks and render `<Can />`.
Client-side authorization is never a security boundary.
@ -46,10 +46,10 @@ Real applications need all of them, often in the same decision.
## Public Surface
```ts
import { createEnginePermissions, createPermissionHttpHandlers } from '$svrs/perm';
import { createEnginePerms, createPermHttpHandlers } from '$svrs/perm';
import {
createActivePermissions,
createActivePerms,
definePermSchema,
definePolicies,
allow,
@ -72,11 +72,11 @@ import {
Main APIs:
- `createEnginePermissions(options)` creates the authoritative engine from `$svrs/perm`.
- `createActivePermissions(options)` creates a reactive client-side reflector from `$perm`.
- `App.createActivePermissions(options)` creates an App-wired active client with `App.Http` and `App.Logger`.
- `createPermissionHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain` from `$svrs/perm`.
- `<Can />` renders UI based on `Permissions.can(...)`.
- `createEnginePerms(options)` creates the authoritative engine from `$svrs/perm`.
- `createActivePerms(options)` creates a reactive client-side reflector from `$perm`.
- `App.createActivePerms(options)` creates an App-wired active client with `App.Http` and `App.Logger`.
- `createPermHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain` from `$svrs/perm`.
- `<Can />` renders UI based on `Perms.can(...)`.
## Core Concepts
@ -171,7 +171,7 @@ Typical context values:
`perm` does not return plain booleans from the engine. `check()` returns a decision:
```ts
type PermissionDecision =
type PermDecision =
| {
effect: 'allow';
policy: string;
@ -201,7 +201,7 @@ Decision meaning:
`can()` is sugar over `check()`:
```ts
await Permissions.can(input); // true only when effect === 'allow'
await Perms.can(input); // true only when effect === 'allow'
```
Everything else is false.
@ -270,10 +270,10 @@ production persistence:
```ts
import {
PERMISSION_SQL_SCHEMA_MODEL,
type PermissionDecisionAuditDbRow,
type PermissionPolicyDbRow,
type PermissionRelationDbRow
PERM_SQL_SCHEMA_MODEL,
type PermDecisionAuditDbRow,
type PermPolicyDbRow,
type PermRelationDbRow
} from '$svrs/perm';
```
@ -299,7 +299,7 @@ This is a support model, not an automatic adapter. The engine still receives:
Static applications can keep policies in TypeScript. Dynamic or multi-tenant applications can store
the produced `PolicyIR` JSON in `permission_policies`, validate it while loading, and pass it to
`createEnginePermissions()`.
`createEnginePerms()`.
Example loader:
@ -309,10 +309,10 @@ const rows = await db.permissionPolicy.findMany({
orderBy: [{ version: 'desc' }, { id: 'asc' }]
});
const Permissions = createEnginePermissions({
const Perms = createEnginePerms({
schema,
policies: rows.map((row) => row.policy),
providers: createPermissionProviders(db),
providers: createPermProviders(db),
compilers: [createSqlCompiler({ relation: compileRelationForSql })]
});
```
@ -392,7 +392,7 @@ Policies should stay declarative. Providers resolve data that is not already pre
Use a relation provider for ownership, membership and graph-like checks.
```ts
const Permissions = createEnginePermissions({
const Perms = createEnginePerms({
schema,
policies,
providers: {
@ -444,9 +444,9 @@ actor, resource or context.
Create the server-side runtime:
```ts
import { createEnginePermissions } from '$svrs/perm';
import { createEnginePerms } from '$svrs/perm';
export const Permissions = createEnginePermissions({
export const Perms = createEnginePerms({
schema,
policies,
providers,
@ -457,7 +457,7 @@ export const Permissions = createEnginePermissions({
Check a permission:
```ts
const decision = await Permissions.check({
const decision = await Perms.check({
actor,
action: 'post.update',
resource: post,
@ -472,19 +472,19 @@ if (decision.effect !== 'allow') {
Assert a permission:
```ts
await Permissions.assert({
await Perms.assert({
actor,
action: 'post.delete',
resource: post
});
```
`assert()` throws `PermissionDeniedError` for every non-allow decision.
`assert()` throws `PermDeniedError` for every non-allow decision.
Use `can()` only when a boolean is enough:
```ts
if (await Permissions.can({ actor, action: 'post.read', resource: post })) {
if (await Perms.can({ actor, action: 'post.read', resource: post })) {
return post;
}
```
@ -498,7 +498,7 @@ export async function updatePost(event) {
const actor = await resolveActor(event);
const post = await loadPost(event.params.id);
await Permissions.assert({
await Perms.assert({
actor,
action: 'post.update',
resource: post,
@ -509,17 +509,17 @@ export async function updatePost(event) {
}
```
Do not rely on `<Can />` or `ActivePermissions.can()` for this.
Do not rely on `<Can />` or `ActivePerms.can()` for this.
## HTTP Handlers
The active client talks to HTTP handlers.
```ts
import { createPermissionHttpHandlers } from '$svrs/perm';
import { Permissions } from '$lib/server/permissions';
import { createPermHttpHandlers } from '$svrs/perm';
import { Perms } from '$lib/server/permissions';
const handlers = createPermissionHttpHandlers(Permissions, async (request) => {
const handlers = createPermHttpHandlers(Perms, async (request) => {
const session = await readSession(request);
return {
type: 'user',
@ -561,7 +561,7 @@ return json(await handlers.explain(request));
Create the client directly:
```ts
const Permissions = createActivePermissions({
const Perms = createActivePerms({
endpoint: '/api/permissions',
cacheTtlMs: 30_000
});
@ -577,10 +577,10 @@ const App = createActiveApp({
}
});
const Permissions = App.createActivePermissions();
const Perms = App.createActivePerms();
```
`App.createActivePermissions()` injects:
`App.createActivePerms()` injects:
- `App.Http`
- `App.Logger`
@ -591,26 +591,26 @@ The endpoint remains explicit because the client is remote by design.
Active client API:
```ts
await Permissions.check({ action, resource, context });
await Permissions.can({ action, resource, context });
await Permissions.batch({ checks });
await Permissions.what({ resource, actions, context });
await Permissions.explain({ action, resource, context });
Permissions.hydrate(snapshot);
Permissions.snapshot();
Permissions.invalidate();
Permissions.onChange((snapshot) => {});
await Perms.check({ action, resource, context });
await Perms.can({ action, resource, context });
await Perms.batch({ checks });
await Perms.what({ resource, actions, context });
await Perms.explain({ action, resource, context });
Perms.hydrate(snapshot);
Perms.snapshot();
Perms.invalidate();
Perms.onChange((snapshot) => {});
```
Reactive fields:
```ts
Permissions.currentSnapshot;
Permissions.decisions;
Permissions.size;
Permissions.loading;
Permissions.lastError;
Perms.currentSnapshot;
Perms.decisions;
Perms.size;
Perms.loading;
Perms.lastError;
```
## Snapshots And Cache
@ -618,7 +618,7 @@ Permissions.lastError;
The active client has a small decision cache.
```ts
const Permissions = createActivePermissions({
const Perms = createActivePerms({
endpoint: '/api/permissions',
initialSnapshot,
cacheTtlMs: 10_000,
@ -632,11 +632,11 @@ const Permissions = createActivePermissions({
Snapshot shape:
```ts
interface PermissionSnapshot {
interface PermSnapshot {
actor?: SubjectRef;
version?: string;
decisions?: Record<string, PermissionDecision>;
global?: Record<string, boolean | PermissionDecision>;
decisions?: Record<string, PermDecision>;
global?: Record<string, boolean | PermDecision>;
expiresAt?: string;
}
```
@ -657,14 +657,14 @@ Important:
## App.Bus Auto Invalidation
`ActivePermissions` can invalidate its local decision cache from public app
`ActivePerms` can invalidate its local decision cache from public app
events. App injects `App.Bus` when you create permissions through
`App.createActivePermissions(...)`.
`App.createActivePerms(...)`.
Default is safe:
```ts
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: '/api/permissions'
});
// No automatic invalidation unless autoInvalidateOn is configured.
@ -673,7 +673,7 @@ const Permissions = App.createActivePermissions({
Opt in:
```ts
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: '/api/permissions',
autoInvalidateOn: 'standard'
});
@ -683,14 +683,14 @@ const Permissions = App.createActivePermissions({
| App event | Effect |
| --- | --- |
| `APP_EVENT_USER_IDENTITY_CHANGED` | `Permissions.invalidate()` |
| `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | `Permissions.invalidate()` |
| `APP_EVENT_TENANT_SWITCHED` | `Permissions.invalidate()` |
| `APP_EVENT_USER_IDENTITY_CHANGED` | `Perms.invalidate()` |
| `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | `Perms.invalidate()` |
| `APP_EVENT_TENANT_SWITCHED` | `Perms.invalidate()` |
Fine-grained form:
```ts
const Permissions = App.createActivePermissions({
const Perms = App.createActivePerms({
endpoint: '/api/permissions',
autoInvalidateOn: ['userIdentityChange', 'permissionsRefresh']
});
@ -704,15 +704,15 @@ is still scoped by `scopeKey` and explicit `invalidate()` calls.
## The `<Can />` Component
`<Can />` is a small Svelte component that renders its children only when
`Permissions.can(...)` returns `true`.
`Perms.can(...)` returns `true`.
It reads the active client from Svelte context:
```ts
import { setPermissionsContext } from '$perm';
import { setPermsContext } from '$perm';
const Permissions = App.createActivePermissions();
setPermissionsContext(Permissions);
const Perms = App.createActivePerms();
setPermsContext(Perms);
```
Basic usage:
@ -752,7 +752,7 @@ Again: `<Can />` is only UI. It prevents confusing affordances; it does not prot
Use `what()` when a view needs the full action matrix for one resource.
```ts
const actions = await Permissions.what({
const actions = await Perms.what({
actor,
resource: post
});
@ -765,7 +765,7 @@ actions['post.delete'];
Client-side:
```ts
const actions = await Permissions.what({
const actions = await Perms.what({
resource: post,
actions: ['post.read', 'post.update']
});
@ -778,7 +778,7 @@ This is better than firing many separate checks for a toolbar or detail page.
Use `explain()` for debugging, audit panels and tests.
```ts
const result = await Permissions.explain({
const result = await Perms.explain({
actor,
action: 'post.publish',
resource: post,
@ -806,7 +806,7 @@ Use `filter(action)` to turn authorization into a list query.
In-memory predicate:
```ts
const canRead = await Permissions.filter('post.read').for(actor).resource('post').toPredicate();
const canRead = await Perms.filter('post.read').for(actor).resource('post').toPredicate();
const visible = [];
for (const post of posts) {
@ -817,7 +817,7 @@ for (const post of posts) {
Query plan:
```ts
const plan = await Permissions.filter('post.read')
const plan = await Perms.filter('post.read')
.for(actor)
.resource('post')
.context({ tenant: 'acme' })
@ -827,7 +827,7 @@ const plan = await Permissions.filter('post.read')
SQL compiler:
```ts
const Permissions = createEnginePermissions({
const Perms = createEnginePerms({
schema,
policies,
compilers: [
@ -875,7 +875,7 @@ Obligations are returned in the decision. It is the caller's job to enforce them
Example:
```ts
const decision = await Permissions.check({ actor, action: 'post.read', resource: post });
const decision = await Perms.check({ actor, action: 'post.read', resource: post });
if (decision.effect === 'allow') {
return applyObligations(post, decision.obligations);
@ -888,7 +888,7 @@ Advice is similar but non-mandatory.
### aapp
`App.createActivePermissions()` builds the UI client and injects `App.Http`,
`App.createActivePerms()` builds the UI client and injects `App.Http`,
`App.Logger` and `App.Bus`.
```ts
@ -899,10 +899,10 @@ const App = createActiveApp({
}
});
const Permissions = App.createActivePermissions();
const Perms = App.createActivePerms();
```
`App.Permissions` is `undefined` until `createActivePermissions()` is called.
`App.Perms` is `undefined` until `createActivePerms()` is called.
### sess
@ -912,7 +912,7 @@ Typical flow:
```ts
const actor = actorFromSession(App.Sess.current);
await Permissions.assert({ actor, action, resource });
await Perms.assert({ actor, action, resource });
```
On the server, resolve the actor from server-side session state, not from a client payload.
@ -928,7 +928,7 @@ events. A client-side `<Can />` around a chat button is UX only.
### logr
`createEnginePermissions({ logger })` emits structured logs under category `'perm'` for decisions,
`createEnginePerms({ logger })` emits structured logs under category `'perm'` for decisions,
denials and indeterminate decisions.
## Testing
@ -937,7 +937,7 @@ Unit-test policies as data.
```ts
it('denies suspended users', async () => {
const decision = await Permissions.check({
const decision = await Perms.check({
actor: { type: 'user', id: 'u1', status: 'suspended' },
action: 'post.read',
resource: { type: 'post', id: 'p1', visibility: 'public' }
@ -964,8 +964,8 @@ There is an interactive page at:
It exercises:
- `createEnginePermissions()`
- `App.createActivePermissions()`
- `createEnginePerms()`
- `App.createActivePerms()`
- HTTP handlers
- client cache
- `<Can />`
@ -977,7 +977,7 @@ It exercises:
## Security Checklist
- Always enforce permissions on the server.
- Treat `ActivePermissions` and `<Can />` as UI helpers only.
- Treat `ActivePerms` and `<Can />` as UI helpers only.
- Never trust actor data sent by the browser.
- Prefer stable policy ids.
- Prefer constants for shared actions, relation names and policy ids.
@ -996,7 +996,7 @@ import {
allow,
and,
attr,
createEnginePermissions,
createEnginePerms,
definePermSchema,
definePolicies,
deny,
@ -1028,7 +1028,7 @@ const policies = definePolicies(schema, [
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived')))
]);
export const Permissions = createEnginePermissions({
export const Perms = createEnginePerms({
schema,
policies,
providers: {
@ -1045,7 +1045,7 @@ export const Permissions = createEnginePermissions({
Usage:
```ts
await Permissions.assert({
await Perms.assert({
actor: { type: 'user', id: 'u1', status: 'active' },
action: 'post.update',
resource: {

@ -4,42 +4,42 @@ import {
APP_EVENT_TENANT_SWITCHED,
APP_EVENT_USER_IDENTITY_CHANGED
} from '$libs/active-app/events';
import { createPermissionClient } from './client.ts';
import { createPermClient } from './client.ts';
import {
PERMISSION_AUTO_INVALIDATE_NONE,
PERMISSION_AUTO_INVALIDATE_PERMISSIONS_REFRESH,
PERMISSION_AUTO_INVALIDATE_STANDARD,
PERMISSION_AUTO_INVALIDATE_TENANT_SWITCHED,
PERMISSION_AUTO_INVALIDATE_USER_IDENTITY_CHANGE,
PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED,
PERMISSION_METHOD_BATCH,
PERMISSION_METHOD_CAN,
PERMISSION_METHOD_CHECK,
PERMISSION_METHOD_CLEAR_ERROR,
PERMISSION_METHOD_DECISION_KEY,
PERMISSION_METHOD_EXPLAIN,
PERMISSION_METHOD_HYDRATE,
PERMISSION_METHOD_INVALIDATE,
PERMISSION_METHOD_SUBSCRIBE,
PERMISSION_METHOD_WHAT
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_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED,
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
} from './consts.ts';
import { PermDisposedError, PermInvalidEndpointError } from './errors.ts';
import { disposedPermissionsMessage } from './helpers.ts';
import { disposedPermsMessage } from './helpers.ts';
import type {
ActivePermissionError,
ActivePermissions,
ActivePermissionsOptions,
PermissionSnapshot
ActivePermError,
ActivePerms,
ActivePermsOptions,
PermSnapshot
} from './types.ts';
export function createActivePermissions(options: ActivePermissionsOptions): ActivePermissions {
export function createActivePerms(options: ActivePermsOptions): ActivePerms {
if (!options.endpoint)
throw new PermInvalidEndpointError(PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED);
throw new PermInvalidEndpointError(PERM_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED);
const client = createPermissionClient(options);
let snapshotCell = $state<PermissionSnapshot>(client.snapshot());
const client = createPermClient(options);
let snapshotCell = $state<PermSnapshot>(client.snapshot());
let loadingCount = $state(0);
let lastErrorCell = $state<ActivePermissionError | null>(null);
let lastErrorCell = $state<ActivePermError | null>(null);
let disposed = false;
const off = client.subscribe((snapshot) => {
@ -58,7 +58,7 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti
lastErrorCell = null;
return value;
} catch (error) {
lastErrorCell = normalizeActivePermissionError(error);
lastErrorCell = normalizeActivePermError(error);
throw error;
} finally {
updateLoading(-1);
@ -66,7 +66,7 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti
}
function ensureLive(method: string): void {
if (disposed) throw new PermDisposedError(disposedPermissionsMessage(method));
if (disposed) throw new PermDisposedError(disposedPermsMessage(method));
}
return {
@ -89,44 +89,44 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti
return disposed;
},
check: (input) => {
ensureLive(PERMISSION_METHOD_CHECK);
ensureLive(PERM_METHOD_CHECK);
return track(() => client.check(input));
},
can: (input) => {
ensureLive(PERMISSION_METHOD_CAN);
ensureLive(PERM_METHOD_CAN);
return track(() => client.can(input));
},
batch: (input) => {
ensureLive(PERMISSION_METHOD_BATCH);
ensureLive(PERM_METHOD_BATCH);
return track(() => client.batch(input));
},
what: (input) => {
ensureLive(PERMISSION_METHOD_WHAT);
ensureLive(PERM_METHOD_WHAT);
return track(() => client.what(input));
},
explain: (input) => {
ensureLive(PERMISSION_METHOD_EXPLAIN);
ensureLive(PERM_METHOD_EXPLAIN);
return track(() => client.explain(input));
},
hydrate(snapshot) {
ensureLive(PERMISSION_METHOD_HYDRATE);
ensureLive(PERM_METHOD_HYDRATE);
client.hydrate(snapshot);
},
snapshot: () => client.snapshot(),
invalidate(scope) {
ensureLive(PERMISSION_METHOD_INVALIDATE);
ensureLive(PERM_METHOD_INVALIDATE);
client.invalidate(scope);
},
onChange(listener) {
ensureLive(PERMISSION_METHOD_SUBSCRIBE);
ensureLive(PERM_METHOD_SUBSCRIBE);
return client.subscribe(listener);
},
decisionKey: (input) => {
ensureLive(PERMISSION_METHOD_DECISION_KEY);
ensureLive(PERM_METHOD_DECISION_KEY);
return client.decisionKey(input);
},
clearError() {
ensureLive(PERMISSION_METHOD_CLEAR_ERROR);
ensureLive(PERM_METHOD_CLEAR_ERROR);
lastErrorCell = null;
},
dispose() {
@ -143,21 +143,21 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti
if (bus === undefined) return [];
const targets = resolveAutoInvalidateTargets(options.autoInvalidateOn);
const subscriptions: BusSubscription[] = [];
if (targets.has(PERMISSION_AUTO_INVALIDATE_USER_IDENTITY_CHANGE)) {
if (targets.has(PERM_AUTO_INVALIDATE_USER_IDENTITY_CHANGE)) {
subscriptions.push(
bus.on(APP_EVENT_USER_IDENTITY_CHANGED, () => {
if (!disposed) client.invalidate();
})
);
}
if (targets.has(PERMISSION_AUTO_INVALIDATE_PERMISSIONS_REFRESH)) {
if (targets.has(PERM_AUTO_INVALIDATE_PERMISSIONS_REFRESH)) {
subscriptions.push(
bus.on(APP_EVENT_PERMISSIONS_REFRESH_REQUESTED, () => {
if (!disposed) client.invalidate();
})
);
}
if (targets.has(PERMISSION_AUTO_INVALIDATE_TENANT_SWITCHED)) {
if (targets.has(PERM_AUTO_INVALIDATE_TENANT_SWITCHED)) {
subscriptions.push(
bus.on(APP_EVENT_TENANT_SWITCHED, () => {
if (!disposed) client.invalidate();
@ -168,24 +168,24 @@ export function createActivePermissions(options: ActivePermissionsOptions): Acti
}
}
function normalizeActivePermissionError(error: unknown): ActivePermissionError {
function normalizeActivePermError(error: unknown): ActivePermError {
return error instanceof Error ? error : new Error(String(error));
}
function resolveAutoInvalidateTargets(
autoInvalidateOn: ActivePermissionsOptions['autoInvalidateOn']
autoInvalidateOn: ActivePermsOptions['autoInvalidateOn']
): ReadonlySet<string> {
if (
autoInvalidateOn === undefined ||
autoInvalidateOn === PERMISSION_AUTO_INVALIDATE_NONE
autoInvalidateOn === PERM_AUTO_INVALIDATE_NONE
) {
return new Set();
}
if (autoInvalidateOn === PERMISSION_AUTO_INVALIDATE_STANDARD) {
if (autoInvalidateOn === PERM_AUTO_INVALIDATE_STANDARD) {
return new Set([
PERMISSION_AUTO_INVALIDATE_USER_IDENTITY_CHANGE,
PERMISSION_AUTO_INVALIDATE_PERMISSIONS_REFRESH,
PERMISSION_AUTO_INVALIDATE_TENANT_SWITCHED
PERM_AUTO_INVALIDATE_USER_IDENTITY_CHANGE,
PERM_AUTO_INVALIDATE_PERMISSIONS_REFRESH,
PERM_AUTO_INVALIDATE_TENANT_SWITCHED
]);
}
return new Set(autoInvalidateOn);

@ -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 };
}

@ -1,14 +1,14 @@
import type { StandardSchemaV1 } from '$libs/standard-schema';
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http';
import {
PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX,
PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX,
PERMISSION_HTTP_CREDENTIALS_INCLUDE
PERM_ERROR_MSG_REQUEST_FAILED_PREFIX,
PERM_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX,
PERM_HTTP_CREDENTIALS_INCLUDE
} from './consts.ts';
import { PermRemoteRequestError } from './errors.ts';
import type { PermissionClientOptions } from './types.ts';
import type { PermClientOptions } from './types.ts';
const PERMISSION_JSON_PASSTHROUGH_SCHEMA: StandardSchemaV1<unknown, unknown> = {
const PERM_JSON_PASSTHROUGH_SCHEMA: StandardSchemaV1<unknown, unknown> = {
'~standard': {
version: 1,
vendor: 'active-perm',
@ -22,8 +22,8 @@ function joinUrl(base: string, path: string): string {
return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
}
export async function postPermissionJson<T>(
options: PermissionClientOptions,
export async function postPermJson<T>(
options: PermClientOptions,
path: string,
body: unknown
): Promise<T> {
@ -31,12 +31,12 @@ export async function postPermissionJson<T>(
if (options.http) {
const response = await options.http.post(url, {
body: body as Record<string, unknown>,
schema: PERMISSION_JSON_PASSTHROUGH_SCHEMA
schema: PERM_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}`,
`${PERM_ERROR_MSG_REQUEST_FAILED_PREFIX}${status ?? url}`,
url,
status
);
@ -46,26 +46,26 @@ export async function postPermissionJson<T>(
const response = await fetcher(url, {
method: HTTP_METHOD_POST,
headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON },
credentials: PERMISSION_HTTP_CREDENTIALS_INCLUDE,
credentials: PERM_HTTP_CREDENTIALS_INCLUDE,
body: JSON.stringify(body)
});
if (!response.ok) {
throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`,
`${PERM_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`,
url,
response.status
);
}
return readPermissionJson<T>(response, url);
return readPermJson<T>(response, url);
}
async function readPermissionJson<T>(response: Response, url: string): Promise<T> {
async function readPermJson<T>(response: Response, url: string): Promise<T> {
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}`,
`${PERM_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`,
url,
response.status
);
@ -75,7 +75,7 @@ async function readPermissionJson<T>(response: Response, url: string): Promise<T
return (await response.json()) as T;
} catch (_error) {
throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`,
`${PERM_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`,
url,
response.status
);

@ -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
});
}

@ -1,20 +1,20 @@
import { CodeError } from '$libs/errs';
import {
PERMISSION_ERR_CLIENT_DISPOSED,
PERMISSION_ERR_INVALID_ENDPOINT,
PERMISSION_ERR_NO_CONTEXT,
PERMISSION_ERR_REMOTE_REQUEST
PERM_ERR_CLIENT_DISPOSED,
PERM_ERR_INVALID_ENDPOINT,
PERM_ERR_NO_CONTEXT,
PERM_ERR_REMOTE_REQUEST
} from './consts.ts';
export class PermInvalidEndpointError extends CodeError {
constructor(message: string) {
super(PERMISSION_ERR_INVALID_ENDPOINT, { message });
super(PERM_ERR_INVALID_ENDPOINT, { message });
}
}
export class PermNoContextError extends CodeError {
constructor(message: string) {
super(PERMISSION_ERR_NO_CONTEXT, { message });
super(PERM_ERR_NO_CONTEXT, { message });
}
}
@ -23,7 +23,7 @@ export class PermRemoteRequestError extends CodeError {
readonly status?: number;
constructor(message: string, url?: string, status?: number) {
super(PERMISSION_ERR_REMOTE_REQUEST, { message });
super(PERM_ERR_REMOTE_REQUEST, { message });
this.url = url;
this.status = status;
}
@ -31,7 +31,7 @@ export class PermRemoteRequestError extends CodeError {
export class PermDisposedError extends CodeError {
constructor(message: string) {
super(PERMISSION_ERR_CLIENT_DISPOSED, { message });
super(PERM_ERR_CLIENT_DISPOSED, { message });
}
}

@ -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';

@ -1,24 +1,24 @@
import { describe, expect, it, vi } from 'vitest';
import { PERMISSION_EFFECT_ALLOW } from '$libs/permissions';
import { PERM_EFFECT_ALLOW } from '$libs/perm';
import {
allow,
attr,
createActivePermissions,
createPermissionClient,
createActivePerms,
createPermClient,
definePermSchema,
definePolicies,
isPermDisposedError,
isPermInvalidEndpointError,
PERMISSION_ERROR_MSG_DISPOSED_SUFFIX,
PERMISSION_METHOD_CHECK,
PERMISSION_METHOD_DECISION_KEY,
permissionDecisionKey
} from '$permissions';
PERM_ERROR_MSG_DISPOSED_SUFFIX,
PERM_METHOD_CHECK,
PERM_METHOD_DECISION_KEY,
permDecisionKey
} from '$perm';
import {
createEnginePermissions,
createPermissionHttpHandlers,
createEnginePerms,
createPermHttpHandlers,
isPermInvalidBodyError
} from '$svrs/permissions';
} from '$svrs/perm';
const schema = definePermSchema({
actors: {
@ -43,11 +43,11 @@ function jsonRequest(body: unknown, url = 'https://perm.test/permissions/check',
};
}
describe('Permission client + HTTP handlers', () => {
describe('Perm client + HTTP handlers', () => {
it('checks remotely, caches the decision and keeps the same decision key as batch', async () => {
const runtime = createEnginePermissions({ schema, policies });
const runtime = createEnginePerms({ schema, policies });
const actor = { type: 'user', id: 'u1', status: 'active' };
const handlers = createPermissionHttpHandlers(runtime, () => actor);
const handlers = createPermHttpHandlers(runtime, () => actor);
const calls: string[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
@ -63,7 +63,7 @@ describe('Permission client + HTTP handlers', () => {
});
}) as typeof fetch;
const client = createPermissionClient({
const client = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher
});
@ -76,28 +76,28 @@ describe('Permission client + HTTP handlers', () => {
const first = await client.check(input);
const second = await client.check(input);
expect(first.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(second.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(first.effect).toBe(PERM_EFFECT_ALLOW);
expect(second.effect).toBe(PERM_EFFECT_ALLOW);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(calls).toEqual(['/permissions/check']);
client.invalidate();
const batch = await client.batch({ checks: [input] });
expect(batch[permissionDecisionKey(input)]?.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(batch[permDecisionKey(input)]?.effect).toBe(PERM_EFFECT_ALLOW);
expect(calls).toEqual(['/permissions/check', '/permissions/batch']);
});
it('uses typed errors for invalid active client options and invalid bodies', async () => {
expect.assertions(2);
try {
createActivePermissions({ endpoint: '' });
createActivePerms({ endpoint: '' });
} catch (error) {
expect(isPermInvalidEndpointError(error)).toBe(true);
}
const runtime = createEnginePermissions({ schema, policies });
const handlers = createPermissionHttpHandlers(runtime, () => ({
const runtime = createEnginePerms({ schema, policies });
const handlers = createPermHttpHandlers(runtime, () => ({
type: 'user',
id: 'u1',
status: 'active'
@ -111,9 +111,9 @@ describe('Permission client + HTTP handlers', () => {
});
it('dispatches permission HTTP requests through handle()', async () => {
const runtime = createEnginePermissions({ schema, policies });
const runtime = createEnginePerms({ schema, policies });
const actor = { type: 'user', id: 'u1', status: 'active' };
const handlers = createPermissionHttpHandlers(runtime, () => actor);
const handlers = createPermHttpHandlers(runtime, () => actor);
const response = await handlers.handle(
jsonRequest(
@ -127,13 +127,13 @@ describe('Permission client + HTTP handlers', () => {
const missing = await handlers.handle(jsonRequest({}, 'https://perm.test/permissions/missing'));
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ effect: PERMISSION_EFFECT_ALLOW });
expect(response.body).toMatchObject({ effect: PERM_EFFECT_ALLOW });
expect(missing.status).toBe(404);
});
it('backs off repeated remote failures for the same decision key', async () => {
const fetcher = vi.fn(async () => new Response(null, { status: 503 })) as typeof fetch;
const client = createPermissionClient({
const client = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher,
remoteFailureBackoffMs: 10_000
@ -150,7 +150,7 @@ describe('Permission client + HTTP handlers', () => {
});
it('can include an explicit scope key in local decision keys', () => {
const client = createPermissionClient({
const client = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher: vi.fn() as unknown as typeof fetch,
scopeKey: 'u1'
@ -160,7 +160,7 @@ describe('Permission client + HTTP handlers', () => {
resource: { type: 'post', id: 'p1' }
};
expect(client.decisionKey(input)).not.toBe(permissionDecisionKey(input));
expect(client.decisionKey(input)).not.toBe(permDecisionKey(input));
});
it('invalidates scoped decisions by structured prefix, not substring', async () => {
@ -168,13 +168,13 @@ describe('Permission client + HTTP handlers', () => {
async () =>
new Response(
JSON.stringify({
effect: PERMISSION_EFFECT_ALLOW,
effect: PERM_EFFECT_ALLOW,
policy: 'remote.allow'
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;
const client = createPermissionClient({
const client = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher
});
@ -198,7 +198,7 @@ describe('Permission client + HTTP handlers', () => {
responses.push(resolve);
})
) as typeof fetch;
const client = createPermissionClient({
const client = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher,
initialSnapshot: { actor: { type: 'user', id: 'u1' }, decisions: {} }
@ -211,7 +211,7 @@ describe('Permission client + HTTP handlers', () => {
const first = client.check(input);
client.hydrate({ actor: { type: 'user', id: 'u2' }, decisions: {} });
responses[0]!(
new Response(JSON.stringify({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' }), {
new Response(JSON.stringify({ effect: PERM_EFFECT_ALLOW, policy: 'remote.allow' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
@ -221,7 +221,7 @@ describe('Permission client + HTTP handlers', () => {
const second = client.check(input);
expect(fetcher).toHaveBeenCalledTimes(2);
responses[1]!(
new Response(JSON.stringify({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' }), {
new Response(JSON.stringify({ effect: PERM_EFFECT_ALLOW, policy: 'remote.allow' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
@ -230,7 +230,7 @@ describe('Permission client + HTTP handlers', () => {
});
it('does not cache stale batch or what responses after actor snapshot changes', async () => {
const decision = { effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' } as const;
const decision = { effect: PERM_EFFECT_ALLOW, policy: 'remote.allow' } as const;
const input = {
action: 'post.read',
resource: { type: 'post', id: 'p1' }
@ -245,7 +245,7 @@ describe('Permission client + HTTP handlers', () => {
batchResponses.push({ path, resolve });
});
}) as typeof fetch;
const batchClient = createPermissionClient({
const batchClient = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher: batchFetcher,
initialSnapshot: { actor: { type: 'user', id: 'u1' }, decisions: {} }
@ -255,7 +255,7 @@ describe('Permission client + HTTP handlers', () => {
expect(batchResponses[0]?.path).toBe('/permissions/batch');
batchClient.hydrate({ actor: { type: 'user', id: 'u2' }, decisions: {} });
batchResponses[0]!.resolve(
new Response(JSON.stringify({ decisions: { [permissionDecisionKey(input)]: decision } }), {
new Response(JSON.stringify({ decisions: { [permDecisionKey(input)]: decision } }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
@ -283,7 +283,7 @@ describe('Permission client + HTTP handlers', () => {
whatResponses.push({ path, resolve });
});
}) as typeof fetch;
const whatClient = createPermissionClient({
const whatClient = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher: whatFetcher,
initialSnapshot: { actor: { type: 'user', id: 'u1' }, decisions: {} }
@ -317,7 +317,7 @@ describe('Permission client + HTTP handlers', () => {
});
it('reports the real client method name after dispose()', async () => {
const client = createPermissionClient({
const client = createPermClient({
endpoint: 'https://perm.test/permissions',
fetcher: vi.fn() as unknown as typeof fetch
});
@ -329,10 +329,10 @@ describe('Permission client + HTTP handlers', () => {
client.dispose();
expect(() => client.decisionKey(input)).toThrow(
`${PERMISSION_METHOD_DECISION_KEY}${PERMISSION_ERROR_MSG_DISPOSED_SUFFIX}`
`${PERM_METHOD_DECISION_KEY}${PERM_ERROR_MSG_DISPOSED_SUFFIX}`
);
await expect(client.check(input)).rejects.toMatchObject({
message: `${PERMISSION_METHOD_CHECK}${PERMISSION_ERROR_MSG_DISPOSED_SUFFIX}`
message: `${PERM_METHOD_CHECK}${PERM_ERROR_MSG_DISPOSED_SUFFIX}`
});
await expect(client.check(input)).rejects.toSatisfy(isPermDisposedError);
});

@ -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…
Cancel
Save

Powered by TurnKey Linux.