@bhooai/nexus-core 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * defineRoutes — declarative route collector used by routes/*.ts files.
3
+ *
4
+ * Auto-discovered by `createNexusApp()` from the backend's src/routes/
5
+ * folder. Each file's default export is consumed by the framework.
6
+ */
7
+ import type { Handler, Middleware } from '../http/context.js';
8
+
9
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
10
+
11
+ export interface UploadSpec {
12
+ field: string;
13
+ disk?: string;
14
+ dir?: string;
15
+ maxSize?: number;
16
+ mime?: string[];
17
+ maxFiles?: number;
18
+ transform?: {
19
+ width?: number;
20
+ height?: number;
21
+ fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
22
+ format?: 'webp' | 'jpeg' | 'png' | 'avif';
23
+ };
24
+ }
25
+
26
+ export interface RouteDef {
27
+ method: HttpMethod;
28
+ path: string;
29
+ handler: Handler;
30
+ middleware?: Middleware[];
31
+ /** Route-level upload handler configuration. */
32
+ upload?: UploadSpec;
33
+ /** Per-route rate limit override (requests per minute). */
34
+ rateLimit?: number;
35
+ /** Marks the route as public, bypassing auth middleware. */
36
+ public?: boolean;
37
+ }
38
+
39
+ export interface RoutesFile {
40
+ routes: RouteDef[];
41
+ /** Optional URL prefix applied to every route in this file. */
42
+ prefix?: string;
43
+ }
44
+
45
+ /** Build a RoutesFile from a list of route definitions. */
46
+ export function defineRoutes(routes: RouteDef[], opts?: { prefix?: string }): RoutesFile {
47
+ return opts?.prefix ? { routes, prefix: opts.prefix } : { routes };
48
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Loader — auto-discovers convention folders under a backend's src/.
3
+ *
4
+ * Scans a backend src/ root for the standard Nexus folders and imports
5
+ * matching files. Each folder has a defined matching pattern. Returns a
6
+ * manifest of discovered modules.
7
+ *
8
+ * Discovery is synchronous for simplicity — the dynamic imports happen lazily
9
+ * via `await import()` inside the discovery methods.
10
+ */
11
+ import { readdir, stat } from 'node:fs/promises';
12
+ import { join, resolve, extname, basename } from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { existsSync } from 'node:fs';
15
+
16
+ export interface DiscoveredFile {
17
+ /** Absolute file path. */
18
+ path: string;
19
+ /** File name without extension (e.g. "users" from routes/users.ts). */
20
+ name: string;
21
+ /** Subdirectory inside the convention folder ("" for direct children). */
22
+ subdir: string;
23
+ }
24
+
25
+ async function listRecursive(dir: string, subdir: string = ''): Promise<DiscoveredFile[]> {
26
+ const out: DiscoveredFile[] = [];
27
+ if (!existsSync(dir)) return out;
28
+ const entries = await readdir(dir, { withFileTypes: true });
29
+ for (const e of entries) {
30
+ const full = join(dir, e.name);
31
+ if (e.isDirectory()) {
32
+ const children = await listRecursive(full, subdir ? `${subdir}/${e.name}` : e.name);
33
+ out.push(...children);
34
+ } else if (e.isFile()) {
35
+ const ext = extname(e.name);
36
+ if (ext !== '.ts' && ext !== '.js' && ext !== '.mts' && ext !== '.mjs') continue;
37
+ const name = basename(e.name, ext);
38
+ out.push({ path: full, name, subdir });
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ async function* iterateFiles(dir: string, pattern?: RegExp) {
45
+ const files = await listRecursive(dir);
46
+ for (const f of files) {
47
+ if (pattern && !pattern.test(f.name)) continue;
48
+ yield f;
49
+ }
50
+ }
51
+
52
+ export interface DiscoveryResult {
53
+ routes: DiscoveredFile[];
54
+ graphql: DiscoveredFile[];
55
+ rooms: DiscoveredFile[];
56
+ mailables: DiscoveredFile[];
57
+ errorHandler: DiscoveredFile | null;
58
+ errorPages: DiscoveredFile[];
59
+ events: DiscoveredFile[];
60
+ listeners: DiscoveredFile[];
61
+ jobs: DiscoveredFile[];
62
+ policies: DiscoveredFile[];
63
+ providers: DiscoveredFile[];
64
+ seeds: DiscoveredFile[];
65
+ migrations: DiscoveredFile[];
66
+ plugins: DiscoveredFile[];
67
+ config: DiscoveredFile[];
68
+ }
69
+
70
+ /**
71
+ * Walk a backend src/ root and produce a manifest of all auto-discovered
72
+ * convention folders. Missing folders are simply absent from the result.
73
+ */
74
+ export async function discoverBackend(srcRoot: string): Promise<DiscoveryResult> {
75
+ const abs = resolve(srcRoot);
76
+
77
+ const [routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config] =
78
+ await Promise.all([
79
+ collect(join(abs, 'routes'), /\.(ts|js)$/),
80
+ collect(join(abs, 'graphql'), /\.graph\.(ts|js)$/),
81
+ collect(join(abs, 'ws'), /\.room\.(ts|js)$/),
82
+ collect(join(abs, 'mail', 'mailables'), /\.(ts|js)$/),
83
+ collect(join(abs, 'errors', 'pages'), /\.html$/),
84
+ collect(join(abs, 'events'), /\.(ts|js)$/),
85
+ collect(join(abs, 'listeners'), /^On.*\.(ts|js)$/),
86
+ collect(join(abs, 'jobs'), /Job\.(ts|js)$/),
87
+ collect(join(abs, 'policies'), /Policy\.(ts|js)$/),
88
+ collect(join(abs, 'providers'), /ServiceProvider\.(ts|js)$/),
89
+ collect(join(abs, 'database', 'seeds'), /Seeder\.(ts|js)$/),
90
+ collect(join(abs, 'database', 'migrations'), /^\d{4}_\d{2}_\d{2}_\d{6}_.*\.(ts|js)$/),
91
+ collectPluginDirs(join(abs, 'plugins')),
92
+ collect(join(abs, 'config'), /\.(ts|js)$/),
93
+ ]);
94
+
95
+ // errors/Handler.ts is a single well-known file.
96
+ const errorHandlerPath = join(abs, 'errors', 'Handler.ts');
97
+ const errorHandler: DiscoveredFile | null = existsSync(errorHandlerPath)
98
+ ? { path: errorHandlerPath, name: 'Handler', subdir: '' }
99
+ : null;
100
+
101
+ return { routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config, errorHandler };
102
+ }
103
+
104
+ async function collect(dir: string, pattern: RegExp): Promise<DiscoveredFile[]> {
105
+ if (!existsSync(dir)) return [];
106
+ const out: DiscoveredFile[] = [];
107
+ for await (const f of iterateFiles(dir)) {
108
+ // Apply pattern to filename only (not full path)
109
+ if (!pattern.test(basename(f.path))) continue;
110
+ out.push(f);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ async function collectPluginDirs(dir: string): Promise<DiscoveredFile[]> {
116
+ if (!existsSync(dir)) return [];
117
+ const out: DiscoveredFile[] = [];
118
+ const entries = await readdir(dir, { withFileTypes: true });
119
+ for (const e of entries) {
120
+ if (!e.isDirectory()) continue;
121
+ const entry = join(dir, e.name, 'index.ts');
122
+ if (existsSync(entry)) {
123
+ out.push({ path: entry, name: e.name, subdir: '' });
124
+ continue;
125
+ }
126
+ const entryJs = join(dir, e.name, 'index.js');
127
+ if (existsSync(entryJs)) {
128
+ out.push({ path: entryJs, name: e.name, subdir: '' });
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** Import a discovered file via dynamic import. */
135
+ export async function importDiscovered(file: DiscoveredFile): Promise<unknown> {
136
+ const url = pathToFileURL(file.path).href;
137
+ return await import(url);
138
+ }
139
+
140
+ /** Import a discovered file and return its default export. */
141
+ export async function importDefault<T = unknown>(file: DiscoveredFile): Promise<T> {
142
+ const mod: any = await importDiscovered(file);
143
+ return (mod?.default ?? mod) as T;
144
+ }
@@ -0,0 +1,121 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>{{STATUS}} — {{TITLE}}</title>
7
+ <style>
8
+ :root {
9
+ --nx-bg: #0b1220;
10
+ --nx-fg: #e2e8f0;
11
+ --nx-muted: #94a3b8;
12
+ --nx-accent: #60a5fa;
13
+ --nx-card: #0f172a;
14
+ --nx-border: #1e293b;
15
+ }
16
+ @media (prefers-color-scheme: light) {
17
+ :root {
18
+ --nx-bg: #f8fafc;
19
+ --nx-fg: #0b1220;
20
+ --nx-muted: #475569;
21
+ --nx-accent: #2563eb;
22
+ --nx-card: #ffffff;
23
+ --nx-border: #e2e8f0;
24
+ }
25
+ }
26
+ * { box-sizing: border-box; }
27
+ html, body { height: 100%; margin: 0; }
28
+ body {
29
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
30
+ background: var(--nx-bg);
31
+ color: var(--nx-fg);
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ padding: 2rem;
36
+ }
37
+ .card {
38
+ background: var(--nx-card);
39
+ border: 1px solid var(--nx-border);
40
+ border-radius: 12px;
41
+ padding: 3rem 2.5rem;
42
+ max-width: 34rem;
43
+ width: 100%;
44
+ text-align: center;
45
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
46
+ }
47
+ .code {
48
+ font-size: 4rem;
49
+ font-weight: 700;
50
+ color: var(--nx-accent);
51
+ letter-spacing: -0.04em;
52
+ margin: 0 0 0.5rem 0;
53
+ line-height: 1;
54
+ }
55
+ h1 {
56
+ font-size: 1.5rem;
57
+ margin: 0 0 1rem 0;
58
+ font-weight: 600;
59
+ }
60
+ p {
61
+ color: var(--nx-muted);
62
+ margin: 0 0 1.5rem 0;
63
+ line-height: 1.5;
64
+ }
65
+ .actions {
66
+ display: flex;
67
+ gap: 0.75rem;
68
+ justify-content: center;
69
+ flex-wrap: wrap;
70
+ }
71
+ .btn {
72
+ display: inline-block;
73
+ padding: 0.625rem 1.125rem;
74
+ border-radius: 8px;
75
+ text-decoration: none;
76
+ font-weight: 500;
77
+ font-size: 0.9375rem;
78
+ transition: opacity 0.15s ease;
79
+ cursor: pointer;
80
+ border: none;
81
+ }
82
+ .btn-primary {
83
+ background: var(--nx-accent);
84
+ color: white;
85
+ }
86
+ .btn-primary:hover { opacity: 0.9; }
87
+ .btn-ghost {
88
+ background: transparent;
89
+ color: var(--nx-fg);
90
+ border: 1px solid var(--nx-border);
91
+ }
92
+ .btn-ghost:hover { background: rgba(148,163,184,0.08); }
93
+ .foot {
94
+ margin-top: 2rem;
95
+ font-size: 0.8125rem;
96
+ color: var(--nx-muted);
97
+ opacity: 0.7;
98
+ }
99
+ code {
100
+ background: rgba(148,163,184,0.15);
101
+ padding: 0.125rem 0.375rem;
102
+ border-radius: 4px;
103
+ font-size: 0.875em;
104
+ }
105
+ </style>
106
+ </head>
107
+ <body>
108
+ <main class="card">
109
+ <div class="code">{{CODE}}</div>
110
+ <h1>{{TITLE}}</h1>
111
+ <p>{{MESSAGE}}</p>
112
+ <div class="actions">
113
+ <a class="btn btn-primary" href="/">Home</a>
114
+ <button class="btn btn-ghost" onclick="location.reload()">Retry</button>
115
+ </div>
116
+ {{#REQUEST_ID}}
117
+ <div class="foot">Request ID: <code>{{REQUEST_ID}}</code></div>
118
+ {{/REQUEST_ID}}
119
+ </main>
120
+ </body>
121
+ </html>
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ErrorPages — renders a templated HTML page for a given HTTP status.
3
+ *
4
+ * Reads `mail/pages/<status>.html` (project-level) if present, else falls back
5
+ * to the framework default template. Substitutes simple {{PLACEHOLDER}}
6
+ * markers. Production-safe (no stack traces).
7
+ */
8
+ import { readFile } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+ import { existsSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+
15
+ const DEFAULT_MESSAGES: Record<number, { title: string; message: string }> = {
16
+ 400: { title: 'Bad Request', message: 'The request could not be understood. Check the body and try again.' },
17
+ 401: { title: 'Unauthorized', message: 'Authentication is required to access this resource.' },
18
+ 403: { title: 'Forbidden', message: 'You do not have permission to access this resource.' },
19
+ 404: { title: 'Not Found', message: 'The page you were looking for does not exist.' },
20
+ 405: { title: 'Method Not Allowed', message: 'The requested method is not supported on this endpoint.' },
21
+ 408: { title: 'Request Timeout', message: 'The server timed out waiting for the request.' },
22
+ 409: { title: 'Conflict', message: 'The request conflicts with the current state of the resource.' },
23
+ 413: { title: 'Payload Too Large', message: 'The uploaded file or body exceeds the allowed size.' },
24
+ 422: { title: 'Unprocessable Entity', message: 'The request was well-formed but could not be processed.' },
25
+ 429: { title: 'Too Many Requests', message: 'Rate limit exceeded. Please slow down and retry.' },
26
+ 500: { title: 'Internal Server Error', message: 'Something went wrong on our side. We are looking into it.' },
27
+ 502: { title: 'Bad Gateway', message: 'Received an invalid response from an upstream service.' },
28
+ 503: { title: 'Service Unavailable', message: 'Service is temporarily down. Please retry shortly.' },
29
+ 504: { title: 'Gateway Timeout', message: 'Upstream service did not respond in time.' },
30
+ };
31
+
32
+ export interface ErrorPageRenderContext {
33
+ requestId?: string;
34
+ /** Override default message (e.g. from a caught error). */
35
+ message?: string;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export class ErrorPages {
40
+ constructor(private projectRoot: string) {}
41
+
42
+ /** Render the HTML for a given status. */
43
+ async render(status: number, ctx: ErrorPageRenderContext = {}): Promise<string> {
44
+ const custom = await this.tryCustomPage(status);
45
+ const template = custom ?? (await this.defaultTemplate());
46
+ const defaults = DEFAULT_MESSAGES[status] ?? {
47
+ title: `Error ${status}`,
48
+ message: 'An unexpected error occurred.',
49
+ };
50
+ const message = ctx.message ?? defaults.message;
51
+ return substitute(template, {
52
+ CODE: String(status),
53
+ STATUS: String(status),
54
+ TITLE: defaults.title,
55
+ MESSAGE: message,
56
+ REQUEST_ID: ctx.requestId ?? '',
57
+ });
58
+ }
59
+
60
+ private async tryCustomPage(status: number): Promise<string | null> {
61
+ const candidates = [
62
+ join(this.projectRoot, 'src', 'errors', 'pages', `${status}.html`),
63
+ join(this.projectRoot, 'src', 'errors', 'pages', 'error.html'), // generic catch-all
64
+ ];
65
+ for (const cand of candidates) {
66
+ if (existsSync(cand)) {
67
+ try {
68
+ return await readFile(cand, 'utf-8');
69
+ } catch { /* ignore */ }
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ private async defaultTemplate(): Promise<string> {
76
+ // Bundled next to this module at errorPages/_base.html.
77
+ const candidate = join(HERE, 'errorPages', '_base.html');
78
+ if (existsSync(candidate)) {
79
+ try { return await readFile(candidate, 'utf-8'); } catch { /* ignore */ }
80
+ }
81
+ return INLINE_FALLBACK;
82
+ }
83
+ }
84
+
85
+ /** Substitute {{KEY}} markers + handle {{#KEY}}…{{/KEY}} blocks. */
86
+ function substitute(template: string, vars: Record<string, unknown>): string {
87
+ let out = template;
88
+ // Conditional blocks: {{#KEY}}content{{/KEY}} — shown only when KEY is truthy
89
+ out = out.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_m, key: string, content: string) => {
90
+ const v = vars[key];
91
+ if (v === undefined || v === null || v === '' || v === false) return '';
92
+ return substitute(content, vars);
93
+ });
94
+ // Simple {{KEY}}
95
+ out = out.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => {
96
+ const v = vars[key];
97
+ return v === undefined || v === null ? '' : escapeHtml(String(v));
98
+ });
99
+ return out;
100
+ }
101
+
102
+ function escapeHtml(s: string): string {
103
+ return s
104
+ .replace(/&/g, '&amp;')
105
+ .replace(/</g, '&lt;')
106
+ .replace(/>/g, '&gt;')
107
+ .replace(/"/g, '&quot;')
108
+ .replace(/'/g, '&#39;');
109
+ }
110
+
111
+ const INLINE_FALLBACK = `<!doctype html><html><head><meta charset="utf-8"><title>{{CODE}}</title><style>
112
+ body{font-family:system-ui;background:#0b1220;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;padding:2rem}
113
+ main{max-width:32rem;text-align:center;background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:3rem 2rem}
114
+ .c{font-size:4rem;color:#60a5fa;margin:0 0 .5rem;font-weight:700;letter-spacing:-.04em}
115
+ h1{font-size:1.5rem;margin:0 0 1rem;font-weight:600} p{color:#94a3b8;line-height:1.5;margin:0 0 1.5rem}
116
+ a{display:inline-block;padding:.625rem 1.125rem;border-radius:8px;background:#60a5fa;color:#fff;text-decoration:none}
117
+ </style></head><body><main><div class="c">{{CODE}}</div><h1>{{TITLE}}</h1><p>{{MESSAGE}}</p><a href="/">Home</a></main></body></html>`;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Domain events + in-process event bus.
3
+ *
4
+ * Events are plain classes; Listeners subscribe via class reference.
5
+ * The bus supports sync + async handlers, with isolated error capture so a
6
+ * failing listener does not break the dispatcher's request path.
7
+ */
8
+
9
+ type AnyEvent = object;
10
+
11
+ export interface EventListener<T extends AnyEvent = AnyEvent> {
12
+ /** Handle the event. May be async. */
13
+ handle(event: T): Promise<void> | void;
14
+ }
15
+
16
+ type EventClass<T extends AnyEvent = AnyEvent> = new (...args: never[]) => T;
17
+ type ListenerClass<T extends AnyEvent = AnyEvent> = new () => EventListener<T>;
18
+
19
+ interface Sub {
20
+ eventName: string;
21
+ listener: EventListener<AnyEvent>;
22
+ }
23
+
24
+ /**
25
+ * In-process event bus. For cross-process / cross-replica eventing, swap the
26
+ * in-memory registry for a Redis-backed adapter (same API).
27
+ */
28
+ export class EventBus {
29
+ private subs: Sub[] = [];
30
+
31
+ /** Subscribe a listener instance to an event class. */
32
+ on<T extends AnyEvent>(event: EventClass<T>, listener: EventListener<T>): this {
33
+ this.subs.push({ eventName: event.name, listener: listener as EventListener<AnyEvent> });
34
+ return this;
35
+ }
36
+
37
+ /** Register a listener class (instantiated lazily on first event). */
38
+ register<T extends AnyEvent>(ListenerCtor: ListenerClass<T>, EventCtor: EventClass<T>): this {
39
+ const listener = new ListenerCtor();
40
+ return this.on(EventCtor, listener);
41
+ }
42
+
43
+ /** Dispatch synchronously; listener errors are logged but not thrown. */
44
+ async dispatch<T extends AnyEvent>(event: T): Promise<void> {
45
+ const name = (event as { constructor: { name: string } }).constructor.name;
46
+ const target = this.subs.filter((s) => s.eventName === name);
47
+ for (const { listener } of target) {
48
+ try {
49
+ await listener.handle(event);
50
+ } catch (err) {
51
+ // Listener errors must not break the request path.
52
+ console.error(`[events] listener for ${name} failed:`, err);
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Synchronous dispatch; fires-and-forgets with attached catch. */
58
+ dispatchSync<T extends AnyEvent>(event: T): void {
59
+ void this.dispatch(event).catch((err) => {
60
+ console.error('[events] dispatch failed:', err);
61
+ });
62
+ }
63
+
64
+ /** Number of registered listeners (any event). */
65
+ get size(): number {
66
+ return this.subs.length;
67
+ }
68
+ }
69
+
70
+ /** Base class for domain events. Subclassing is optional — plain objects work too. */
71
+ export abstract class DomainEvent {
72
+ readonly occurredAt: Date = new Date();
73
+ }
74
+
75
+ /** Base listener class. Subclass + implement `handle`. */
76
+ export abstract class Listener<T extends AnyEvent = AnyEvent> implements EventListener<T> {
77
+ abstract handle(event: T): Promise<void> | void;
78
+ }
79
+
80
+ /** Global default bus. Apps may construct their own for isolation. */
81
+ export const eventBus = new EventBus();
@@ -0,0 +1,14 @@
1
+ export * from './createNexusApp.js';
2
+ export * from './defineRoutes.js';
3
+ export * from './discover.js';
4
+ export * from './events.js';
5
+ export * from './policies.js';
6
+ export * from './FormRequest.js';
7
+ export * from './Resource.js';
8
+ export * from './ServiceProvider.js';
9
+ export * from './Job.js';
10
+ export * from './Mailable.js';
11
+ export * from './ErrorHandler.js';
12
+ export * from './Seeder.js';
13
+ export * from './Storage.js';
14
+ export * from './maintenance.js';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Maintenance mode (`nexus down`/`nexus up`).
3
+ *
4
+ * When active, middleware intercepts requests and serves the branded
5
+ * maintenance page (errors/pages/maintenance.html) with status 503 and a
6
+ * Retry-After hint. Health checks stay reachable for orchestrators.
7
+ *
8
+ * State lives in `.nexus-down` file at project root (touch = on, rm = off).
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { writeFile, unlink, mkdir } from 'node:fs/promises';
12
+ import { dirname, resolve, join } from 'node:path';
13
+ import { readFile } from 'node:fs/promises';
14
+ import type { Middleware } from '../http/context.js';
15
+
16
+ const FLAG_NAME = '.nexus-down';
17
+
18
+ export function maintenanceFlagPath(projectRoot: string): string {
19
+ return join(resolve(projectRoot), FLAG_NAME);
20
+ }
21
+
22
+ export async function setMaintenance(projectRoot: string, on: boolean, message?: string): Promise<void> {
23
+ const flag = maintenanceFlagPath(projectRoot);
24
+ if (on) {
25
+ await mkdir(dirname(flag), { recursive: true });
26
+ const payload = JSON.stringify({ since: new Date().toISOString(), message: message ?? 'Under maintenance' });
27
+ await writeFile(flag, payload, 'utf-8');
28
+ } else {
29
+ if (existsSync(flag)) await unlink(flag);
30
+ }
31
+ }
32
+
33
+ export function isMaintenance(projectRoot: string): boolean {
34
+ return existsSync(maintenanceFlagPath(projectRoot));
35
+ }
36
+
37
+ export async function maintenanceMessage(projectRoot: string): Promise<string> {
38
+ if (!isMaintenance(projectRoot)) return '';
39
+ try {
40
+ const raw = await readFile(maintenanceFlagPath(projectRoot), 'utf-8');
41
+ const parsed = JSON.parse(raw) as { message?: string };
42
+ return parsed.message ?? 'Under maintenance';
43
+ } catch {
44
+ return 'Under maintenance';
45
+ }
46
+ }
47
+
48
+ export interface MaintenanceMiddlewareOpts {
49
+ projectRoot: string;
50
+ /** Paths always allowed through (defaults include /health). */
51
+ allowPaths?: string[];
52
+ /** HTML body for maintenance responses. Falls back to a tiny inline page. */
53
+ pageHtml?: string;
54
+ /** seconds; defaults to 60. */
55
+ retryAfter?: number;
56
+ }
57
+
58
+ export function maintenanceMiddleware(opts: MaintenanceMiddlewareOpts): Middleware {
59
+ const allow = new Set(opts.allowPaths ?? ['/health', '/favicon.ico']);
60
+ return async (ctx, next) => {
61
+ if (!isMaintenance(opts.projectRoot)) return next();
62
+ if (allow.has(ctx.path)) return next();
63
+ const acceptsHtml = ((ctx.headers.accept as string) ?? '').includes('text/html');
64
+ ctx.setHeader('retry-after', String(opts.retryAfter ?? 60));
65
+ ctx.setHeader('cache-control', 'no-store');
66
+ if (acceptsHtml) {
67
+ const html = opts.pageHtml ?? defaultPage();
68
+ ctx.html(html, 503);
69
+ } else {
70
+ ctx.json({ error: { code: 'MAINTENANCE', message: 'Service under maintenance' } }, 503);
71
+ }
72
+ };
73
+ }
74
+
75
+ function defaultPage(): string {
76
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Maintenance</title>
77
+ <style>body{font-family:system-ui;background:#0b1220;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
78
+ main{max-width:32rem;text-align:center}h1{font-size:2rem;margin:0 0 1rem}p{color:#94a3b8}</style></head>
79
+ <body><main><h1>We'll be right back</h1><p>Service under maintenance. Please retry shortly.</p></main></body></html>`;
80
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Policies — Laravel-style authorization rules keyed by resource type.
3
+ *
4
+ * Usage:
5
+ * class UserPolicy extends Policy<User> {
6
+ * view(actor, target) { return actor.id === target.id || actor.role === 'admin'; }
7
+ * update(actor, target) { return actor.id === target.id; }
8
+ * delete(actor, target) { return actor.role === 'admin'; }
9
+ * }
10
+ *
11
+ * policyRegistry.register(User, new UserPolicy());
12
+ * ctx.authorize('update', targetUser); // throws 403 if denied
13
+ */
14
+
15
+ export type PolicyAbility<TSubject, TResource> = (actor: TSubject, resource: TResource) => boolean | Promise<boolean>;
16
+
17
+ export abstract class Policy<TSubject = unknown, TResource = unknown> {
18
+ /** Default deny-all if ability not defined. Subclasses override any method. */
19
+ [ability: string]: unknown;
20
+ }
21
+
22
+ interface PolicyEntry {
23
+ resourceKey: { name: string };
24
+ policy: Record<string, PolicyAbility<unknown, unknown>>;
25
+ }
26
+
27
+ export class PolicyRegistry {
28
+ private entries: PolicyEntry[] = [];
29
+
30
+ register<TResource>(ResourceCtor: new (...args: never[]) => TResource, policy: Record<string, PolicyAbility<unknown, unknown>>): this {
31
+ this.entries.push({ resourceKey: ResourceCtor, policy });
32
+ return this;
33
+ }
34
+
35
+ /** Run a policy check. Returns true if allowed, false if denied, null if no policy matched. */
36
+ async check<TSubject, TResource>(
37
+ ability: string,
38
+ actor: TSubject,
39
+ resource: TResource,
40
+ ): Promise<boolean | null> {
41
+ if (resource === null || resource === undefined) return null;
42
+ const ctor = (resource as { constructor?: { name: string } }).constructor;
43
+ if (!ctor) return null;
44
+ const found = this.entries.find((e) => e.resourceKey.name === ctor.name);
45
+ if (!found) return null;
46
+ const fn = found.policy[ability];
47
+ if (!fn) return false; // deny-by-default on missing ability
48
+ return !!(await fn(actor, resource));
49
+ }
50
+ }
51
+
52
+ /** Default singleton registry. */
53
+ export const policyRegistry = new PolicyRegistry();
@@ -132,24 +132,4 @@ export const defaults: NexusConfig = {
132
132
  host: 'localhost',
133
133
  enabled: true,
134
134
  },
135
- cluster: {
136
- enabled: false,
137
- failOpenSingleNode: true,
138
- lbHost: '127.0.0.1',
139
- lbPort: 8080,
140
- nodeAgentHost: '127.0.0.1',
141
- nodeAgentPort: 7575,
142
- registryFile: 'cluster.runtime.json',
143
- token: '',
144
- autoscale: {
145
- enabled: true,
146
- mode: 'auto',
147
- minNodes: 1,
148
- maxNodes: 4,
149
- cooldownMs: 60_000,
150
- cpuHigh: 80,
151
- rpsPerNodeHigh: 15,
152
- rpsPerNodeLow: 5,
153
- },
154
- },
155
135
  };