@beignet/core
Version:
Core framework primitives for Beignet
480 lines (435 loc) • 12.5 kB
text/typescript
/**
* @beignet/core/entitlements
*
* Provider-neutral product access primitives for Beignet applications.
*/
type MaybePromise<T> = T | Promise<T>;
/**
* Stable app-defined capability key, such as "issues.create".
*/
export type EntitlementKey = string;
/**
* Principal whose product access is being checked.
*/
export type EntitlementSubject = {
/**
* App-defined subject type, commonly "tenant" or "account".
*/
type: string;
/**
* Stable subject identifier.
*/
id: string;
};
/**
* Input for checking whether a subject has access to a capability.
*/
export type EntitlementCheckInput<
TKey extends EntitlementKey = EntitlementKey,
> = {
/**
* Capability being checked.
*/
entitlement: TKey;
/**
* Tenant, account, or other app-owned subject whose product access is checked.
*/
subject: EntitlementSubject;
};
/**
* An entitlement decision that allows the capability.
*/
export type EntitlementAllowedDecision = {
allowed: true;
};
/**
* An entitlement decision that denies the capability.
*/
export type EntitlementDeniedDecision = {
allowed: false;
reason?: string;
code?: string;
details?: unknown;
};
/**
* Normalized entitlement decision.
*/
export type EntitlementDecision =
| EntitlementAllowedDecision
| EntitlementDeniedDecision;
/**
* Value an entitlement resolver may return.
*/
export type EntitlementResolverResult = boolean | EntitlementDecision;
/**
* Entitlement port method that produced a decision.
*/
export type EntitlementDecisionSource = "can" | "inspect" | "require";
/**
* Optional context for entitlement checks.
*/
export type EntitlementCheckOptions<TContext = unknown> = {
/**
* Application context used for the check. Observers can use this to copy
* request correlation fields without coupling the resolver to app context.
*/
ctx?: TContext;
/**
* Source to report to observers. Port methods set this automatically.
*/
source?: EntitlementDecisionSource;
};
/**
* App-facing product access port.
*/
export type EntitlementsPort<
TKey extends EntitlementKey = EntitlementKey,
TContext = unknown,
> = {
/**
* Return only whether the capability is allowed.
*/
can(
input: EntitlementCheckInput<TKey>,
options?: EntitlementCheckOptions<TContext>,
): Promise<boolean>;
/**
* Return the full allow/deny decision without throwing.
*/
inspect(
input: EntitlementCheckInput<TKey>,
options?: EntitlementCheckOptions<TContext>,
): Promise<EntitlementDecision>;
};
/**
* Function that decides whether an entitlement is granted.
*/
export type EntitlementResolver<TKey extends EntitlementKey = EntitlementKey> =
(
input: EntitlementCheckInput<TKey>,
) => MaybePromise<EntitlementResolverResult>;
/**
* Best-effort entitlement decision observation emitted by an entitlements port.
*
* Observers are diagnostic only. They do not participate in entitlement
* control flow and thrown/rejected observer errors are ignored.
*/
export type EntitlementDecisionObservation<
TContext = unknown,
TKey extends EntitlementKey = EntitlementKey,
> = {
/**
* Port helper that produced the decision.
*/
source: EntitlementDecisionSource;
/**
* Application context passed by the caller, when available.
*/
ctx?: TContext;
/**
* Capability being evaluated.
*/
entitlement: TKey;
/**
* Subject whose product access is checked.
*/
subject: EntitlementSubject;
/**
* Normalized decision, when resolver evaluation returned normally.
*/
decision?: EntitlementDecision;
/**
* Error thrown by the resolver, when evaluation failed.
*/
error?: unknown;
/**
* Resolver duration, excluding observer work.
*/
durationMs: number;
/**
* Correlation fields copied from the context when present.
*/
requestId?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
traceparent?: string;
};
/**
* Best-effort observer called after each entitlement decision or resolver
* error.
*/
export type EntitlementDecisionObserver<
TContext = unknown,
TKey extends EntitlementKey = EntitlementKey,
> = (
observation: EntitlementDecisionObservation<TContext, TKey>,
) => MaybePromise<void>;
/**
* Options for `createEntitlements(...)`.
*/
export type CreateEntitlementsOptions<
TKey extends EntitlementKey = EntitlementKey,
TContext = unknown,
> = {
/**
* Resolver that maps app-owned product state to an entitlement decision.
*/
inspect: EntitlementResolver<TKey>;
/**
* Optional best-effort observer for entitlement decisions.
*
* This hook is diagnostic only: it cannot change decisions or thrown errors.
*/
onDecision?: EntitlementDecisionObserver<TContext, TKey>;
};
/**
* Static grant map keyed by subject key.
*/
export type StaticEntitlementGrantMap<
TKey extends EntitlementKey = EntitlementKey,
> = Record<string, readonly TKey[]>;
/**
* Options for `createStaticEntitlements(...)`.
*/
export type CreateStaticEntitlementsOptions<
TKey extends EntitlementKey = EntitlementKey,
TContext = unknown,
> = {
/**
* Granted capabilities keyed by subject. The default key is
* `${subject.type}:${subject.id}`.
*/
grants: StaticEntitlementGrantMap<TKey>;
/**
* Optional subject key mapper.
*/
subjectKey?: (subject: EntitlementSubject) => string;
/**
* Default denial decision fields.
*/
denied?: Omit<EntitlementDeniedDecision, "allowed">;
/**
* Optional best-effort observer for entitlement decisions.
*/
onDecision?: EntitlementDecisionObserver<TContext, TKey>;
};
/**
* Options accepted by `requireEntitlement(...)`.
*/
export type RequireEntitlementOptions<
TKey extends EntitlementKey = EntitlementKey,
> = {
/**
* Create the error to throw instead of the framework default.
*/
error?: (
decision: EntitlementDeniedDecision,
input: EntitlementCheckInput<TKey>,
) => unknown;
};
/**
* Context shape consumed by `requireEntitlement(...)`.
*/
export type EntitlementsContext<TKey extends EntitlementKey = EntitlementKey> =
{
ports: {
entitlements: EntitlementsPort<TKey>;
};
};
/**
* Error thrown by `requireEntitlement(...)` when product access is denied.
*/
export class EntitlementRequiredError extends Error {
readonly code: string;
readonly status = 403;
readonly details?: unknown;
readonly entitlement: string;
readonly subject: EntitlementSubject;
constructor(
input: EntitlementCheckInput,
decision: EntitlementDeniedDecision = denyEntitlement(),
) {
super(
decision.reason ??
`Entitlement "${input.entitlement}" is required for this action.`,
);
this.name = "EntitlementRequiredError";
this.code = decision.code ?? "ENTITLEMENT_REQUIRED";
this.details = decision.details;
this.entitlement = input.entitlement;
this.subject = input.subject;
}
}
/**
* Create an explicit allow decision.
*/
export function allowEntitlement(): EntitlementAllowedDecision {
return { allowed: true };
}
/**
* Create an explicit deny decision.
*/
export function denyEntitlement(
reasonOrDecision?: string | Omit<EntitlementDeniedDecision, "allowed">,
): EntitlementDeniedDecision {
if (typeof reasonOrDecision === "string") {
return { allowed: false, reason: reasonOrDecision };
}
return {
allowed: false,
...reasonOrDecision,
};
}
/**
* Create an entitlement port from an app-owned resolver.
*/
export function createEntitlements<
TKey extends EntitlementKey = EntitlementKey,
TContext = unknown,
>(
options: CreateEntitlementsOptions<TKey, TContext>,
): EntitlementsPort<TKey, TContext> {
const observeDecision = createDecisionObserver(options.onDecision);
async function evaluate(
input: EntitlementCheckInput<TKey>,
checkOptions: EntitlementCheckOptions<TContext> | undefined,
): Promise<EntitlementDecision> {
const startedAt = entitlementNow();
try {
const decision = normalizeEntitlementDecision(
await options.inspect(input),
);
observeDecision({
source: checkOptions?.source ?? "inspect",
ctx: checkOptions?.ctx,
entitlement: input.entitlement,
subject: input.subject,
decision,
durationMs: entitlementNow() - startedAt,
});
return decision;
} catch (error) {
observeDecision({
source: checkOptions?.source ?? "inspect",
ctx: checkOptions?.ctx,
entitlement: input.entitlement,
subject: input.subject,
error,
durationMs: entitlementNow() - startedAt,
});
throw error;
}
}
return {
async can(input, checkOptions) {
return (await evaluate(input, { ...checkOptions, source: "can" }))
.allowed;
},
inspect(input, checkOptions) {
return evaluate(input, {
...checkOptions,
source: checkOptions?.source ?? "inspect",
});
},
};
}
/**
* Create a static entitlement port for tests, starters, and simple apps.
*/
export function createStaticEntitlements<
TKey extends EntitlementKey = EntitlementKey,
TContext = unknown,
>(
options: CreateStaticEntitlementsOptions<TKey, TContext>,
): EntitlementsPort<TKey, TContext> {
const subjectKey = options.subjectKey ?? defaultSubjectKey;
return createEntitlements<TKey, TContext>({
inspect(input) {
const granted = options.grants[subjectKey(input.subject)] ?? [];
if (granted.includes(input.entitlement)) return allowEntitlement();
return denyEntitlement({
reason: `Entitlement "${input.entitlement}" is not granted.`,
code: "ENTITLEMENT_NOT_GRANTED",
details: {
entitlement: input.entitlement,
subject: input.subject,
},
...options.denied,
});
},
onDecision: options.onDecision,
});
}
/**
* Require an entitlement or throw a framework-owned 403 error.
*/
export async function requireEntitlement<
TKey extends EntitlementKey = EntitlementKey,
>(
ctx: EntitlementsContext<TKey>,
input: EntitlementCheckInput<TKey>,
options?: RequireEntitlementOptions<TKey>,
): Promise<EntitlementAllowedDecision> {
const decision = await ctx.ports.entitlements.inspect(input, {
ctx,
source: "require",
});
if (decision.allowed) return decision;
throw options?.error
? options.error(decision, input)
: new EntitlementRequiredError(input, decision);
}
function normalizeEntitlementDecision(
result: EntitlementResolverResult,
): EntitlementDecision {
if (typeof result === "boolean") {
return result ? allowEntitlement() : denyEntitlement();
}
return result;
}
function entitlementNow(): number {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function contextStringField(ctx: unknown, field: string): string | undefined {
if (!ctx || typeof ctx !== "object") return undefined;
const value = (ctx as Record<string, unknown>)[field];
return typeof value === "string" ? value : undefined;
}
function enrichObservation<TContext, TKey extends EntitlementKey>(
observation: EntitlementDecisionObservation<TContext, TKey>,
): EntitlementDecisionObservation<TContext, TKey> {
return {
...observation,
requestId: contextStringField(observation.ctx, "requestId"),
traceId: contextStringField(observation.ctx, "traceId"),
spanId: contextStringField(observation.ctx, "spanId"),
parentSpanId: contextStringField(observation.ctx, "parentSpanId"),
traceparent: contextStringField(observation.ctx, "traceparent"),
};
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
typeof (value as { then?: unknown }).then === "function"
);
}
function createDecisionObserver<TContext, TKey extends EntitlementKey>(
observer: EntitlementDecisionObserver<TContext, TKey> | undefined,
): (observation: EntitlementDecisionObservation<TContext, TKey>) => void {
return (observation) => {
if (!observer) return;
try {
const result = observer(enrichObservation(observation));
if (isPromiseLike(result)) {
Promise.resolve(result).catch(() => undefined);
}
} catch {
// Observers are diagnostic only and must not affect entitlement checks.
}
};
}
function defaultSubjectKey(subject: EntitlementSubject): string {
return `${subject.type}:${subject.id}`;
}