@bhooai/nexus-cli 0.1.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.
- package/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent store for payment orders + transactions.
|
|
3
|
+
*
|
|
4
|
+
* Orders are created/updated from the checkout routes (paymentRoutes.ts); raw
|
|
5
|
+
* provider webhook events are appended as transactions (main.ts). Both are
|
|
6
|
+
* exposed to the admin app under /admin/payments/*.
|
|
7
|
+
*
|
|
8
|
+
* Models register lazily on the default connection, so they work from both the
|
|
9
|
+
* app bootstrap and the admin routes regardless of call order.
|
|
10
|
+
*/
|
|
11
|
+
import { Schema, model, ObjectId, type DocumentInstance, type Model } from '@bhooai/nexus-data';
|
|
12
|
+
import type { Order, WebhookEvent } from '@bhooai/nexus-payments';
|
|
13
|
+
|
|
14
|
+
export interface OrderDoc {
|
|
15
|
+
_id?: ObjectId;
|
|
16
|
+
/** Provider name (razorpay, paypal, payu, skrill, payoneer). */
|
|
17
|
+
provider: string;
|
|
18
|
+
/** Provider order id (e.g. PayPal order id / Razorpay order id). */
|
|
19
|
+
orderId: string;
|
|
20
|
+
/** Merchant reference echoed back on the order. */
|
|
21
|
+
reference: string;
|
|
22
|
+
status: string;
|
|
23
|
+
amount: number;
|
|
24
|
+
currency: string;
|
|
25
|
+
paymentUrl?: string;
|
|
26
|
+
paymentId?: string;
|
|
27
|
+
customer?: Record<string, unknown>;
|
|
28
|
+
description?: string;
|
|
29
|
+
raw?: Record<string, unknown>;
|
|
30
|
+
createdAt?: Date;
|
|
31
|
+
updatedAt?: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TransactionDoc {
|
|
35
|
+
_id?: ObjectId;
|
|
36
|
+
provider: string;
|
|
37
|
+
/** Normalized event (e.g. "payment.captured", "refund.created"). */
|
|
38
|
+
event: string;
|
|
39
|
+
verified: boolean;
|
|
40
|
+
orderId?: string;
|
|
41
|
+
paymentId?: string;
|
|
42
|
+
amount?: number;
|
|
43
|
+
currency?: string;
|
|
44
|
+
status?: string;
|
|
45
|
+
raw?: Record<string, unknown>;
|
|
46
|
+
createdAt?: Date;
|
|
47
|
+
updatedAt?: Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type OrderInstance = DocumentInstance & OrderDoc;
|
|
51
|
+
export type TransactionInstance = DocumentInstance & TransactionDoc;
|
|
52
|
+
|
|
53
|
+
const orderSchema = new Schema<OrderDoc>(
|
|
54
|
+
{
|
|
55
|
+
provider: { type: String, required: true },
|
|
56
|
+
orderId: { type: String, required: true },
|
|
57
|
+
reference: { type: String },
|
|
58
|
+
status: { type: String, default: 'created' },
|
|
59
|
+
amount: { type: Number },
|
|
60
|
+
currency: { type: String, default: 'USD' },
|
|
61
|
+
paymentUrl: { type: String },
|
|
62
|
+
paymentId: { type: String },
|
|
63
|
+
customer: { type: Object },
|
|
64
|
+
description: { type: String },
|
|
65
|
+
raw: { type: Object },
|
|
66
|
+
},
|
|
67
|
+
{ timestamps: true, collection: 'payment_orders' },
|
|
68
|
+
);
|
|
69
|
+
orderSchema.indexes.push({ spec: { provider: 1, orderId: 1 }, options: { unique: true } });
|
|
70
|
+
|
|
71
|
+
const transactionSchema = new Schema<TransactionDoc>(
|
|
72
|
+
{
|
|
73
|
+
provider: { type: String, required: true },
|
|
74
|
+
event: { type: String, required: true },
|
|
75
|
+
verified: { type: Boolean, default: false },
|
|
76
|
+
orderId: { type: String },
|
|
77
|
+
paymentId: { type: String },
|
|
78
|
+
amount: { type: Number },
|
|
79
|
+
currency: { type: String },
|
|
80
|
+
status: { type: String },
|
|
81
|
+
raw: { type: Object },
|
|
82
|
+
},
|
|
83
|
+
{ timestamps: true, collection: 'payment_transactions' },
|
|
84
|
+
);
|
|
85
|
+
transactionSchema.indexes.push({ spec: { createdAt: -1 }, options: {} });
|
|
86
|
+
|
|
87
|
+
let Order: OrderModel | undefined;
|
|
88
|
+
let Transaction: TransactionModel | undefined;
|
|
89
|
+
|
|
90
|
+
/** Register both payment models on the default connection (idempotent). */
|
|
91
|
+
export function initPaymentModels(): void {
|
|
92
|
+
if (Transaction) return;
|
|
93
|
+
Order = model<OrderInstance>('PaymentOrder', orderSchema);
|
|
94
|
+
Transaction = model<TransactionInstance>('PaymentTransaction', transactionSchema);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getOrderModel(): Model<OrderInstance> {
|
|
98
|
+
if (!Order) initPaymentModels();
|
|
99
|
+
if (!Order) throw new Error('Payment order model unavailable');
|
|
100
|
+
return Order;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getTransactionModel(): Model<TransactionInstance> {
|
|
104
|
+
if (!Transaction) initPaymentModels();
|
|
105
|
+
if (!Transaction) throw new Error('Payment transaction model unavailable');
|
|
106
|
+
return Transaction;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Upsert an order from a provider response so we always keep the latest status. */
|
|
110
|
+
export async function upsertOrder(
|
|
111
|
+
order: Order,
|
|
112
|
+
provider: string,
|
|
113
|
+
meta: { customer?: Record<string, unknown>; description?: string } = {},
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
await getOrderModel().findOneAndUpdate(
|
|
116
|
+
{ provider, orderId: order.id },
|
|
117
|
+
{
|
|
118
|
+
$set: {
|
|
119
|
+
provider,
|
|
120
|
+
orderId: order.id,
|
|
121
|
+
reference: order.reference,
|
|
122
|
+
status: order.status,
|
|
123
|
+
amount: order.amount,
|
|
124
|
+
currency: order.currency,
|
|
125
|
+
paymentUrl: order.paymentUrl ?? null,
|
|
126
|
+
paymentId: order.paymentId ?? null,
|
|
127
|
+
customer: meta.customer ?? null,
|
|
128
|
+
description: meta.description ?? null,
|
|
129
|
+
raw: order.raw ?? null,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{ upsert: true },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Append a normalized webhook event as a transaction. */
|
|
137
|
+
export async function recordTransaction(provider: string, event: WebhookEvent): Promise<void> {
|
|
138
|
+
const data = (event.data ?? {}) as Record<string, unknown>;
|
|
139
|
+
const fields: Record<string, unknown> = {
|
|
140
|
+
provider,
|
|
141
|
+
event: event.event ?? 'unknown',
|
|
142
|
+
verified: !!event.verified,
|
|
143
|
+
orderId: firstStr(data, 'orderId', 'order_id', 'orderReference'),
|
|
144
|
+
paymentId: firstStr(data, 'paymentId', 'payment_id', 'id'),
|
|
145
|
+
amount: firstNum(data, 'amount', 'gross_amount', 'total_paid'),
|
|
146
|
+
currency: firstStr(data, 'currency'),
|
|
147
|
+
status: firstStr(data, 'status', 'payment_status', 'state'),
|
|
148
|
+
raw: data,
|
|
149
|
+
};
|
|
150
|
+
await getTransactionModel().create(fields);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function firstStr(data: Record<string, unknown>, ...keys: string[]): string | undefined {
|
|
154
|
+
for (const k of keys) {
|
|
155
|
+
const v = data[k];
|
|
156
|
+
if (typeof v === 'string' && v.length) return v;
|
|
157
|
+
if (typeof v === 'number') return String(v);
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function firstNum(data: Record<string, unknown>, ...keys: string[]): number | undefined {
|
|
163
|
+
for (const k of keys) {
|
|
164
|
+
const v = data[k];
|
|
165
|
+
if (typeof v === 'number') return v;
|
|
166
|
+
if (typeof v === 'string' && !Number.isNaN(Number(v))) return Number(v);
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type OrderModel = Model<OrderInstance>;
|
|
172
|
+
export type TransactionModel = Model<TransactionInstance>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { WriteStream } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Datewise, append-only HTTP request log.
|
|
7
|
+
*
|
|
8
|
+
* Each request is written as a single JSON line to a date-stamped file
|
|
9
|
+
* (`requests-YYYY-MM-DD.log`) inside the project's logging dir, so a day's
|
|
10
|
+
* traffic is easy to inspect and long ranges can be aggregated on the fly.
|
|
11
|
+
*
|
|
12
|
+
* Written by the backend request middleware (main.ts) and read by the admin
|
|
13
|
+
* endpoints under /admin/requests (adminRoutes.ts).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface RequestLogEntry {
|
|
17
|
+
time: number;
|
|
18
|
+
method: string;
|
|
19
|
+
path: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
status: number;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
ip?: string;
|
|
24
|
+
referer?: string;
|
|
25
|
+
userAgent?: string;
|
|
26
|
+
origin?: string;
|
|
27
|
+
requestId?: string;
|
|
28
|
+
route?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RequestSeriesRange = 'today' | '5d' | 'week' | 'month' | 'year';
|
|
32
|
+
|
|
33
|
+
export interface RequestSeries {
|
|
34
|
+
range: RequestSeriesRange;
|
|
35
|
+
bucketMs: number;
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
total: number;
|
|
39
|
+
points: Array<{ t: number; count: number }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let stream: WriteStream | null = null;
|
|
43
|
+
let streamDate = '';
|
|
44
|
+
let streamDir = '';
|
|
45
|
+
|
|
46
|
+
function dateKey(d = new Date()): string {
|
|
47
|
+
const y = d.getFullYear();
|
|
48
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
49
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
50
|
+
return `${y}-${m}-${day}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fileNameFor(date: string): string {
|
|
54
|
+
return `requests-${date}.log`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ensureStream(dir: string, date: string): WriteStream {
|
|
58
|
+
if (stream && streamDate === date && streamDir === dir) return stream;
|
|
59
|
+
if (stream) {
|
|
60
|
+
stream.end();
|
|
61
|
+
stream = null;
|
|
62
|
+
}
|
|
63
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
64
|
+
stream = createWriteStream(join(dir, fileNameFor(date)), { flags: 'a' });
|
|
65
|
+
stream.on('error', () => {
|
|
66
|
+
stream = null;
|
|
67
|
+
});
|
|
68
|
+
streamDate = date;
|
|
69
|
+
streamDir = dir;
|
|
70
|
+
return stream;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Append one JSON line for a completed request. Safe to call from the hot path. */
|
|
74
|
+
export function appendRequestLog(dir: string, entry: RequestLogEntry): void {
|
|
75
|
+
try {
|
|
76
|
+
ensureStream(dir, dateKey()).write(JSON.stringify(entry) + '\n');
|
|
77
|
+
} catch {
|
|
78
|
+
// never let logging break the request path
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Close the active stream (used on shutdown). */
|
|
83
|
+
export function closeRequestLog(): void {
|
|
84
|
+
if (stream) {
|
|
85
|
+
stream.end();
|
|
86
|
+
stream = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function listLogFiles(dir: string): string[] {
|
|
91
|
+
if (!existsSync(dir)) return [];
|
|
92
|
+
return readdirSync(dir)
|
|
93
|
+
.filter((f) => /^requests-\d{4}-\d{2}-\d{2}\.log$/.test(f))
|
|
94
|
+
.sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fileDate(file: string): number {
|
|
98
|
+
const match = /^requests-(\d{4}-\d{2}-\d{2})\.log$/.exec(file);
|
|
99
|
+
if (!match) return 0;
|
|
100
|
+
return Date.parse(match[1]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readEntries(dir: string, from: number): RequestLogEntry[] {
|
|
104
|
+
const out: RequestLogEntry[] = [];
|
|
105
|
+
for (const file of listLogFiles(dir)) {
|
|
106
|
+
if (fileDate(file) < from) continue;
|
|
107
|
+
let text: string;
|
|
108
|
+
try {
|
|
109
|
+
text = readFileSync(join(dir, file), 'utf8');
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const line of text.split('\n')) {
|
|
114
|
+
if (!line.trim()) continue;
|
|
115
|
+
try {
|
|
116
|
+
const e = JSON.parse(line) as RequestLogEntry;
|
|
117
|
+
if (typeof e.time === 'number' && e.time >= from) out.push(e);
|
|
118
|
+
} catch {
|
|
119
|
+
// skip malformed lines
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const RANGE_PARAMS: Record<RequestSeriesRange, { bucketMs: number; back: number }> = {
|
|
127
|
+
today: { bucketMs: 5 * 60_000, back: 24 * 60 * 60_000 },
|
|
128
|
+
'5d': { bucketMs: 60 * 60_000, back: 5 * 24 * 60 * 60_000 },
|
|
129
|
+
week: { bucketMs: 6 * 60 * 60_000, back: 7 * 24 * 60 * 60_000 },
|
|
130
|
+
month: { bucketMs: 24 * 60 * 60_000, back: 31 * 24 * 60 * 60_000 },
|
|
131
|
+
year: { bucketMs: 7 * 24 * 60 * 60_000, back: 365 * 24 * 60 * 60_000 },
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Aggregate request logs into coarse buckets for the requested range. */
|
|
135
|
+
export function readRequestSeries(dir: string, range: RequestSeriesRange): RequestSeries {
|
|
136
|
+
const { bucketMs, back } = RANGE_PARAMS[range];
|
|
137
|
+
const end = Date.now();
|
|
138
|
+
const start = end - back;
|
|
139
|
+
const entries = readEntries(dir, start);
|
|
140
|
+
const buckets = new Map<number, number>();
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (const e of entries) {
|
|
143
|
+
const idx = Math.floor((e.time - start) / bucketMs);
|
|
144
|
+
buckets.set(idx, (buckets.get(idx) ?? 0) + 1);
|
|
145
|
+
total += 1;
|
|
146
|
+
}
|
|
147
|
+
const points = [...buckets.entries()]
|
|
148
|
+
.map(([idx, count]) => ({ t: start + idx * bucketMs, count }))
|
|
149
|
+
.sort((a, b) => a.t - b.t);
|
|
150
|
+
return { range, bucketMs, start, end, total, points };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Tail the newest request entries, reading back across days to satisfy the limit. */
|
|
154
|
+
export function tailRequestLogs(dir: string, limit: number): RequestLogEntry[] {
|
|
155
|
+
const out: RequestLogEntry[] = [];
|
|
156
|
+
const files = listLogFiles(dir).reverse();
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
if (out.length >= limit) break;
|
|
159
|
+
try {
|
|
160
|
+
const text = readFileSync(join(dir, file), 'utf8');
|
|
161
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
162
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
163
|
+
try {
|
|
164
|
+
out.push(JSON.parse(lines[i]) as RequestLogEntry);
|
|
165
|
+
} catch {
|
|
166
|
+
// skip
|
|
167
|
+
}
|
|
168
|
+
if (out.length >= limit) break;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineSubgraph, type Resolvers, type GraphQLContext, type EntityRepresentation } from '@bhooai/nexus-graphql';
|
|
2
|
+
import { getUserModel } from './userModel.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Users subgraph (single-subgraph Phase 5). Exposes User as a federation
|
|
6
|
+
* entity (`@key(fields: "id")`) plus `me`/`user`/`users` queries. Resolvers
|
|
7
|
+
* use the User ODM model. `me` requires an authenticated context (`ctx.user.sub`).
|
|
8
|
+
*/
|
|
9
|
+
const typeDefs = /* graphql */ `
|
|
10
|
+
type User @key(fields: "id") {
|
|
11
|
+
id: ID!
|
|
12
|
+
email: String!
|
|
13
|
+
name: String
|
|
14
|
+
roles: [String!]!
|
|
15
|
+
emailVerified: Boolean!
|
|
16
|
+
createdAt: String
|
|
17
|
+
updatedAt: String
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Query {
|
|
21
|
+
me: User
|
|
22
|
+
user(id: ID!): User
|
|
23
|
+
users(limit: Int = 50): [User!]!
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Subscription {
|
|
27
|
+
userCount: Int!
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function toGraphUser(doc: any): Record<string, unknown> | null {
|
|
32
|
+
if (!doc) return null;
|
|
33
|
+
const obj = doc.toObject ? doc.toObject() : doc;
|
|
34
|
+
return {
|
|
35
|
+
id: String(obj._id ?? obj.id),
|
|
36
|
+
email: obj.email,
|
|
37
|
+
name: obj.name ?? null,
|
|
38
|
+
roles: obj.roles ?? [],
|
|
39
|
+
emailVerified: obj.emailVerified ?? false,
|
|
40
|
+
createdAt: obj.createdAt ? new Date(obj.createdAt).toISOString() : null,
|
|
41
|
+
updatedAt: obj.updatedAt ? new Date(obj.updatedAt).toISOString() : null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildUsersSubgraph() {
|
|
46
|
+
const resolvers: Resolvers = {
|
|
47
|
+
Query: {
|
|
48
|
+
me: async (_parent, _args, ctx: GraphQLContext) => {
|
|
49
|
+
if (!ctx.user?.sub) return null;
|
|
50
|
+
const User = getUserModel();
|
|
51
|
+
const doc = await User.findById(ctx.user.sub).lean();
|
|
52
|
+
return toGraphUser(doc);
|
|
53
|
+
},
|
|
54
|
+
user: async (_parent, args: { id: string }) => {
|
|
55
|
+
const User = getUserModel();
|
|
56
|
+
return toGraphUser(await User.findById(args.id).lean());
|
|
57
|
+
},
|
|
58
|
+
users: async (_parent, args: { limit: number }) => {
|
|
59
|
+
const User = getUserModel();
|
|
60
|
+
const docs = await User.find().limit(args.limit ?? 50).lean();
|
|
61
|
+
return docs.map(toGraphUser);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
Subscription: {
|
|
65
|
+
// The payload is the new count; the host publishes 'USER_COUNT' after a
|
|
66
|
+
// user is created. ctx.pubsub is injected by main.ts (HTTP + WS contexts).
|
|
67
|
+
userCount: {
|
|
68
|
+
subscribe: (_parent, _args, ctx: GraphQLContext) => (ctx.pubsub as any).asyncIterator('USER_COUNT'),
|
|
69
|
+
resolve: async () => {
|
|
70
|
+
const User = getUserModel();
|
|
71
|
+
return await User.countDocuments();
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const entityResolver = async (rep: EntityRepresentation) => {
|
|
78
|
+
const User = getUserModel();
|
|
79
|
+
return toGraphUser(await User.findById(String(rep.id)).lean());
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return defineSubgraph({ name: 'users', typeDefs, resolvers, entityResolver });
|
|
83
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Schema, model, ObjectId, type DocumentInstance, type Model } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
/** Persisted user document. `passwordHash` is select:false so it is excluded from normal queries. */
|
|
4
|
+
export interface UserDoc {
|
|
5
|
+
_id?: ObjectId;
|
|
6
|
+
email: string;
|
|
7
|
+
passwordHash?: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
roles: string[];
|
|
10
|
+
emailVerified: boolean;
|
|
11
|
+
/** Linked OAuth identities (for account linking). */
|
|
12
|
+
oauthAccounts: Array<{ provider: string; providerUserId: string }>;
|
|
13
|
+
createdAt?: Date;
|
|
14
|
+
updatedAt?: Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type UserInstance = DocumentInstance & UserDoc;
|
|
18
|
+
export type UserModel = Model<UserInstance>;
|
|
19
|
+
|
|
20
|
+
export const userSchema = new Schema<UserDoc>(
|
|
21
|
+
{
|
|
22
|
+
email: { type: String, required: true, unique: true, match: /.+@.+\..+/, transform: (v) => String(v).toLowerCase() },
|
|
23
|
+
passwordHash: { type: String, select: false },
|
|
24
|
+
name: { type: String },
|
|
25
|
+
roles: { type: [String], default: () => ['user'] },
|
|
26
|
+
emailVerified: { type: Boolean, default: false },
|
|
27
|
+
oauthAccounts: { type: Array, default: () => [] },
|
|
28
|
+
},
|
|
29
|
+
{ timestamps: true, collection: 'users' },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
let User: UserModel | undefined;
|
|
33
|
+
|
|
34
|
+
/** Register the User model on the default connection (call after `connect()`). */
|
|
35
|
+
export function initUserModel(): UserModel {
|
|
36
|
+
if (User) return User;
|
|
37
|
+
User = model<UserInstance>('User', userSchema);
|
|
38
|
+
return User;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getUserModel(): UserModel {
|
|
42
|
+
if (!User) throw new Error('User model not initialized — call initUserModel() after connect().');
|
|
43
|
+
return User;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fetch a user by email INCLUDING the select:false passwordHash (raw driver lookup). */
|
|
47
|
+
export async function findUserForLogin(email: string): Promise<UserInstance | null> {
|
|
48
|
+
const User = getUserModel();
|
|
49
|
+
const coll = await User.collection;
|
|
50
|
+
const raw = await coll.findOne({ email: String(email).toLowerCase() });
|
|
51
|
+
return raw ? (User.hydrate(raw) as UserInstance) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Find-or-create a user from an OAuth profile (account linking by provider+id). */
|
|
55
|
+
export async function upsertOAuthUser(profile: {
|
|
56
|
+
provider: string;
|
|
57
|
+
providerUserId: string;
|
|
58
|
+
email?: string;
|
|
59
|
+
name?: string;
|
|
60
|
+
}): Promise<UserInstance> {
|
|
61
|
+
const User = getUserModel();
|
|
62
|
+
const coll = await User.collection;
|
|
63
|
+
const existing = await coll.findOne({
|
|
64
|
+
oauthAccounts: { $elemMatch: { provider: profile.provider, providerUserId: profile.providerUserId } },
|
|
65
|
+
});
|
|
66
|
+
if (existing) return User.hydrate(existing) as UserInstance;
|
|
67
|
+
|
|
68
|
+
// Link to an existing email account if present, else create a new one.
|
|
69
|
+
if (profile.email) {
|
|
70
|
+
const byEmail = await coll.findOne({ email: profile.email.toLowerCase() });
|
|
71
|
+
if (byEmail) {
|
|
72
|
+
await User.updateOne({ _id: byEmail._id }, {
|
|
73
|
+
$addToSet: { oauthAccounts: { provider: profile.provider, providerUserId: profile.providerUserId } },
|
|
74
|
+
});
|
|
75
|
+
const refreshed = await coll.findOne({ _id: byEmail._id });
|
|
76
|
+
return User.hydrate(refreshed!) as UserInstance;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [created] = await User.create({
|
|
81
|
+
email: profile.email ?? `${profile.provider}-${profile.providerUserId}@oauth.local`,
|
|
82
|
+
name: profile.name,
|
|
83
|
+
emailVerified: true,
|
|
84
|
+
oauthAccounts: [{ provider: profile.provider, providerUserId: profile.providerUserId }],
|
|
85
|
+
roles: ['user'],
|
|
86
|
+
});
|
|
87
|
+
return created;
|
|
88
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal in-process cron scheduler for plugins. Supports a subset of 5-field
|
|
3
|
+
* cron at minute resolution: star, star-slash-N (every N minutes), and specific
|
|
4
|
+
* numbers for each field. Honest v1 subset — full cron (ranges, lists, L/W) is
|
|
5
|
+
* Phase 12.
|
|
6
|
+
*/
|
|
7
|
+
interface Job {
|
|
8
|
+
name: string;
|
|
9
|
+
cron: string;
|
|
10
|
+
fn: () => void | Promise<void>;
|
|
11
|
+
parts: number[]; // [minute, hour, dom, month, dow] matcher encoded as -1 = any, else value (step handled inline)
|
|
12
|
+
step: number; // minute step for */N, else 0
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseField(field: string, min: number, max: number): { value: number; step: number } {
|
|
16
|
+
if (field === '*') return { value: -1, step: 0 };
|
|
17
|
+
const m = /^\*\/(\d+)$/.exec(field);
|
|
18
|
+
if (m) return { value: -1, step: Number(m[1]) };
|
|
19
|
+
return { value: Number(field), step: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class CronScheduler {
|
|
23
|
+
private jobs = new Map<string, Job>();
|
|
24
|
+
private timer: NodeJS.Timeout | null = null;
|
|
25
|
+
|
|
26
|
+
schedule(name: string, cron: string, fn: () => void | Promise<void>): void {
|
|
27
|
+
const [min, hour, dom, month, dow] = cron.trim().split(/\s+/);
|
|
28
|
+
const mf = parseField(min!, 0, 59);
|
|
29
|
+
const hf = parseField(hour!, 0, 23);
|
|
30
|
+
this.jobs.set(name, {
|
|
31
|
+
name, cron, fn,
|
|
32
|
+
parts: [hf.value, parseField(dom!, 1, 31).value, parseField(month!, 1, 12).value, parseField(dow!, 0, 6).value],
|
|
33
|
+
step: mf.step,
|
|
34
|
+
});
|
|
35
|
+
if (this.jobs.size === 1) this.start();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cancel(name: string): void {
|
|
39
|
+
this.jobs.delete(name);
|
|
40
|
+
if (this.jobs.size === 0 && this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private start(): void {
|
|
44
|
+
this.timer = setInterval(() => this.tick(), 60_000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async tick(): Promise<void> {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
for (const job of this.jobs.values()) {
|
|
50
|
+
const [hour, dom, month, dow] = job.parts;
|
|
51
|
+
const m = now.getMinutes();
|
|
52
|
+
// minute: */N matches when m % step === 0; specific value matches equality; '*' always.
|
|
53
|
+
const minOk = job.step > 0 ? m % job.step === 0 : true; // step 0 → '*' → any minute
|
|
54
|
+
if (!minOk) continue;
|
|
55
|
+
if (hour !== -1 && hour !== now.getHours()) continue;
|
|
56
|
+
if (month !== -1 && month !== now.getMonth() + 1) continue;
|
|
57
|
+
if (dom !== -1 && dom !== now.getDate()) continue;
|
|
58
|
+
if (dow !== -1 && dow !== now.getDay()) continue;
|
|
59
|
+
try { await job.fn(); } catch { /* swallow job errors */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close(): void {
|
|
64
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
65
|
+
this.jobs.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
has(name: string): boolean { return this.jobs.has(name); }
|
|
69
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { PluginHost, AdminExtensions, HookBus, ServiceContainer, type HostBindings } from '@bhooai/nexus-plugins';
|
|
3
|
+
import type { Router } from '@bhooai/nexus-core';
|
|
4
|
+
import type { Logger } from '@bhooai/nexus-telemetry';
|
|
5
|
+
import type { RealtimeServer } from '@bhooai/nexus-realtime';
|
|
6
|
+
import { CronScheduler } from './CronScheduler.js';
|
|
7
|
+
|
|
8
|
+
export interface LoadPluginsDeps {
|
|
9
|
+
router: Router;
|
|
10
|
+
log: Logger;
|
|
11
|
+
realtime: RealtimeServer;
|
|
12
|
+
pluginsDir: string;
|
|
13
|
+
/** Map of plugin name → config, merged from nexus.config plugins.entries. */
|
|
14
|
+
configByPlugin?: Record<string, unknown>;
|
|
15
|
+
/** sandbox limits by plugin name. */
|
|
16
|
+
limitsByPlugin?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoadedPlugins {
|
|
20
|
+
host: PluginHost;
|
|
21
|
+
adminExtensions: AdminExtensions;
|
|
22
|
+
hookBus: HookBus;
|
|
23
|
+
services: ServiceContainer;
|
|
24
|
+
scheduler: CronScheduler;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build real HostBindings backed by the running services, discover + load
|
|
29
|
+
* plugins from the plugins dir, and run install → init → start in dependency
|
|
30
|
+
* order. Returns handles the app keeps for shutdown + admin rendering.
|
|
31
|
+
*/
|
|
32
|
+
export async function loadPlugins(deps: LoadPluginsDeps): Promise<LoadedPlugins> {
|
|
33
|
+
const { router, log, realtime, pluginsDir } = deps;
|
|
34
|
+
const adminExtensions = new AdminExtensions();
|
|
35
|
+
const hookBus = new HookBus();
|
|
36
|
+
const services = new ServiceContainer();
|
|
37
|
+
const scheduler = new CronScheduler();
|
|
38
|
+
|
|
39
|
+
const pluginLog = log.child({ component: 'plugins' });
|
|
40
|
+
const bindings: HostBindings = {
|
|
41
|
+
http: {
|
|
42
|
+
addRoute: (method, path, handler) => { router.add(method, path, handler); },
|
|
43
|
+
addMiddleware: (mw, phase) => { router.use(mw); },
|
|
44
|
+
},
|
|
45
|
+
graphql: {
|
|
46
|
+
// Runtime subgraph recompose + hot-swap is a Phase 12 hardening item; in v1
|
|
47
|
+
// trusted plugins add subgraphs at boot via the gateway builder. Calling
|
|
48
|
+
// addSubgraph at runtime logs a notice rather than recomposing the live gateway.
|
|
49
|
+
addSubgraph: async (sg) => { pluginLog.warn('runtime graphql.addSubgraph deferred to Phase 12', { subgraph: sg.name }); },
|
|
50
|
+
removeSubgraph: async (name) => { pluginLog.warn('runtime graphql.removeSubgraph deferred to Phase 12', { subgraph: name }); },
|
|
51
|
+
},
|
|
52
|
+
data: {
|
|
53
|
+
registerModel: (_n, _s) => { pluginLog.warn('plugin data.registerModel is a v1 stub (models register via nexus-data directly)'); return undefined; },
|
|
54
|
+
getModel: (n) => { pluginLog.warn('plugin data.getModel is a v1 stub', { model: n }); return undefined; },
|
|
55
|
+
},
|
|
56
|
+
realtime: {
|
|
57
|
+
join: (_room, _connId) => { /* plugins don't join on behalf of connections in v1 */ },
|
|
58
|
+
broadcast: (room, msg) => { (realtime as any).broadcastRoom?.(room, { type: 'broadcast', room, event: 'plugin', data: msg }); },
|
|
59
|
+
},
|
|
60
|
+
scheduler: {
|
|
61
|
+
schedule: (name, cron, fn) => scheduler.schedule(name, cron, fn),
|
|
62
|
+
cancel: (name) => scheduler.cancel(name),
|
|
63
|
+
},
|
|
64
|
+
events: {
|
|
65
|
+
publish: (topic, payload) => hookBus.publish(topic, payload),
|
|
66
|
+
subscribe: (topic, handler) => hookBus.subscribe(topic, handler),
|
|
67
|
+
},
|
|
68
|
+
admin: {
|
|
69
|
+
registerAdminPage: (page) => adminExtensions.registerPage('<plugin>', page),
|
|
70
|
+
registerSlot: (slot) => adminExtensions.registerSlot('<plugin>', slot),
|
|
71
|
+
},
|
|
72
|
+
services: {
|
|
73
|
+
register: (name, svc) => services.register(name, svc),
|
|
74
|
+
get: (name) => services.get(name),
|
|
75
|
+
},
|
|
76
|
+
config: {
|
|
77
|
+
get: (pluginName) => deps.configByPlugin?.[pluginName],
|
|
78
|
+
set: (_pluginName, _cfg) => { /* admin writes nexus.runtime.json, not here */ },
|
|
79
|
+
},
|
|
80
|
+
logger: {
|
|
81
|
+
child: (meta) => {
|
|
82
|
+
const child = pluginLog.child(meta);
|
|
83
|
+
return {
|
|
84
|
+
info: (m: string, _meta?: unknown) => child.info(m),
|
|
85
|
+
warn: (m: string, _meta?: unknown) => child.warn(m),
|
|
86
|
+
error: (m: string, _meta?: unknown) => child.error(m),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const host = new PluginHost(bindings, resolve(pluginsDir));
|
|
93
|
+
const order = await host.load(deps.configByPlugin ?? {}, deps.limitsByPlugin ?? {});
|
|
94
|
+
if (order.length === 0) {
|
|
95
|
+
pluginLog.info('no plugins found', { dir: pluginsDir });
|
|
96
|
+
return { host, adminExtensions, hookBus, services, scheduler };
|
|
97
|
+
}
|
|
98
|
+
pluginLog.info('plugins loaded', { order });
|
|
99
|
+
await host.install();
|
|
100
|
+
await host.init();
|
|
101
|
+
await host.start();
|
|
102
|
+
pluginLog.info('plugins started', { count: order.length });
|
|
103
|
+
|
|
104
|
+
// Patch admin page/slot registration to attribute by plugin name: the host
|
|
105
|
+
// can't easily know which plugin issued each call, so we re-walk handles.
|
|
106
|
+
return { host, adminExtensions, hookBus, services, scheduler };
|
|
107
|
+
}
|