@bhooai/nexus-core 2.0.4 → 2.0.6

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,101 @@
1
+ /**
2
+ * User model — framework-provided Mongo-backed User schema.
3
+ *
4
+ * Registered via `initUserModel()` once a connection exists (the app boot
5
+ * flow calls `connect()` from `@bhooai/nexus-data` first). `passwordHash` is
6
+ * `select: false`, so it is excluded from normal queries; `findUserForLogin`
7
+ * uses the raw driver lookup to fetch it explicitly.
8
+ */
9
+ import { Schema, model, type DocumentInstance, type Model, type ObjectId } from '@bhooai/nexus-data';
10
+
11
+ export interface UserDoc {
12
+ _id?: ObjectId;
13
+ email: string;
14
+ passwordHash?: string;
15
+ name?: string;
16
+ roles: string[];
17
+ emailVerified: boolean;
18
+ /** Linked OAuth identities (for account linking). */
19
+ oauthAccounts: Array<{ provider: string; providerUserId: string }>;
20
+ createdAt?: Date;
21
+ updatedAt?: Date;
22
+ }
23
+
24
+ export type UserInstance = DocumentInstance & UserDoc;
25
+ export type UserModel = Model<UserInstance>;
26
+
27
+ export const userSchema = new Schema<UserDoc>(
28
+ {
29
+ email: {
30
+ type: String,
31
+ required: true,
32
+ unique: true,
33
+ match: /.+@.+\..+/,
34
+ transform: (v: unknown) => String(v).toLowerCase(),
35
+ },
36
+ passwordHash: { type: String, select: false },
37
+ name: { type: String },
38
+ roles: { type: [String], default: () => ['user'] },
39
+ emailVerified: { type: Boolean, default: false },
40
+ oauthAccounts: { type: Array, default: () => [] },
41
+ },
42
+ { timestamps: true, collection: 'users' },
43
+ );
44
+
45
+ let User: UserModel | undefined;
46
+
47
+ /** Register the User model on the default connection (call after `connect()`). */
48
+ export function initUserModel(): UserModel {
49
+ if (User) return User;
50
+ User = model<UserInstance>('User', userSchema);
51
+ return User;
52
+ }
53
+
54
+ export function getUserModel(): UserModel {
55
+ if (!User) throw new Error('User model not initialized — call initUserModel() after connect().');
56
+ return User;
57
+ }
58
+
59
+ /** Fetch a user by email INCLUDING the select:false passwordHash (raw driver lookup). */
60
+ export async function findUserForLogin(email: string): Promise<UserInstance | null> {
61
+ const User = getUserModel();
62
+ const coll = await User.collection;
63
+ const raw = await coll.findOne({ email: String(email).toLowerCase() });
64
+ return raw ? (User.hydrate(raw as Record<string, unknown>) as UserInstance) : null;
65
+ }
66
+
67
+ /** Find-or-create a user from an OAuth profile (account linking by provider+id). */
68
+ export async function upsertOAuthUser(profile: {
69
+ provider: string;
70
+ providerUserId: string;
71
+ email?: string;
72
+ name?: string;
73
+ }): Promise<UserInstance> {
74
+ const User = getUserModel();
75
+ const coll = await User.collection;
76
+ const existing = await coll.findOne({
77
+ oauthAccounts: { $elemMatch: { provider: profile.provider, providerUserId: profile.providerUserId } },
78
+ });
79
+ if (existing) return User.hydrate(existing as Record<string, unknown>) as UserInstance;
80
+
81
+ // Link to an existing email account if present, else create a new one.
82
+ if (profile.email) {
83
+ const byEmail = await coll.findOne({ email: profile.email.toLowerCase() });
84
+ if (byEmail) {
85
+ await User.updateOne({ _id: byEmail._id }, {
86
+ $addToSet: { oauthAccounts: { provider: profile.provider, providerUserId: profile.providerUserId } },
87
+ });
88
+ const refreshed = await coll.findOne({ _id: byEmail._id });
89
+ if (refreshed) return User.hydrate(refreshed as Record<string, unknown>) as UserInstance;
90
+ }
91
+ }
92
+
93
+ const [created] = await User.create({
94
+ email: profile.email ?? `${profile.provider}-${profile.providerUserId}@oauth.local`,
95
+ name: profile.name,
96
+ emailVerified: true,
97
+ oauthAccounts: [{ provider: profile.provider, providerUserId: profile.providerUserId }],
98
+ roles: ['user'],
99
+ });
100
+ return created as UserInstance;
101
+ }
@@ -39,9 +39,32 @@ export async function writeRuntimeJson(projectRoot: string, data: Record<string,
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Recursively drop any value that equals the redaction mask (••••••••).
44
+ * The admin config editor round-trips masked values from GET /admin/config,
45
+ * so without this guard a redacted secret/cookie-name would be persisted as a
46
+ * literal placeholder — e.g. auth.refreshCookieName = "••••••••", which then
47
+ * breaks Set-Cookie writing ("Invalid character in header content").
48
+ */
49
+ export function stripRedactionMask<T>(value: T): T | undefined {
50
+ if (typeof value === 'string') return (value === '••••••••' ? undefined : value) as T;
51
+ if (Array.isArray(value)) {
52
+ return value.map(stripRedactionMask).filter((v) => v !== undefined) as T;
53
+ }
54
+ if (value && typeof value === 'object') {
55
+ const out: Record<string, unknown> = {};
56
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
57
+ const clean = stripRedactionMask(v);
58
+ if (clean !== undefined) out[k] = clean;
59
+ }
60
+ return out as T;
61
+ }
62
+ return value;
63
+ }
64
+
42
65
  /**
43
66
  * Merge a partial update into the runtime JSON (deep merge on `ai.providers`,
44
- * shallow merge everywhere else).
67
+ * shallow merge everywhere else). Masked placeholders are never written back.
45
68
  */
46
69
  export async function mergeRuntimeJson(projectRoot: string, patch: Record<string, unknown>): Promise<void> {
47
70
  const existing = await readRuntimeJson(projectRoot);
@@ -51,5 +74,5 @@ export async function mergeRuntimeJson(projectRoot: string, patch: Record<string
51
74
  if (patch.ai && typeof patch.ai === 'object' && existing.ai && typeof existing.ai === 'object') {
52
75
  merged.ai = { ...(existing as { ai: Record<string, unknown> }).ai, ...(patch.ai as Record<string, unknown>) };
53
76
  }
54
- await writeRuntimeJson(projectRoot, merged);
77
+ await writeRuntimeJson(projectRoot, stripRedactionMask(merged) as Record<string, unknown>);
55
78
  }