alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
405 lines (357 loc) • 11.8 kB
text/typescript
import { createHash } from "node:crypto";
import { $hook, $inject, $state, Alepha, AlephaError, z } from "alepha";
import { $logger } from "alepha/logger";
import type { SecurityProvider, UserAccountToken } from "alepha/security";
import {
$action,
$route,
$sse,
type ClientRequestEntry,
type ClientRequestOptions,
type RequestConfigSchema,
ServerTimingProvider,
serverApiOptions,
} from "alepha/server";
import type { ApiRegistryResponse } from "../schemas/apiLinksResponseSchema.ts";
import { type HttpClientLink, LinkProvider } from "./LinkProvider.ts";
import { RemotePrimitiveProvider } from "./RemotePrimitiveProvider.ts";
export class ServerLinksProvider {
protected readonly serverApi = $state(serverApiOptions);
protected readonly alepha = $inject(Alepha);
protected readonly linkProvider = $inject(LinkProvider);
protected readonly remoteProvider = $inject(RemotePrimitiveProvider);
protected readonly serverTimingProvider = $inject(ServerTimingProvider);
protected readonly log = $logger();
/**
* Resolved once on start. Undefined when alepha/security is not loaded.
*/
protected securityProvider: SecurityProvider | undefined;
/**
* Cache of serialized JSON by role key.
* Key = sorted roles joined by comma (empty string for unauthenticated).
*/
protected registryCache = new Map<string, RegistryCacheEntry>();
public get prefix() {
return this.serverApi.prefix;
}
public readonly onRoute = $hook({
on: "configure",
handler: () => {
// convert all $action to local links
for (const action of this.alepha.primitives($action)) {
const bodyContentType = action.getBodyContentType();
this.linkProvider.registerLink({
name: action.name,
group: action.group,
schema: action.options.schema,
contentType:
bodyContentType && bodyContentType !== "application/json"
? bodyContentType
: undefined,
secured: action.middlewares.some((m) => m?.name === "$secure")
? (action.middlewares.find((m) => m?.name === "$secure")?.options ??
true)
: undefined,
method: action.method === "GET" ? undefined : action.method,
prefix: action.prefix,
path: action.path,
// by local, we mean that it can be called directly via the handler
handler: (
config: ClientRequestEntry<RequestConfigSchema>,
options: ClientRequestOptions = {},
) => action.run(config, options),
});
}
// convert all $sse to local links
for (const sse of this.alepha.primitives($sse)) {
this.linkProvider.registerLink({
name: sse.name,
group: sse.group,
kind: "sse",
schema: {
body: sse.schema?.body,
},
method: "POST",
prefix: sse.prefix,
path: sse.path,
handler: async (config) => {
return sse.run(config as any) as any;
},
});
}
},
});
protected readonly onStart = $hook({
on: "start",
handler: () => {
try {
this.securityProvider =
this.alepha.inject<SecurityProvider>("SecurityProvider");
} catch {
this.log.debug(
"Security module is not loaded — permission checks are disabled",
);
}
},
});
/**
* API registry endpoint — returns all available actions for the user.
*
* Response is filtered by the user's permissions.
* Cached by role set with ETag support.
*/
public readonly links = $route({
path: LinkProvider.path.apiLinks,
handler: async ({ user, headers, reply }) => {
const roleKey = user?.roles?.slice().sort().join(",") ?? "";
const cached = this.registryCache.get(roleKey);
if (cached) {
// ETag match → 304
if (headers["if-none-match"] === cached.etag) {
reply.setStatus(304);
reply.setHeader("etag", cached.etag);
return;
}
reply.setHeader("etag", cached.etag);
reply.setHeader("content-type", "application/json");
reply.body = cached.json;
return;
}
// Cache miss — compute, serialize, cache
const response = await this.getUserApiLinks({
user,
authorization: headers.authorization,
});
const json = JSON.stringify(response);
const etag = `"${createHash("md5").update(json).digest("hex")}"`;
this.registryCache.set(roleKey, { json, etag });
reply.setHeader("etag", etag);
reply.setHeader("content-type", "application/json");
reply.body = json;
},
});
/**
* On-demand schemas endpoint — returns JSON Schemas for requested actions.
*
* Schemas are filtered by the user's permissions (same logic as the registry).
*/
public readonly schemas = $route({
method: "POST",
path: "/api/_links/schemas",
schema: {
body: z.object({
actions: z.array(z.text()),
}),
response: z.record(
z.text(),
z.object({
body: z.string().optional(),
response: z.string().optional(),
}),
),
},
handler: async ({ body, user }) => {
const result: Record<string, { body?: string; response?: string }> = {};
for (const name of body.actions) {
const link = this.linkProvider
.getServerLinks()
.find((l) => l.name === name && !l.host);
if (!link) continue;
if (!this.isLinkAccessible(link, user)) continue;
const entry: { body?: string; response?: string } = {};
if (link.schema?.body) {
entry.body = JSON.stringify(link.schema.body);
}
if (link.schema?.response) {
entry.response = JSON.stringify(link.schema.response);
}
result[name] = entry;
}
return result as any;
},
});
/**
* Batch endpoint — execute multiple actions in a single HTTP request.
* Each sub-request is independent: errors in one don't affect others.
*/
public readonly batch = $route({
method: "POST",
path: "/api/_batch",
schema: {
body: z.array(
z.object({
action: z.text(),
params: z.record(z.text(), z.any()).optional(),
query: z.record(z.text(), z.any()).optional(),
body: z.record(z.text(), z.any()).optional(),
}),
),
response: z.array(
z.object({
action: z.text(),
status: z.integer(),
data: z.any().optional(),
error: z.text().optional(),
}),
),
},
handler: async ({ body }) => {
if (body.length > ServerLinksProvider.MAX_BATCH_SIZE) {
throw new AlephaError(
`Batch size ${body.length} exceeds maximum of ${ServerLinksProvider.MAX_BATCH_SIZE}`,
);
}
const results = await Promise.allSettled(
body.map((entry) =>
this.linkProvider.follow(entry.action, {
params: entry.params as any,
query: entry.query as any,
body: entry.body as any,
}),
),
);
return results.map((result, i) => {
const action = body[i].action;
if (result.status === "fulfilled") {
return { action, status: 200, data: result.value };
}
const reason = result.reason;
const status = reason?.status ?? 500;
const message = reason?.message ?? "Internal error";
this.log.warn("Batch action failed", {
action,
status,
message,
error: reason,
});
return { action, status, error: message };
});
},
});
protected static readonly MAX_BATCH_SIZE = 20;
/**
* Retrieves API registry for the user based on their permissions.
* Will check on local links and remote links.
*/
public async getUserApiLinks(
options: GetApiLinksOptions,
): Promise<ApiRegistryResponse> {
const { user } = options;
const { securityProvider } = this;
const securityPermissions =
securityProvider && user
? securityProvider.getPermissions(user)
: undefined;
const actions: Record<string, any> = {};
const permissions: string[] = [];
// Collect permissions not related to $action (virtual permissions)
for (const permission of securityPermissions ?? []) {
if (
!permission.path &&
!permission.method &&
permission.name &&
permission.group
) {
permissions.push(`${permission.group}:${permission.name}`);
}
}
// Add local links
for (const link of this.linkProvider.getServerLinks()) {
// SKIP REMOTE LINKS, remote links are handled separately for security
if (link.host) continue;
if (!this.isLinkAccessible(link, user)) continue;
actions[link.name] = {
path: link.path,
method: link.method || undefined,
kind: link.kind,
contentType: link.contentType,
service: link.service,
};
}
this.serverTimingProvider.beginTiming("fetchRemoteLinks");
// TODO: remote links can be cached by user.roles
const remoteResults = await Promise.all(
this.remoteProvider
.getRemotes()
.filter((it) => it.proxy) // add only "proxy" remotes
.map(async (remote) => {
const registry = await remote.links(options);
return { remote, registry };
}),
);
for (const { remote, registry } of remoteResults) {
const remotePrefix = registry.prefix ?? "/api";
// Merge remote actions
for (const [name, action] of Object.entries(registry.actions)) {
let path = action.path.replace(remotePrefix, "");
if (action.service) {
path = `/${action.service}${path}`;
}
actions[name] = {
path,
method: action.method,
contentType: action.contentType,
service: remote.name,
};
}
// Merge remote permissions
if (registry.permissions) {
permissions.push(...registry.permissions);
}
}
this.serverTimingProvider.endTiming("fetchRemoteLinks");
return {
prefix: this.serverApi.prefix,
actions,
permissions: permissions.length > 0 ? permissions : undefined,
};
}
/**
* Check if a link is accessible by the given user based on security rules.
*/
protected isLinkAccessible(
link: HttpClientLink,
user?: UserAccountToken,
): boolean {
const { securityProvider } = this;
if (securityProvider && link.secured) {
if (!user) return false;
if (typeof link.secured === "object") {
// issuer check
if (
link.secured.issuers?.length &&
(!user.realm || !link.secured.issuers.includes(user.realm))
) {
return false;
}
// role check
if (link.secured.roles?.length) {
const hasRole = link.secured.roles.some((role: string) =>
user.roles?.includes(role),
);
if (!hasRole) return false;
}
// explicit permission check
if (link.secured.permissions?.length) {
for (const perm of link.secured.permissions) {
const result = securityProvider.checkPermission(
perm,
...(user.roles ?? []),
);
if (!result.isAuthorized) return false;
}
}
}
// link.secured === true → auth only, user is already checked above
}
return true;
}
}
export interface GetApiLinksOptions {
user?: UserAccountToken;
authorization?: string;
}
interface RegistryCacheEntry {
json: string;
etag: string;
}