Files
mizan/backends/mizan-ts/src/dispatch.ts

275 lines
9.2 KiB
TypeScript

/**
* Request dispatch — context GET and mutation POST handlers.
*
* Framework-agnostic. Returns plain objects. The router adapter
* (Express, Hono, etc.) converts to framework-specific responses.
*/
import { getFunction, getContextGroups } from './registry'
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
import { ANONYMOUS, type Identity } from './identity'
import type { AuthRequirement } from './types'
let _cacheSecret: string | null = null
/** Set the cache secret for origin-side caching. */
export function setCacheSecret(secret: string | null): void {
_cacheSecret = secret
}
export interface MizanResponse {
status: number
body: any
headers: Record<string, string>
}
interface AuthDenial {
status: 401 | 403
code: 'UNAUTHORIZED' | 'FORBIDDEN'
message: string
}
/**
* Check whether `identity` satisfies the stored `auth` requirement.
* Ports Django's _check_auth_requirement exactly. Returns an AuthDenial
* on failure, or null when access is allowed.
*/
function checkAuth(auth: AuthRequirement | undefined, identity: Identity): AuthDenial | null {
if (auth === undefined) return null
// Callable runs first — before the authentication gate.
if (typeof auth === 'function') {
try {
return auth(identity)
? null
: { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
} catch (e: any) {
return { status: 403, code: 'FORBIDDEN', message: e?.message || 'Access denied' }
}
}
if (!identity.isAuthenticated) {
return { status: 401, code: 'UNAUTHORIZED', message: 'Authentication required' }
}
if (auth === 'staff' && !identity.isStaff) {
return { status: 403, code: 'FORBIDDEN', message: 'Staff access required' }
}
if (auth === 'superuser' && !identity.isSuperuser) {
return { status: 403, code: 'FORBIDDEN', message: 'Superuser access required' }
}
return null
}
function authDenialResponse(denial: AuthDenial): MizanResponse {
return {
status: denial.status,
body: { error: true, code: denial.code, message: denial.message },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
/**
* Handle GET /api/mizan/ctx/:contextName/
*
* Bundles all functions in a named context into one response.
*/
export async function handleContextFetch(
contextName: string,
params: Record<string, string>,
identity: Identity = ANONYMOUS,
): Promise<MizanResponse> {
const groups = getContextGroups()
const fnNames = groups[contextName]
if (!fnNames) {
return {
status: 404,
body: { error: true, code: 'NOT_FOUND', message: `Context '${contextName}' not found` },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Auth pre-pass — run BEFORE the cache lookup so a cache HIT can never
// leak to an unauthorized caller. Any denial short-circuits, uncached.
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
const denial = checkAuth(entry.auth, identity)
if (denial) return authDenialResponse(denial)
}
// Resolve effective rev (max across functions) and cache policy (min TTL)
let effectiveRev = 0
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
}
// Origin-side cache lookup
const cacheBackend = getCache()
const cacheSecret = _cacheSecret
if (cacheBackend && cacheSecret) {
try {
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
if (cached !== null) {
return {
status: 200,
body: JSON.parse(cached),
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
}
}
} catch { /* cache miss on error */ }
}
const results: Record<string, any> = {}
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
// Filter params to only those this function declares
const fnParams: Record<string, any> = {}
for (const p of entry.params) {
if (p.name in params) fnParams[p.name] = params[p.name]
}
try {
const argValues = entry.params.map(p => fnParams[p.name])
const result = await entry.fn(...argValues)
// View path — skip (context GET is for RPC data)
if (result instanceof Response) continue
results[fnName] = result
} catch (e: any) {
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
}
// Resolve effective cache policy for origin-side cache decision
let effectiveCache: number | boolean = true
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
if (entry.cache === false) { effectiveCache = false; break }
if (typeof entry.cache === 'number') {
effectiveCache = effectiveCache === true
? entry.cache
: Math.min(effectiveCache as number, entry.cache)
}
}
// Store in origin-side cache (skip if cache=False)
if (cacheBackend && cacheSecret && effectiveCache !== false) {
try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
} catch { /* cache store failure is non-fatal */ }
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
},
}
}
/**
* Handle POST /api/mizan/call/
*
* Dispatches to a named function. Returns result + invalidation.
*/
export async function handleMutationCall(
fnName: string,
args: Record<string, any>,
identity: Identity = ANONYMOUS,
): Promise<MizanResponse> {
const entry = getFunction(fnName)
if (!entry) {
return {
status: 404,
body: { error: true, code: 'NOT_FOUND', message: `Function '${fnName}' not found` },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Reject private functions from RPC dispatch
if (entry.private) {
return {
status: 403,
body: { error: true, code: 'FORBIDDEN', message: 'Function is not client-callable' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
// Auth enforcement — after private rejection, before execution.
const denial = checkAuth(entry.auth, identity)
if (denial) return authDenialResponse(denial)
try {
const argValues = entry.params.map(p => args[p.name])
const result = await entry.fn(...argValues)
// View path — return Response directly with invalidation header
if (result instanceof Response) {
const invalidate = resolveInvalidation(entry, args)
if (invalidate) {
result.headers.set('X-Mizan-Invalidate', formatInvalidateHeader(invalidate))
}
result.headers.set('Cache-Control', 'no-store')
return {
status: result.status,
body: result,
headers: Object.fromEntries(result.headers.entries()),
}
}
// RPC path — JSON response with invalidation
const invalidate = resolveInvalidation(entry, args)
const responseData: Record<string, any> = { result }
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
}
if (invalidate) {
responseData.invalidate = invalidate
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
// Purge origin-side cache
const cb = getCache()
if (cb) {
try {
for (const entry of invalidate) {
if (typeof entry === 'string') {
cachePurge(cb, entry)
} else {
cachePurge(cb, entry.context, entry.params, _cacheSecret)
}
}
} catch { /* purge failure is non-fatal */ }
}
}
return { status: 200, body: responseData, headers }
} catch (e: any) {
return {
status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
}