275 lines
9.2 KiB
TypeScript
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' },
|
|
}
|
|
}
|
|
}
|