Completes step 3 of the active-app refactor. The orca-based service
schema model is now the single supported path; the legacy
auto-reactive ecosystem is gone.
Removed:
- arts/active-app/integrations/session-translator.ts and auth-cache.ts
(their purpose is replaced by orca presets in arts/active-app/presets/).
- arts/connection/bus-session-source.ts (canonical session events
flow through SESSION_EVENT_* directly).
- libs/active-app/ entirely. Its contents (consts, errors, events
reduced to APP_EVENT_DISPOSE_STARTING) consolidated into
arts/active-app/{consts,errors,events}.ts. The libs layer no longer
has anything app-specific.
- From arts/cache:
- bus / autoInvalidateOn options on ActiveCacheOptions.
- wireAutoInvalidation internal subscription.
- CACHE_AUTO_INVALIDATE_* constants and types.
- From arts/perm:
- bus / autoInvalidateOn options on ActivePermsOptions.
- wireAutoInvalidation internal subscription.
- PERM_AUTO_INVALIDATE_* constants and types.
- From arts/connection:
- bus option in EngineConnectionsOptions.
- shouldWireBusSessionSource helper.
- The "reacts to canonical app identity bus events" test that
depended on the deleted bus-session-source.
- From arts/active-app/active-app.svelte.ts:
- createSiumEngine() / createActiveSession() / createActiveConnections() /
createActivePerms() / createActiveAuth() factory methods.
- App.Sess / App.Perms / App.Auth getters and the singleton
guards (Sess !== undefined etc.).
- APP_ORCHESTRATION_* preset system, resolveActiveAppOrchestration,
STANDARD_ORCHESTRATION_TRANSLATORS, all five translator handles.
- APP_ERROR_ALREADY_CREATED_* / APP_ERROR_CREATE_PERM_ENDPOINT_REQUIRED
constants (orphan after factory removal).
- From arts/active-app/test:
- ecosystem.integration.test.ts (9 monolithic tests, ~1900 lines).
- session-translator.test.ts.
- create-sium-engine.test.ts.
- From libs/active-app/test:
- events.test.ts (covered the deleted publishers and the legacy
APP_USER_IDENTITY_* causes).
- APP_EVENT_USER_IDENTITY_CHANGED, APP_EVENT_TENANT_SWITCHED,
APP_EVENT_PERMISSIONS_REFRESH_REQUESTED,
APP_EVENT_CACHE_INVALIDATE_REQUESTED, APP_EVENT_CONNECTIVITY_CHANGED
(only DISPOSE_STARTING survives).
- Their associated payload interfaces and the
APP_USER_IDENTITY_CAUSE_* constants.
- publishAppUserIdentityChanged / publishAppPermsRefreshRequested /
publishAppCacheInvalidateRequested / publishAppTenantSwitched /
publishAppConnectivityChanged. publishAppDisposeStarting and the
new onAppDisposeStarting helper remain.
Final shape of arts/active-app/:
- active-app.svelte.ts: builds Logger, Lang, Format, Frontend, Dom,
Storage, Http, Timers, Bus, Orca, Cache, then the schema services
via buildServiceBuilders. dispose() publishes DISPOSE_STARTING and
tears everything down in reverse construction order.
- services.ts, service-builder.ts, service-factories/, presets/,
bus-context.svelte.ts, consts.ts, errors.ts, events.ts, types.ts,
index.ts (public barrel exposing every define*, applyStandardOrca,
types and errors).
Suite: 1374 tests pass. The reduction from the prior 1408 reflects the
deleted legacy tests; coverage of the new model is comprehensive
(schema-declarative.test.ts, service-builder.test.ts,
service-factories.test.ts, presets.test.ts, active-app.test.ts core,
plus the existing per-art suites).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
parent
01a85ad299
commit
64ab1f0b63
@ -1,26 +1,37 @@
|
||||
export { APP_MODULE } from '$libs/active-app/consts';
|
||||
import { APP_MODULE } from '$libs/active-app/consts';
|
||||
/**
|
||||
* Constants for `arts/active-app`. Holds the module identifier, the
|
||||
* Svelte context key for the bus, the safe-publish guards (sensitive
|
||||
* key parts, runtime gates) and the canonical app-event names.
|
||||
*/
|
||||
|
||||
export const APP_LOG_MSG_SESSION_CACHE_CLEAR_FAILED =
|
||||
'session change cache clear failed';
|
||||
export const APP_CONNECTION_CLOSE_REASON_IDENTITY_CLEARED = 'identity_cleared';
|
||||
export const APP_MODULE = 'app';
|
||||
|
||||
export const APP_ORCHESTRATION_STANDARD = 'standard';
|
||||
export const APP_ORCHESTRATION_SILENT = 'silent';
|
||||
export const APP_ORCHESTRATION_TRANSLATOR_IDENTITY = 'identity';
|
||||
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';
|
||||
export const APP_BUS_CONTEXT_KEY = 'arts.app.bus';
|
||||
|
||||
export const APP_ERROR_ALREADY_CREATED_SESSION =
|
||||
`[${APP_MODULE}] App.createActiveSession() called twice — only one session per App.`;
|
||||
export const APP_EVENT_SENSITIVE_KEY_PARTS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'credential',
|
||||
'csrf',
|
||||
'hash',
|
||||
'header',
|
||||
'password',
|
||||
'secret',
|
||||
'token'
|
||||
] as const;
|
||||
|
||||
export const APP_ERROR_ALREADY_CREATED_PERMISSIONS =
|
||||
`[${APP_MODULE}] App.createActivePerms() called twice — only one permissions client per App.`;
|
||||
/**
|
||||
* Where a given app event is allowed to be published. `'both'` is the
|
||||
* default for facts that exist in both server-rendered and browser-
|
||||
* mounted contexts; `'client'` is reserved for browser-only signals;
|
||||
* `'server'` for events that only make sense in the request lifecycle.
|
||||
*/
|
||||
export const APP_EVENT_RUNTIME_BOTH = 'both';
|
||||
export const APP_EVENT_RUNTIME_CLIENT = 'client';
|
||||
export const APP_EVENT_RUNTIME_SERVER = 'server';
|
||||
|
||||
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_PERM_ENDPOINT_REQUIRED =
|
||||
`[${APP_MODULE}] App.createActivePerms() requires endpoint via options.permissions or argument.`;
|
||||
export const APP_EVENT_RUNTIMES_VALUES = [
|
||||
APP_EVENT_RUNTIME_BOTH,
|
||||
APP_EVENT_RUNTIME_CLIENT,
|
||||
APP_EVENT_RUNTIME_SERVER
|
||||
] as const;
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import type { BusPublishOptions, EventPublisher, EventSubscriber } from '$libs/bus';
|
||||
import {
|
||||
APP_EVENT_RUNTIME_BOTH,
|
||||
APP_EVENT_RUNTIME_CLIENT,
|
||||
APP_EVENT_SENSITIVE_KEY_PARTS
|
||||
} from './consts.ts';
|
||||
import { AappInvalidEventRuntimeError, AappUnsafeEventPayloadError } from './errors.ts';
|
||||
|
||||
/**
|
||||
* The single App-owned event that survives the orca-based migration.
|
||||
* Apps that need to react to teardown subscribe via `App.Bus.on(...)` or
|
||||
* register an orca action.
|
||||
*/
|
||||
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
|
||||
|
||||
/**
|
||||
* Where each `APP_EVENT_*` is allowed to fire. `'both'` is the default;
|
||||
* `'client'` is reserved for facts that only exist in the browser.
|
||||
*/
|
||||
export type AppEventRuntime = typeof APP_EVENT_RUNTIME_BOTH | typeof APP_EVENT_RUNTIME_CLIENT;
|
||||
|
||||
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
|
||||
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws `AappInvalidEventRuntimeError` if `type` is an `APP_EVENT_*`
|
||||
* declared as client-only and the current runtime is the server (or
|
||||
* vice-versa). Module events are not registered in `APP_EVENT_RUNTIMES`
|
||||
* and are accepted unconditionally.
|
||||
*/
|
||||
export function assertEventCanFire(
|
||||
type: string,
|
||||
where: typeof APP_EVENT_RUNTIME_CLIENT | 'server'
|
||||
): void {
|
||||
const allowed = APP_EVENT_RUNTIMES[type];
|
||||
if (allowed === undefined) return;
|
||||
if (allowed === APP_EVENT_RUNTIME_BOTH) return;
|
||||
if (allowed === where) return;
|
||||
throw new AappInvalidEventRuntimeError(type, allowed, where);
|
||||
}
|
||||
|
||||
export function assertAppEventPayloadSafe(type: string, payload: unknown): void {
|
||||
const unsafePath = findSensitivePayloadPath(payload);
|
||||
if (unsafePath !== undefined) throw new AappUnsafeEventPayloadError(type, unsafePath);
|
||||
}
|
||||
|
||||
function findSensitivePayloadPath(value: unknown, path = '$'): string | undefined {
|
||||
if (value === null || typeof value !== 'object') return undefined;
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unsafe = findSensitivePayloadPath(value[index], `${path}[${index}]`);
|
||||
if (unsafe !== undefined) return unsafe;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const childPath = `${path}.${key}`;
|
||||
if (isSensitivePayloadKey(key)) return childPath;
|
||||
const unsafe = findSensitivePayloadPath(child, childPath);
|
||||
if (unsafe !== undefined) return unsafe;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSensitivePayloadKey(key: string): boolean {
|
||||
const normalized = key.toLowerCase();
|
||||
return APP_EVENT_SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export interface AppDisposeStartingPayload {
|
||||
readonly cause: string;
|
||||
}
|
||||
|
||||
export interface AppEventMap {
|
||||
[APP_EVENT_DISPOSE_STARTING]: AppDisposeStartingPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only contract for App's event bus. App publishes only
|
||||
* `DISPOSE_STARTING` directly; everything else flows through the bus by
|
||||
* other modules (session, connection, etc.) and is orchestrated via
|
||||
* `App.Orca`.
|
||||
*/
|
||||
export type AppEventBus = EventSubscriber<AppEventMap>;
|
||||
|
||||
function currentRuntime(): typeof APP_EVENT_RUNTIME_CLIENT | 'server' {
|
||||
return typeof window === 'undefined' ? 'server' : APP_EVENT_RUNTIME_CLIENT;
|
||||
}
|
||||
|
||||
export function publishAppDisposeStarting(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppDisposeStartingPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_DISPOSE_STARTING, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_DISPOSE_STARTING, payload);
|
||||
return bus.publish(APP_EVENT_DISPOSE_STARTING, payload, options);
|
||||
}
|
||||
|
||||
export function onAppDisposeStarting(
|
||||
bus: AppEventBus,
|
||||
listener: (payload: AppDisposeStartingPayload) => void | Promise<void>
|
||||
) {
|
||||
return bus.on(APP_EVENT_DISPOSE_STARTING, (envelope) => listener(envelope.payload));
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { CACHE_SCOPE_PUBLIC } from '$libs/cache';
|
||||
import type { AuthClientCachePort } from '$libs/auth/contracts';
|
||||
import type { ActiveCache } from '$cache';
|
||||
import type { ActivePerms } from '$perm';
|
||||
|
||||
export function createAuthCacheInvalidator(input: {
|
||||
readonly cache: ActiveCache;
|
||||
readonly permissions: () => ActivePerms | undefined;
|
||||
}): AuthClientCachePort {
|
||||
return {
|
||||
async invalidate(event) {
|
||||
input.permissions()?.invalidate();
|
||||
await Promise.all(
|
||||
event.tags.map((tag) =>
|
||||
input.cache.invalidate({
|
||||
tag,
|
||||
scope: CACHE_SCOPE_PUBLIC
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import type { AppEventMap, AppUserIdentityChangeCause } from '$libs/active-app/events';
|
||||
import {
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED,
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER,
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_EXPIRED,
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED,
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_REFRESHED,
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_REVOKED,
|
||||
publishAppUserIdentityChanged
|
||||
} from '$libs/active-app/events';
|
||||
import {
|
||||
SESSION_EVENT_LIFECYCLE_ADOPTED,
|
||||
SESSION_EVENT_LIFECYCLE_ADOPTED_SERVER,
|
||||
SESSION_EVENT_LIFECYCLE_EXPIRED,
|
||||
SESSION_EVENT_LIFECYCLE_EXTERNAL_CHANGED,
|
||||
SESSION_EVENT_LIFECYCLE_REFRESHED,
|
||||
SESSION_EVENT_LIFECYCLE_REVOKED,
|
||||
SESSION_EVENT_CHANGED
|
||||
} from '$session/consts';
|
||||
import type { SessEventMap, SessLifecyclePayload, SessionEvent } from '$session/types';
|
||||
import type { EngineBus } from '$bus';
|
||||
import { APP_MODULE } from '../consts.ts';
|
||||
|
||||
type SessionTranslatorBusEvents = AppEventMap & SessEventMap;
|
||||
|
||||
export function wireSessionTranslator(bus: EngineBus<SessionTranslatorBusEvents>): () => void {
|
||||
const subscription = bus.on(SESSION_EVENT_CHANGED, (event) => {
|
||||
publishAppIdentityFromSessionLifecycleEvent(bus, event.payload);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
export function publishAppIdentityFromSessionLifecycleEvent(
|
||||
bus: EngineBus<SessionTranslatorBusEvents>,
|
||||
payload: SessLifecyclePayload
|
||||
): void {
|
||||
const cause = resolveAppIdentityCause(payload.event);
|
||||
if (cause === undefined) return;
|
||||
publishAppUserIdentityChanged(
|
||||
bus,
|
||||
{
|
||||
event: payload.event,
|
||||
generation: payload.generation,
|
||||
identity: {
|
||||
from: payload.identity.from,
|
||||
to: payload.identity.to
|
||||
},
|
||||
cause
|
||||
},
|
||||
{
|
||||
source: APP_MODULE
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAppIdentityCause(event: SessionEvent): AppUserIdentityChangeCause | undefined {
|
||||
if (event === SESSION_EVENT_LIFECYCLE_ADOPTED) return APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED;
|
||||
if (event === SESSION_EVENT_LIFECYCLE_ADOPTED_SERVER) return APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER;
|
||||
if (event === SESSION_EVENT_LIFECYCLE_REFRESHED) return APP_USER_IDENTITY_CAUSE_SESSION_REFRESHED;
|
||||
if (event === SESSION_EVENT_LIFECYCLE_REVOKED) return APP_USER_IDENTITY_CAUSE_SESSION_REVOKED;
|
||||
if (event === SESSION_EVENT_LIFECYCLE_EXPIRED) return APP_USER_IDENTITY_CAUSE_SESSION_EXPIRED;
|
||||
if (event === SESSION_EVENT_LIFECYCLE_EXTERNAL_CHANGED) return APP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED;
|
||||
return undefined;
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
/**
|
||||
* App.createSiumEngine() — page-scoped Sium construction smoke tests.
|
||||
*
|
||||
* Verifies that the method:
|
||||
* - returns a working EngineSium wired to App.Lang and App.Logger
|
||||
* - routes validation diagnostics through the App's Logger
|
||||
* - returns a fresh engine per call
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTestApp } from '../testing';
|
||||
import { object, pipe, string, email, min, meta } from '$sium/core';
|
||||
import { siumLangs } from '$sium';
|
||||
import { LogLevel } from '$logger';
|
||||
import type { LangNode } from '$lang';
|
||||
|
||||
const langSchema = {
|
||||
auth: { loginCta: { es: 'Entrar', en: 'Sign in' } },
|
||||
sium: siumLangs
|
||||
} satisfies LangNode;
|
||||
|
||||
const Login = object({
|
||||
email: pipe(string(), email(), meta({ label: 'Email' })),
|
||||
password: pipe(string(), min(8), meta({ label: 'Password' }))
|
||||
});
|
||||
|
||||
describe('App.createSiumEngine()', () => {
|
||||
it('returns a working EngineSium wired to App.Lang', async () => {
|
||||
const App = createTestApp({ lang: { schema: langSchema, defaultLocale: 'es' } });
|
||||
const sium = App.createSiumEngine();
|
||||
|
||||
const result = await sium.validate(Login, { email: 'no', password: '123' });
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
// Issues are translated via App.Lang because the method passed it in.
|
||||
if (!result.ok) {
|
||||
const messages = result.issues.map((i) => sium.resolveIssue(i, App.getLocale()));
|
||||
expect(messages.every((m) => typeof m === 'string' && m.length > 0)).toBe(true);
|
||||
}
|
||||
|
||||
App.dispose();
|
||||
});
|
||||
|
||||
it('routes validation diagnostics through App.Logger', async () => {
|
||||
const App = createTestApp({
|
||||
captureLogs: true,
|
||||
lang: { schema: langSchema, defaultLocale: 'es' }
|
||||
});
|
||||
const sium = App.createSiumEngine();
|
||||
|
||||
await sium.validate(Login, { email: 'no', password: '123' });
|
||||
|
||||
// Sium emits a debug entry under category 'sium' on validation failure.
|
||||
const debug = App.entries.find(
|
||||
(e) => e.category === 'sium' && e.level === LogLevel.DEBUG
|
||||
);
|
||||
expect(debug).toBeDefined();
|
||||
expect(debug?.context).toMatchObject({ issueCount: expect.any(Number) });
|
||||
|
||||
App.dispose();
|
||||
});
|
||||
|
||||
it('returns a fresh sium instance per call', () => {
|
||||
const App = createTestApp({ lang: { schema: langSchema, defaultLocale: 'es' } });
|
||||
const a = App.createSiumEngine();
|
||||
const b = App.createSiumEngine();
|
||||
expect(a).not.toBe(b);
|
||||
App.dispose();
|
||||
});
|
||||
});
|
||||
@ -1,18 +0,0 @@
|
||||
import {
|
||||
APP_EVENT_USER_IDENTITY_CHANGED,
|
||||
type AppEventBus
|
||||
} from '$libs/active-app/events';
|
||||
import type { ConnectionSessionSource } from './types.ts';
|
||||
|
||||
export function createBusSessionSource(bus: AppEventBus): ConnectionSessionSource {
|
||||
return {
|
||||
onChange(listener) {
|
||||
const subscription = bus.on(APP_EVENT_USER_IDENTITY_CHANGED, (event) => {
|
||||
listener(event.payload);
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Generic constants for `libs/active-app`. Public app-event names and payload
|
||||
* types live in `./events.ts`; error codes, messages and classes live in
|
||||
* `./errors.ts`; constants here are framework-internal (context keys,
|
||||
* runtime gates).
|
||||
*/
|
||||
|
||||
export const APP_MODULE = 'app';
|
||||
|
||||
export const APP_BUS_CONTEXT_KEY = 'libs.app.bus';
|
||||
|
||||
export const APP_EVENT_SENSITIVE_KEY_PARTS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'credential',
|
||||
'csrf',
|
||||
'hash',
|
||||
'header',
|
||||
'password',
|
||||
'secret',
|
||||
'token'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Where a given app event is allowed to be published. `'both'` is the
|
||||
* default for facts that exist in both server-rendered and
|
||||
* browser-mounted contexts; `'client'` is reserved for events that
|
||||
* depend on browser-only signals (online/offline, focus, etc.);
|
||||
* `'server'` is reserved for events that only make sense in the
|
||||
* server's request lifecycle (currently none, but the slot is here so
|
||||
* the contract scales).
|
||||
*/
|
||||
export const APP_EVENT_RUNTIME_BOTH = 'both';
|
||||
export const APP_EVENT_RUNTIME_CLIENT = 'client';
|
||||
export const APP_EVENT_RUNTIME_SERVER = 'server';
|
||||
|
||||
export const APP_EVENT_RUNTIMES_VALUES = [
|
||||
APP_EVENT_RUNTIME_BOTH,
|
||||
APP_EVENT_RUNTIME_CLIENT,
|
||||
APP_EVENT_RUNTIME_SERVER
|
||||
] as const;
|
||||
@ -1,81 +0,0 @@
|
||||
import {
|
||||
CodeError,
|
||||
errCode,
|
||||
moduleSeed,
|
||||
type ErrCode,
|
||||
type ErrorMessages,
|
||||
type ModuleSeed
|
||||
} from '$libs/errs';
|
||||
import { APP_MODULE } from './consts.ts';
|
||||
|
||||
// ── Error codes ────────────────────────────────────────────────────────
|
||||
|
||||
export const APP_ERR: ModuleSeed = moduleSeed(APP_MODULE);
|
||||
export const APP_ERR_BUS: ErrCode = errCode(APP_ERR, 'bus');
|
||||
export const APP_ERR_BUS_NO_CONTEXT: ErrCode = errCode(APP_ERR_BUS, 'no_context');
|
||||
export const APP_ERR_EVENT: ErrCode = errCode(APP_ERR, 'event');
|
||||
export const APP_ERR_EVENT_INVALID_RUNTIME: ErrCode = errCode(APP_ERR_EVENT, 'invalid_runtime');
|
||||
export const APP_ERR_EVENT_UNSAFE_PAYLOAD: ErrCode = errCode(APP_ERR_EVENT, 'unsafe_payload');
|
||||
|
||||
// ── Error messages ─────────────────────────────────────────────────────
|
||||
|
||||
export const APP_ERROR_MESSAGES: ErrorMessages = {
|
||||
[APP_ERR_BUS_NO_CONTEXT]:
|
||||
`[${APP_MODULE}] no bus in context — call setBus(App.Bus) in a layout before getBus()`,
|
||||
[APP_ERR_EVENT_INVALID_RUNTIME]: `[${APP_MODULE}] event cannot fire in this runtime`,
|
||||
[APP_ERR_EVENT_UNSAFE_PAYLOAD]: `[${APP_MODULE}] app event payload contains a sensitive field`
|
||||
};
|
||||
|
||||
// ── Error classes ──────────────────────────────────────────────────────
|
||||
|
||||
export class AappBusNoContextError extends CodeError {
|
||||
constructor(message = APP_ERROR_MESSAGES[APP_ERR_BUS_NO_CONTEXT] as string) {
|
||||
super(APP_ERR_BUS_NO_CONTEXT, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class AappInvalidEventRuntimeError extends CodeError {
|
||||
readonly type: string;
|
||||
readonly allowed: string;
|
||||
readonly where: string;
|
||||
|
||||
constructor(type: string, allowed: string, where: string) {
|
||||
super(APP_ERR_EVENT_INVALID_RUNTIME, {
|
||||
message: `${APP_ERROR_MESSAGES[APP_ERR_EVENT_INVALID_RUNTIME] as string} (type=${type}, allowed=${allowed}, where=${where})`
|
||||
});
|
||||
this.type = type;
|
||||
this.allowed = allowed;
|
||||
this.where = where;
|
||||
}
|
||||
}
|
||||
|
||||
export class AappUnsafeEventPayloadError extends CodeError {
|
||||
readonly type: string;
|
||||
readonly path: string;
|
||||
|
||||
constructor(type: string, path: string) {
|
||||
super(APP_ERR_EVENT_UNSAFE_PAYLOAD, {
|
||||
message: `${APP_ERROR_MESSAGES[APP_ERR_EVENT_UNSAFE_PAYLOAD] as string} (type=${type}, path=${path})`
|
||||
});
|
||||
this.type = type;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type guards ────────────────────────────────────────────────────────
|
||||
|
||||
export function isAappBusNoContextError(error: unknown): error is AappBusNoContextError {
|
||||
return error instanceof AappBusNoContextError;
|
||||
}
|
||||
|
||||
export function isAappInvalidEventRuntimeError(
|
||||
error: unknown
|
||||
): error is AappInvalidEventRuntimeError {
|
||||
return error instanceof AappInvalidEventRuntimeError;
|
||||
}
|
||||
|
||||
export function isAappUnsafeEventPayloadError(
|
||||
error: unknown
|
||||
): error is AappUnsafeEventPayloadError {
|
||||
return error instanceof AappUnsafeEventPayloadError;
|
||||
}
|
||||
@ -1,295 +0,0 @@
|
||||
import type {
|
||||
BusEnvelope,
|
||||
BusPublishOptions,
|
||||
BusSubscription,
|
||||
EventPublisher,
|
||||
EventSubscriber
|
||||
} from '$libs/bus';
|
||||
import {
|
||||
APP_EVENT_RUNTIME_BOTH,
|
||||
APP_EVENT_RUNTIME_CLIENT,
|
||||
APP_EVENT_SENSITIVE_KEY_PARTS
|
||||
} from './consts.ts';
|
||||
import {
|
||||
AappInvalidEventRuntimeError,
|
||||
AappUnsafeEventPayloadError
|
||||
} from './errors.ts';
|
||||
|
||||
export const APP_EVENT_USER_IDENTITY_CHANGED = 'app.user.identity.changed';
|
||||
export const APP_EVENT_TENANT_SWITCHED = 'app.tenant.switched';
|
||||
export const APP_EVENT_PERMISSIONS_REFRESH_REQUESTED =
|
||||
'app.permissions.refresh.requested';
|
||||
export const APP_EVENT_CONNECTIVITY_CHANGED = 'app.connectivity.changed';
|
||||
export const APP_EVENT_CACHE_INVALIDATE_REQUESTED = 'app.cache.invalidate.requested';
|
||||
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
|
||||
|
||||
/**
|
||||
* Where each `APP_EVENT_*` is allowed to fire. `'both'` is the default;
|
||||
* `'client'` is reserved for facts that only exist in the browser
|
||||
* (connectivity is the canonical example). `assertEventCanFire` reads
|
||||
* this table.
|
||||
*/
|
||||
export type AppEventRuntime =
|
||||
| typeof APP_EVENT_RUNTIME_BOTH
|
||||
| typeof APP_EVENT_RUNTIME_CLIENT;
|
||||
|
||||
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
|
||||
[APP_EVENT_USER_IDENTITY_CHANGED]: APP_EVENT_RUNTIME_BOTH,
|
||||
[APP_EVENT_TENANT_SWITCHED]: APP_EVENT_RUNTIME_BOTH,
|
||||
[APP_EVENT_PERMISSIONS_REFRESH_REQUESTED]: APP_EVENT_RUNTIME_BOTH,
|
||||
[APP_EVENT_CONNECTIVITY_CHANGED]: APP_EVENT_RUNTIME_CLIENT,
|
||||
[APP_EVENT_CACHE_INVALIDATE_REQUESTED]: APP_EVENT_RUNTIME_BOTH,
|
||||
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws `AappInvalidEventRuntimeError` if `type` is an `APP_EVENT_*`
|
||||
* declared as client-only and the current runtime is the server (or
|
||||
* vice-versa). Module events are not registered in `APP_EVENT_RUNTIMES`
|
||||
* and are accepted unconditionally — only the public app-event surface
|
||||
* is gated.
|
||||
*/
|
||||
export function assertEventCanFire(
|
||||
type: string,
|
||||
where: typeof APP_EVENT_RUNTIME_CLIENT | 'server'
|
||||
): void {
|
||||
const allowed = APP_EVENT_RUNTIMES[type];
|
||||
if (allowed === undefined) return;
|
||||
if (allowed === APP_EVENT_RUNTIME_BOTH) return;
|
||||
if (allowed === where) return;
|
||||
throw new AappInvalidEventRuntimeError(type, allowed, where);
|
||||
}
|
||||
|
||||
export function assertAppEventPayloadSafe(type: string, payload: unknown): void {
|
||||
const unsafePath = findSensitivePayloadPath(payload);
|
||||
if (unsafePath !== undefined) throw new AappUnsafeEventPayloadError(type, unsafePath);
|
||||
}
|
||||
|
||||
function findSensitivePayloadPath(value: unknown, path = '$'): string | undefined {
|
||||
if (value === null || typeof value !== 'object') return undefined;
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unsafe = findSensitivePayloadPath(value[index], `${path}[${index}]`);
|
||||
if (unsafe !== undefined) return unsafe;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const childPath = `${path}.${key}`;
|
||||
if (isSensitivePayloadKey(key)) return childPath;
|
||||
const unsafe = findSensitivePayloadPath(child, childPath);
|
||||
if (unsafe !== undefined) return unsafe;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSensitivePayloadKey(key: string): boolean {
|
||||
const normalized = key.toLowerCase();
|
||||
return APP_EVENT_SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export const APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED = 'session-adopted';
|
||||
export const APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER = 'session-adopted-server';
|
||||
export const APP_USER_IDENTITY_CAUSE_SESSION_REFRESHED = 'session-refreshed';
|
||||
export const APP_USER_IDENTITY_CAUSE_SESSION_REVOKED = 'session-revoked';
|
||||
export const APP_USER_IDENTITY_CAUSE_SESSION_EXPIRED = 'session-expired';
|
||||
export const APP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED =
|
||||
'session-external-changed';
|
||||
|
||||
export type AppUserIdentityChangeCause =
|
||||
| typeof APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED
|
||||
| typeof APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER
|
||||
| typeof APP_USER_IDENTITY_CAUSE_SESSION_REFRESHED
|
||||
| typeof APP_USER_IDENTITY_CAUSE_SESSION_REVOKED
|
||||
| typeof APP_USER_IDENTITY_CAUSE_SESSION_EXPIRED
|
||||
| typeof APP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED;
|
||||
|
||||
export interface AppUserIdentityChangedPayload {
|
||||
readonly event: string;
|
||||
readonly generation: number;
|
||||
readonly identity: {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
};
|
||||
readonly cause: AppUserIdentityChangeCause;
|
||||
readonly previousActorId?: string | null;
|
||||
readonly nextActorId?: string | null;
|
||||
readonly tenantId?: string | null;
|
||||
}
|
||||
|
||||
export interface AppPermsRefreshRequestedPayload {
|
||||
readonly cause: string;
|
||||
readonly generation?: number;
|
||||
}
|
||||
|
||||
export interface AppTenantSwitchedPayload {
|
||||
readonly previousTenantId: string | null;
|
||||
readonly nextTenantId: string | null;
|
||||
readonly cause: string;
|
||||
}
|
||||
|
||||
export interface AppConnectivityChangedPayload {
|
||||
readonly online: boolean;
|
||||
readonly cause: string;
|
||||
}
|
||||
|
||||
export interface AppCacheInvalidateRequestedPayload {
|
||||
readonly cause: string;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
export interface AppDisposeStartingPayload {
|
||||
readonly cause: string;
|
||||
}
|
||||
|
||||
export interface AppEventMap {
|
||||
[APP_EVENT_USER_IDENTITY_CHANGED]: AppUserIdentityChangedPayload;
|
||||
[APP_EVENT_TENANT_SWITCHED]: AppTenantSwitchedPayload;
|
||||
[APP_EVENT_PERMISSIONS_REFRESH_REQUESTED]: AppPermsRefreshRequestedPayload;
|
||||
[APP_EVENT_CONNECTIVITY_CHANGED]: AppConnectivityChangedPayload;
|
||||
[APP_EVENT_CACHE_INVALIDATE_REQUESTED]: AppCacheInvalidateRequestedPayload;
|
||||
[APP_EVENT_DISPOSE_STARTING]: AppDisposeStartingPayload;
|
||||
}
|
||||
|
||||
export type AppEventBus = EventSubscriber<AppEventMap>;
|
||||
|
||||
function currentRuntime(): typeof APP_EVENT_RUNTIME_CLIENT | 'server' {
|
||||
return typeof window === 'undefined' ? 'server' : APP_EVENT_RUNTIME_CLIENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated The session art publishes `SESSION_EVENT_IDENTITY_CHANGED`
|
||||
* directly. Subscribe to that event (or use the orca preset
|
||||
* `applyCacheClearOnIdentityChange` / `applyPermInvalidateOnIdentityChange`)
|
||||
* instead of republishing as `APP_EVENT_USER_IDENTITY_CHANGED`.
|
||||
*/
|
||||
export function publishAppUserIdentityChanged(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppUserIdentityChangedPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_USER_IDENTITY_CHANGED, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_USER_IDENTITY_CHANGED, payload);
|
||||
return bus.publish(APP_EVENT_USER_IDENTITY_CHANGED, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated `PERMISSIONS_REFRESH_REQUESTED` is a command disguised as an
|
||||
* event. Call `App.perm.refresh()` (or the legacy `App.Perms?.refresh()`)
|
||||
* directly instead of routing through the bus.
|
||||
*/
|
||||
export function publishAppPermsRefreshRequested(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppPermsRefreshRequestedPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_PERMISSIONS_REFRESH_REQUESTED, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_PERMISSIONS_REFRESH_REQUESTED, payload);
|
||||
return bus.publish(APP_EVENT_PERMISSIONS_REFRESH_REQUESTED, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Tenant switching does not currently have a module owner.
|
||||
* Apps that switch tenant should expose their own event or call
|
||||
* `App.cache.clear()` / `App.perm.invalidate()` directly. Slated for
|
||||
* removal in the orca-based migration.
|
||||
*/
|
||||
export function publishAppTenantSwitched(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppTenantSwitchedPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_TENANT_SWITCHED, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_TENANT_SWITCHED, payload);
|
||||
return bus.publish(APP_EVENT_TENANT_SWITCHED, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated `arts/connection` should publish its own
|
||||
* `CONNECTION_EVENT_*` and the application orchestrates from there.
|
||||
* Slated for removal once consumers migrate.
|
||||
*/
|
||||
export function publishAppConnectivityChanged(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppConnectivityChangedPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_CONNECTIVITY_CHANGED, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_CONNECTIVITY_CHANGED, payload);
|
||||
return bus.publish(APP_EVENT_CONNECTIVITY_CHANGED, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated `CACHE_INVALIDATE_REQUESTED` is a command disguised as an
|
||||
* event. Call `App.cache.clear()` / `App.Cache.clear()` directly
|
||||
* instead of routing through the bus.
|
||||
*/
|
||||
export function publishAppCacheInvalidateRequested(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppCacheInvalidateRequestedPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_CACHE_INVALIDATE_REQUESTED, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_CACHE_INVALIDATE_REQUESTED, payload);
|
||||
return bus.publish(APP_EVENT_CACHE_INVALIDATE_REQUESTED, payload, options);
|
||||
}
|
||||
|
||||
export function publishAppDisposeStarting(
|
||||
bus: EventPublisher<AppEventMap>,
|
||||
payload: AppDisposeStartingPayload,
|
||||
options?: BusPublishOptions
|
||||
) {
|
||||
assertEventCanFire(APP_EVENT_DISPOSE_STARTING, currentRuntime());
|
||||
assertAppEventPayloadSafe(APP_EVENT_DISPOSE_STARTING, payload);
|
||||
return bus.publish(APP_EVENT_DISPOSE_STARTING, payload, options);
|
||||
}
|
||||
|
||||
export function onAppUserIdentityChanged(
|
||||
bus: AppEventBus,
|
||||
listener: (
|
||||
event: BusEnvelope<string, AppUserIdentityChangedPayload>
|
||||
) => void | Promise<void>
|
||||
): BusSubscription {
|
||||
return bus.on(APP_EVENT_USER_IDENTITY_CHANGED, listener);
|
||||
}
|
||||
|
||||
export function onAppPermsRefreshRequested(
|
||||
bus: AppEventBus,
|
||||
listener: (
|
||||
event: BusEnvelope<string, AppPermsRefreshRequestedPayload>
|
||||
) => void | Promise<void>
|
||||
): BusSubscription {
|
||||
return bus.on(APP_EVENT_PERMISSIONS_REFRESH_REQUESTED, listener);
|
||||
}
|
||||
|
||||
export function onAppTenantSwitched(
|
||||
bus: AppEventBus,
|
||||
listener: (event: BusEnvelope<string, AppTenantSwitchedPayload>) => void | Promise<void>
|
||||
): BusSubscription {
|
||||
return bus.on(APP_EVENT_TENANT_SWITCHED, listener);
|
||||
}
|
||||
|
||||
export function onAppConnectivityChanged(
|
||||
bus: AppEventBus,
|
||||
listener: (
|
||||
event: BusEnvelope<string, AppConnectivityChangedPayload>
|
||||
) => void | Promise<void>
|
||||
): BusSubscription {
|
||||
return bus.on(APP_EVENT_CONNECTIVITY_CHANGED, listener);
|
||||
}
|
||||
|
||||
export function onAppCacheInvalidateRequested(
|
||||
bus: AppEventBus,
|
||||
listener: (
|
||||
event: BusEnvelope<string, AppCacheInvalidateRequestedPayload>
|
||||
) => void | Promise<void>
|
||||
): BusSubscription {
|
||||
return bus.on(APP_EVENT_CACHE_INVALIDATE_REQUESTED, listener);
|
||||
}
|
||||
|
||||
export function onAppDisposeStarting(
|
||||
bus: AppEventBus,
|
||||
listener: (event: BusEnvelope<string, AppDisposeStartingPayload>) => void | Promise<void>
|
||||
): BusSubscription {
|
||||
return bus.on(APP_EVENT_DISPOSE_STARTING, listener);
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createEngineBus } from '$bus';
|
||||
import {
|
||||
APP_EVENT_CONNECTIVITY_CHANGED,
|
||||
APP_EVENT_USER_IDENTITY_CHANGED,
|
||||
APP_USER_IDENTITY_CAUSE_SESSION_REVOKED,
|
||||
assertEventCanFire,
|
||||
onAppDisposeStarting,
|
||||
onAppUserIdentityChanged,
|
||||
publishAppDisposeStarting,
|
||||
publishAppUserIdentityChanged,
|
||||
type AppEventMap
|
||||
} from '../events.ts';
|
||||
import {
|
||||
isAappInvalidEventRuntimeError,
|
||||
isAappUnsafeEventPayloadError
|
||||
} from '../errors.ts';
|
||||
|
||||
describe('libs/active-app/events', () => {
|
||||
it('publishes and subscribes to public app events through helpers', () => {
|
||||
const Bus = createEngineBus<AppEventMap>();
|
||||
const identityPayloads: unknown[] = [];
|
||||
const disposePayloads: unknown[] = [];
|
||||
const offIdentity = onAppUserIdentityChanged(Bus, (event) => {
|
||||
identityPayloads.push(event.payload);
|
||||
});
|
||||
const offDispose = onAppDisposeStarting(Bus, (event) => {
|
||||
disposePayloads.push(event.payload);
|
||||
});
|
||||
|
||||
publishAppUserIdentityChanged(Bus, {
|
||||
event: 'REVOKED',
|
||||
generation: 1,
|
||||
identity: { from: 'identified', to: 'none' },
|
||||
cause: APP_USER_IDENTITY_CAUSE_SESSION_REVOKED
|
||||
});
|
||||
publishAppDisposeStarting(Bus, { cause: 'test' });
|
||||
|
||||
expect(identityPayloads).toEqual([
|
||||
{
|
||||
event: 'REVOKED',
|
||||
generation: 1,
|
||||
identity: { from: 'identified', to: 'none' },
|
||||
cause: APP_USER_IDENTITY_CAUSE_SESSION_REVOKED
|
||||
}
|
||||
]);
|
||||
expect(disposePayloads).toEqual([{ cause: 'test' }]);
|
||||
|
||||
offIdentity.unsubscribe();
|
||||
offDispose.unsubscribe();
|
||||
Bus.dispose();
|
||||
});
|
||||
|
||||
it('assertEventCanFire allows both-runtime events anywhere', () => {
|
||||
expect(() => assertEventCanFire(APP_EVENT_USER_IDENTITY_CHANGED, 'server')).not.toThrow();
|
||||
expect(() => assertEventCanFire(APP_EVENT_USER_IDENTITY_CHANGED, 'client')).not.toThrow();
|
||||
});
|
||||
|
||||
it('assertEventCanFire throws when a client-only event is fired on the server', () => {
|
||||
try {
|
||||
assertEventCanFire(APP_EVENT_CONNECTIVITY_CHANGED, 'server');
|
||||
expect.fail('expected assertion to throw');
|
||||
} catch (error) {
|
||||
expect(isAappInvalidEventRuntimeError(error)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('assertEventCanFire ignores unregistered (module) events', () => {
|
||||
expect(() => assertEventCanFire('session.lifecycle.adopted', 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('publish helpers reject sensitive app-event payload keys', () => {
|
||||
const Bus = createEngineBus<AppEventMap>();
|
||||
|
||||
try {
|
||||
publishAppUserIdentityChanged(
|
||||
Bus,
|
||||
{
|
||||
event: 'REVOKED',
|
||||
generation: 1,
|
||||
identity: { from: 'identified', to: 'none' },
|
||||
cause: APP_USER_IDENTITY_CAUSE_SESSION_REVOKED,
|
||||
credential: { token: 'secret-token' }
|
||||
} as unknown as Parameters<typeof publishAppUserIdentityChanged>[1]
|
||||
);
|
||||
expect.fail('expected unsafe payload assertion to throw');
|
||||
} catch (error) {
|
||||
expect(isAappUnsafeEventPayloadError(error)).toBe(true);
|
||||
} finally {
|
||||
Bus.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue