hazo_auth 5.1.31 → 5.1.34
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/README.md +79 -3
- package/SETUP_CHECKLIST.md +76 -4
- package/cli-src/cli/init.ts +35 -8
- package/cli-src/lib/auth/auth_types.ts +1 -0
- package/cli-src/lib/auth/hazo_get_auth.server.ts +1 -0
- package/cli-src/lib/auth/nextauth_config.ts +3 -3
- package/cli-src/lib/config/default_config.ts +21 -0
- package/cli-src/lib/oauth_config.server.ts +18 -0
- package/cli-src/lib/relationships_config.server.ts +47 -0
- package/cli-src/lib/schema/sqlite_schema.ts +21 -0
- package/cli-src/lib/services/relationship_service.ts +563 -0
- package/cli-src/lib/services/session_token_service.ts +15 -8
- package/cli-src/lib/utils/proxy_request.ts +81 -0
- package/cli-src/server/types/app_types.ts +2 -2
- package/config/hazo_auth_config.example.ini +52 -0
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +29 -3
- package/dist/components/layouts/dev_lock/index.d.ts.map +1 -1
- package/dist/components/layouts/dev_lock/index.js +9 -4
- package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
- package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
- package/dist/lib/auth/auth_types.d.ts +1 -0
- package/dist/lib/auth/auth_types.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_auth.server.js +1 -0
- package/dist/lib/auth/nextauth_config.js +3 -3
- package/dist/lib/config/default_config.d.ts +36 -0
- package/dist/lib/config/default_config.d.ts.map +1 -1
- package/dist/lib/config/default_config.js +20 -0
- package/dist/lib/oauth_config.server.d.ts +4 -0
- package/dist/lib/oauth_config.server.d.ts.map +1 -1
- package/dist/lib/oauth_config.server.js +4 -0
- package/dist/lib/relationships_config.server.d.ts +19 -0
- package/dist/lib/relationships_config.server.d.ts.map +1 -0
- package/dist/lib/relationships_config.server.js +26 -0
- package/dist/lib/schema/sqlite_schema.d.ts +1 -1
- package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
- package/dist/lib/schema/sqlite_schema.js +21 -0
- package/dist/lib/services/relationship_service.d.ts +124 -0
- package/dist/lib/services/relationship_service.d.ts.map +1 -0
- package/dist/lib/services/relationship_service.js +431 -0
- package/dist/lib/services/session_token_service.d.ts +3 -1
- package/dist/lib/services/session_token_service.d.ts.map +1 -1
- package/dist/lib/services/session_token_service.js +9 -6
- package/dist/lib/utils/proxy_request.d.ts +20 -0
- package/dist/lib/utils/proxy_request.d.ts.map +1 -0
- package/dist/lib/utils/proxy_request.js +74 -0
- package/dist/page_components/forgot_password.js +2 -2
- package/dist/page_components/login.js +2 -2
- package/dist/page_components/register.js +1 -1
- package/dist/page_components/reset_password.js +2 -2
- package/dist/page_components/verify_email.js +2 -2
- package/dist/server/routes/index.d.ts +4 -0
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +5 -0
- package/dist/server/routes/me.d.ts.map +1 -1
- package/dist/server/routes/me.js +10 -1
- package/dist/server/routes/nextauth.d.ts.map +1 -1
- package/dist/server/routes/nextauth.js +94 -23
- package/dist/server/routes/oauth_google_callback.d.ts +1 -1
- package/dist/server/routes/oauth_google_callback.d.ts.map +1 -1
- package/dist/server/routes/oauth_google_callback.js +25 -14
- package/dist/server/routes/pin_login.d.ts +17 -0
- package/dist/server/routes/pin_login.d.ts.map +1 -0
- package/dist/server/routes/pin_login.js +123 -0
- package/dist/server/routes/relationship_self.d.ts +13 -0
- package/dist/server/routes/relationship_self.d.ts.map +1 -0
- package/dist/server/routes/relationship_self.js +59 -0
- package/dist/server/routes/relationship_upgrade.d.ts +13 -0
- package/dist/server/routes/relationship_upgrade.d.ts.map +1 -0
- package/dist/server/routes/relationship_upgrade.js +66 -0
- package/dist/server/routes/relationships.d.ts +42 -0
- package/dist/server/routes/relationships.d.ts.map +1 -0
- package/dist/server/routes/relationships.js +217 -0
- package/dist/server/routes/remove_profile_picture.d.ts.map +1 -1
- package/dist/server/routes/remove_profile_picture.js +22 -1
- package/dist/server/routes/upload_profile_picture.d.ts.map +1 -1
- package/dist/server/routes/upload_profile_picture.js +25 -4
- package/dist/server/types/app_types.d.ts +2 -2
- package/dist/server/types/app_types.d.ts.map +1 -1
- package/package.json +122 -38
- package/dist/lib/index.d.ts +0 -32
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/lib/index.js +0 -37
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// file_description: service for managing parent-child user relationships (managed sub-profiles)
|
|
2
|
+
// section: imports
|
|
3
|
+
import type { HazoConnectAdapter } from "hazo_connect";
|
|
4
|
+
import { createCrudService } from "hazo_connect/server";
|
|
5
|
+
import argon2 from "argon2";
|
|
6
|
+
import { create_app_logger } from "../app_logger.js";
|
|
7
|
+
import { get_relationships_config, get_allowed_relationship_types } from "../relationships_config.server.js";
|
|
8
|
+
|
|
9
|
+
// section: types
|
|
10
|
+
export type CreateChildData = {
|
|
11
|
+
parent_user_id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
pin?: string;
|
|
14
|
+
relationship_type?: string;
|
|
15
|
+
app_user_data?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RelationshipRecord = {
|
|
19
|
+
id: string;
|
|
20
|
+
parent_user_id: string;
|
|
21
|
+
child_user_id: string;
|
|
22
|
+
relationship_type: string;
|
|
23
|
+
can_view_progress: boolean;
|
|
24
|
+
can_edit_profile: boolean;
|
|
25
|
+
can_delete: boolean;
|
|
26
|
+
is_self: boolean;
|
|
27
|
+
created_at: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ChildProfile = {
|
|
31
|
+
relationship_id: string;
|
|
32
|
+
relationship_type: string;
|
|
33
|
+
can_view_progress: boolean;
|
|
34
|
+
can_edit_profile: boolean;
|
|
35
|
+
can_delete: boolean;
|
|
36
|
+
is_self: boolean;
|
|
37
|
+
user_id: string;
|
|
38
|
+
name: string | null;
|
|
39
|
+
email: string | null; // null for managed users (sentinel stripped)
|
|
40
|
+
profile_picture_url: string | null;
|
|
41
|
+
app_user_data: Record<string, unknown> | null;
|
|
42
|
+
has_pin: boolean;
|
|
43
|
+
created_at: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type RelationshipServiceResult = {
|
|
47
|
+
success: boolean;
|
|
48
|
+
child_user_id?: string;
|
|
49
|
+
relationship_id?: string;
|
|
50
|
+
children?: ChildProfile[];
|
|
51
|
+
error?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// section: sentinel-email-helpers
|
|
55
|
+
const SENTINEL_DOMAIN = "@hazo.internal";
|
|
56
|
+
const SENTINEL_PREFIX = "managed_";
|
|
57
|
+
|
|
58
|
+
export function is_sentinel_email(email: string | null | undefined): boolean {
|
|
59
|
+
if (!email) return false;
|
|
60
|
+
return email.startsWith(SENTINEL_PREFIX) && email.endsWith(SENTINEL_DOMAIN);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function get_display_email(email: string | null | undefined): string | null {
|
|
64
|
+
if (!email) return null;
|
|
65
|
+
return is_sentinel_email(email) ? null : email;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function generate_sentinel_email(): string {
|
|
69
|
+
return `${SENTINEL_PREFIX}${crypto.randomUUID()}${SENTINEL_DOMAIN}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// section: helpers
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates a managed child user and links them to a parent via a relationship record.
|
|
76
|
+
* The child gets a sentinel email and cannot log in independently.
|
|
77
|
+
* @param adapter - The hazo_connect adapter instance
|
|
78
|
+
* @param data - Child creation data including parent_user_id, name, optional pin and relationship_type
|
|
79
|
+
* @returns Result with child_user_id and relationship_id on success
|
|
80
|
+
*/
|
|
81
|
+
export async function create_managed_child(
|
|
82
|
+
adapter: HazoConnectAdapter,
|
|
83
|
+
data: CreateChildData,
|
|
84
|
+
): Promise<RelationshipServiceResult> {
|
|
85
|
+
try {
|
|
86
|
+
const config = get_relationships_config();
|
|
87
|
+
|
|
88
|
+
// Check feature is enabled
|
|
89
|
+
if (!config.enabled) {
|
|
90
|
+
return { success: false, error: "Relationship accounts feature is not enabled" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate relationship type
|
|
94
|
+
const allowed_types = get_allowed_relationship_types();
|
|
95
|
+
const relationship_type = data.relationship_type || config.default_type;
|
|
96
|
+
|
|
97
|
+
if (!allowed_types.includes(relationship_type)) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: `Invalid relationship type "${relationship_type}". Allowed types: ${allowed_types.join(", ")}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
105
|
+
const users_service = createCrudService(adapter, "hazo_users");
|
|
106
|
+
|
|
107
|
+
// Check child count limit
|
|
108
|
+
const existing_relationships = await relationships_service.findBy({
|
|
109
|
+
parent_user_id: data.parent_user_id,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (Array.isArray(existing_relationships) && existing_relationships.length >= config.max_children_per_user) {
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
error: `Maximum number of managed profiles reached (${config.max_children_per_user})`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Validate and hash PIN if provided
|
|
120
|
+
let pin_hash: string | null = null;
|
|
121
|
+
if (data.pin) {
|
|
122
|
+
if (data.pin.length < config.pin_min_length || data.pin.length > config.pin_max_length) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
error: `PIN must be between ${config.pin_min_length} and ${config.pin_max_length} characters`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
pin_hash = await argon2.hash(data.pin);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Generate IDs
|
|
132
|
+
const child_user_id = crypto.randomUUID();
|
|
133
|
+
const relationship_id = crypto.randomUUID();
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
|
|
136
|
+
// Insert managed child user
|
|
137
|
+
await users_service.insert({
|
|
138
|
+
id: child_user_id,
|
|
139
|
+
email_address: generate_sentinel_email(),
|
|
140
|
+
name: data.name,
|
|
141
|
+
pin_hash: pin_hash,
|
|
142
|
+
managed_by_user_id: data.parent_user_id,
|
|
143
|
+
status: "ACTIVE",
|
|
144
|
+
email_verified: true,
|
|
145
|
+
auth_providers: "managed",
|
|
146
|
+
app_user_data: JSON.stringify(data.app_user_data || {}),
|
|
147
|
+
created_at: now,
|
|
148
|
+
changed_at: now,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Insert relationship record
|
|
152
|
+
await relationships_service.insert({
|
|
153
|
+
id: relationship_id,
|
|
154
|
+
parent_user_id: data.parent_user_id,
|
|
155
|
+
child_user_id: child_user_id,
|
|
156
|
+
relationship_type: relationship_type,
|
|
157
|
+
can_view_progress: 1,
|
|
158
|
+
can_edit_profile: 1,
|
|
159
|
+
can_delete: 0,
|
|
160
|
+
is_self: 0,
|
|
161
|
+
created_at: now,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
child_user_id,
|
|
167
|
+
relationship_id,
|
|
168
|
+
};
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const logger = create_app_logger();
|
|
171
|
+
logger.error("create_managed_child_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
error: "Failed to create managed profile",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Lists all children (managed profiles) linked to a parent user.
|
|
181
|
+
* @param adapter - The hazo_connect adapter instance
|
|
182
|
+
* @param parent_user_id - The parent user's ID
|
|
183
|
+
* @returns Result with array of ChildProfile objects
|
|
184
|
+
*/
|
|
185
|
+
export async function list_children(
|
|
186
|
+
adapter: HazoConnectAdapter,
|
|
187
|
+
parent_user_id: string,
|
|
188
|
+
): Promise<RelationshipServiceResult> {
|
|
189
|
+
try {
|
|
190
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
191
|
+
const users_service = createCrudService(adapter, "hazo_users");
|
|
192
|
+
|
|
193
|
+
// Get all relationships for this parent
|
|
194
|
+
const relationships = await relationships_service.findBy({
|
|
195
|
+
parent_user_id,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!Array.isArray(relationships) || relationships.length === 0) {
|
|
199
|
+
return { success: true, children: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Fetch user data for each child
|
|
203
|
+
const children: ChildProfile[] = [];
|
|
204
|
+
|
|
205
|
+
for (const rel of relationships) {
|
|
206
|
+
const child_user_id = rel.child_user_id as string;
|
|
207
|
+
const child_user = await users_service.findById(child_user_id);
|
|
208
|
+
|
|
209
|
+
if (child_user) {
|
|
210
|
+
const email = child_user.email_address as string | null;
|
|
211
|
+
const app_data_raw = child_user.app_user_data;
|
|
212
|
+
let app_user_data: Record<string, unknown> | null = null;
|
|
213
|
+
|
|
214
|
+
if (app_data_raw) {
|
|
215
|
+
try {
|
|
216
|
+
app_user_data = typeof app_data_raw === "string"
|
|
217
|
+
? JSON.parse(app_data_raw)
|
|
218
|
+
: app_data_raw as Record<string, unknown>;
|
|
219
|
+
} catch {
|
|
220
|
+
app_user_data = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
children.push({
|
|
225
|
+
relationship_id: rel.id as string,
|
|
226
|
+
relationship_type: rel.relationship_type as string,
|
|
227
|
+
can_view_progress: Boolean(rel.can_view_progress),
|
|
228
|
+
can_edit_profile: Boolean(rel.can_edit_profile),
|
|
229
|
+
can_delete: Boolean(rel.can_delete),
|
|
230
|
+
is_self: Boolean(rel.is_self),
|
|
231
|
+
user_id: child_user_id,
|
|
232
|
+
name: (child_user.name as string) || null,
|
|
233
|
+
email: get_display_email(email),
|
|
234
|
+
profile_picture_url: (child_user.profile_picture_url as string) || null,
|
|
235
|
+
app_user_data,
|
|
236
|
+
has_pin: !!(child_user.pin_hash),
|
|
237
|
+
created_at: rel.created_at as string,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { success: true, children };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const logger = create_app_logger();
|
|
245
|
+
logger.error("list_children_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
246
|
+
return {
|
|
247
|
+
success: false,
|
|
248
|
+
error: "Failed to list managed profiles",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Updates relationship permissions between a parent and child.
|
|
255
|
+
* @param adapter - The hazo_connect adapter instance
|
|
256
|
+
* @param relationship_id - The relationship record ID
|
|
257
|
+
* @param parent_user_id - The parent user's ID (for ownership verification)
|
|
258
|
+
* @param updates - Fields to update (can_view_progress, can_edit_profile, can_delete)
|
|
259
|
+
* @returns Success or error result
|
|
260
|
+
*/
|
|
261
|
+
export async function update_relationship(
|
|
262
|
+
adapter: HazoConnectAdapter,
|
|
263
|
+
relationship_id: string,
|
|
264
|
+
parent_user_id: string,
|
|
265
|
+
updates: { can_view_progress?: boolean; can_edit_profile?: boolean; can_delete?: boolean },
|
|
266
|
+
): Promise<RelationshipServiceResult> {
|
|
267
|
+
try {
|
|
268
|
+
// Verify ownership
|
|
269
|
+
const rel = await validate_relationship_ownership(adapter, relationship_id, parent_user_id);
|
|
270
|
+
if (!rel) {
|
|
271
|
+
return { success: false, error: "Relationship not found or access denied" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
275
|
+
|
|
276
|
+
const patch: Record<string, unknown> = {};
|
|
277
|
+
if (updates.can_view_progress !== undefined) patch.can_view_progress = updates.can_view_progress ? 1 : 0;
|
|
278
|
+
if (updates.can_edit_profile !== undefined) patch.can_edit_profile = updates.can_edit_profile ? 1 : 0;
|
|
279
|
+
if (updates.can_delete !== undefined) patch.can_delete = updates.can_delete ? 1 : 0;
|
|
280
|
+
|
|
281
|
+
if (Object.keys(patch).length === 0) {
|
|
282
|
+
return { success: false, error: "No valid fields to update" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await relationships_service.updateById(relationship_id, patch);
|
|
286
|
+
|
|
287
|
+
return { success: true, relationship_id };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const logger = create_app_logger();
|
|
290
|
+
logger.error("update_relationship_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
error: "Failed to update relationship",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Deletes a relationship and optionally the child user.
|
|
300
|
+
* @param adapter - The hazo_connect adapter instance
|
|
301
|
+
* @param relationship_id - The relationship record ID
|
|
302
|
+
* @param parent_user_id - The parent user's ID (for ownership verification)
|
|
303
|
+
* @param delete_child_user - If true, also delete the child user (only if no other parents)
|
|
304
|
+
* @returns Success or error result
|
|
305
|
+
*/
|
|
306
|
+
export async function delete_relationship(
|
|
307
|
+
adapter: HazoConnectAdapter,
|
|
308
|
+
relationship_id: string,
|
|
309
|
+
parent_user_id: string,
|
|
310
|
+
delete_child_user: boolean = false,
|
|
311
|
+
): Promise<RelationshipServiceResult> {
|
|
312
|
+
try {
|
|
313
|
+
// Verify ownership
|
|
314
|
+
const rel = await validate_relationship_ownership(adapter, relationship_id, parent_user_id);
|
|
315
|
+
if (!rel) {
|
|
316
|
+
return { success: false, error: "Relationship not found or access denied" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
320
|
+
const child_user_id = rel.child_user_id as string;
|
|
321
|
+
|
|
322
|
+
// Delete the relationship record
|
|
323
|
+
await relationships_service.deleteById(relationship_id);
|
|
324
|
+
|
|
325
|
+
// Optionally delete the child user
|
|
326
|
+
if (delete_child_user) {
|
|
327
|
+
// Check no other parent relationships exist for this child
|
|
328
|
+
const other_relationships = await relationships_service.findBy({
|
|
329
|
+
child_user_id,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (!Array.isArray(other_relationships) || other_relationships.length === 0) {
|
|
333
|
+
const users_service = createCrudService(adapter, "hazo_users");
|
|
334
|
+
await users_service.deleteById(child_user_id);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { success: true, relationship_id };
|
|
339
|
+
} catch (err) {
|
|
340
|
+
const logger = create_app_logger();
|
|
341
|
+
logger.error("delete_relationship_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
error: "Failed to delete relationship",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Creates a self-relationship where parent_user_id === child_user_id.
|
|
351
|
+
* Used when a parent wants to include themselves in a profile list.
|
|
352
|
+
* @param adapter - The hazo_connect adapter instance
|
|
353
|
+
* @param parent_user_id - The user's ID (acts as both parent and child)
|
|
354
|
+
* @returns Success or error result
|
|
355
|
+
*/
|
|
356
|
+
export async function create_self_relationship(
|
|
357
|
+
adapter: HazoConnectAdapter,
|
|
358
|
+
parent_user_id: string,
|
|
359
|
+
): Promise<RelationshipServiceResult> {
|
|
360
|
+
try {
|
|
361
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
362
|
+
|
|
363
|
+
// Check if self-relationship already exists
|
|
364
|
+
const existing = await relationships_service.findBy({
|
|
365
|
+
parent_user_id,
|
|
366
|
+
child_user_id: parent_user_id,
|
|
367
|
+
is_self: 1,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (Array.isArray(existing) && existing.length > 0) {
|
|
371
|
+
return {
|
|
372
|
+
success: true,
|
|
373
|
+
relationship_id: existing[0].id as string,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const config = get_relationships_config();
|
|
378
|
+
const relationship_id = crypto.randomUUID();
|
|
379
|
+
const now = new Date().toISOString();
|
|
380
|
+
|
|
381
|
+
await relationships_service.insert({
|
|
382
|
+
id: relationship_id,
|
|
383
|
+
parent_user_id,
|
|
384
|
+
child_user_id: parent_user_id,
|
|
385
|
+
relationship_type: config.default_type,
|
|
386
|
+
can_view_progress: 1,
|
|
387
|
+
can_edit_profile: 1,
|
|
388
|
+
can_delete: 0,
|
|
389
|
+
is_self: 1,
|
|
390
|
+
created_at: now,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return { success: true, relationship_id };
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const logger = create_app_logger();
|
|
396
|
+
logger.error("create_self_relationship_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
error: "Failed to create self-relationship",
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Upgrades a managed child user to an independent user with email/password login.
|
|
406
|
+
* Removes sentinel email, sets real email and password, clears managed_by_user_id.
|
|
407
|
+
* @param adapter - The hazo_connect adapter instance
|
|
408
|
+
* @param relationship_id - The relationship record ID
|
|
409
|
+
* @param parent_user_id - The parent user's ID (for ownership verification)
|
|
410
|
+
* @param email - The real email address for the upgraded user
|
|
411
|
+
* @param password - The password for the upgraded user
|
|
412
|
+
* @returns Success or error result
|
|
413
|
+
*/
|
|
414
|
+
export async function upgrade_managed_user(
|
|
415
|
+
adapter: HazoConnectAdapter,
|
|
416
|
+
relationship_id: string,
|
|
417
|
+
parent_user_id: string,
|
|
418
|
+
email: string,
|
|
419
|
+
password: string,
|
|
420
|
+
): Promise<RelationshipServiceResult> {
|
|
421
|
+
try {
|
|
422
|
+
// Verify ownership
|
|
423
|
+
const rel = await validate_relationship_ownership(adapter, relationship_id, parent_user_id);
|
|
424
|
+
if (!rel) {
|
|
425
|
+
return { success: false, error: "Relationship not found or access denied" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const child_user_id = rel.child_user_id as string;
|
|
429
|
+
const users_service = createCrudService(adapter, "hazo_users");
|
|
430
|
+
|
|
431
|
+
// Get child user and verify it's a managed user
|
|
432
|
+
const child_user = await users_service.findById(child_user_id);
|
|
433
|
+
if (!child_user) {
|
|
434
|
+
return { success: false, error: "Child user not found" };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const child_email = child_user.email_address as string;
|
|
438
|
+
if (!is_sentinel_email(child_email)) {
|
|
439
|
+
return { success: false, error: "User is already an independent account" };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Check email not already taken
|
|
443
|
+
const existing = await users_service.findBy({ email_address: email });
|
|
444
|
+
if (Array.isArray(existing) && existing.length > 0) {
|
|
445
|
+
return { success: false, error: "Email address already registered" };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Hash password and update user
|
|
449
|
+
const password_hash = await argon2.hash(password);
|
|
450
|
+
const now = new Date().toISOString();
|
|
451
|
+
|
|
452
|
+
await users_service.updateById(child_user_id, {
|
|
453
|
+
email_address: email,
|
|
454
|
+
password_hash,
|
|
455
|
+
managed_by_user_id: null,
|
|
456
|
+
auth_providers: "email",
|
|
457
|
+
changed_at: now,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
return { success: true, child_user_id, relationship_id };
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const logger = create_app_logger();
|
|
463
|
+
logger.error("upgrade_managed_user_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
error: "Failed to upgrade managed user",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Authenticates a managed child user by verifying their PIN.
|
|
473
|
+
* Used for profile switching where children are protected by a PIN.
|
|
474
|
+
* @param adapter - The hazo_connect adapter instance
|
|
475
|
+
* @param parent_user_id - The parent user's ID
|
|
476
|
+
* @param child_user_id - The child user's ID
|
|
477
|
+
* @param pin - The PIN to verify
|
|
478
|
+
* @returns Result with child user info on success
|
|
479
|
+
*/
|
|
480
|
+
export async function authenticate_by_pin(
|
|
481
|
+
adapter: HazoConnectAdapter,
|
|
482
|
+
parent_user_id: string,
|
|
483
|
+
child_user_id: string,
|
|
484
|
+
pin: string,
|
|
485
|
+
): Promise<{ success: boolean; child_user_id?: string; child_name?: string; child_email?: string | null; error?: string }> {
|
|
486
|
+
try {
|
|
487
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
488
|
+
|
|
489
|
+
// Find relationship between parent and child
|
|
490
|
+
const relationships = await relationships_service.findBy({
|
|
491
|
+
parent_user_id,
|
|
492
|
+
child_user_id,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (!Array.isArray(relationships) || relationships.length === 0) {
|
|
496
|
+
return { success: false, error: "No relationship found between users" };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const users_service = createCrudService(adapter, "hazo_users");
|
|
500
|
+
const child_user = await users_service.findById(child_user_id);
|
|
501
|
+
|
|
502
|
+
if (!child_user) {
|
|
503
|
+
return { success: false, error: "Child user not found" };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const pin_hash = child_user.pin_hash as string;
|
|
507
|
+
if (!pin_hash) {
|
|
508
|
+
return { success: false, error: "No PIN set for this profile" };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Verify PIN
|
|
512
|
+
const is_valid = await argon2.verify(pin_hash, pin);
|
|
513
|
+
if (!is_valid) {
|
|
514
|
+
return { success: false, error: "Invalid PIN" };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
success: true,
|
|
519
|
+
child_user_id,
|
|
520
|
+
child_name: (child_user.name as string) || undefined,
|
|
521
|
+
child_email: get_display_email(child_user.email_address as string),
|
|
522
|
+
};
|
|
523
|
+
} catch (err) {
|
|
524
|
+
const logger = create_app_logger();
|
|
525
|
+
logger.error("authenticate_by_pin_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
526
|
+
return {
|
|
527
|
+
success: false,
|
|
528
|
+
error: "Failed to authenticate by PIN",
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Validates that a relationship belongs to the specified parent user.
|
|
535
|
+
* @param adapter - The hazo_connect adapter instance
|
|
536
|
+
* @param relationship_id - The relationship record ID
|
|
537
|
+
* @param parent_user_id - The expected parent user's ID
|
|
538
|
+
* @returns The relationship record if ownership is valid, null otherwise
|
|
539
|
+
*/
|
|
540
|
+
export async function validate_relationship_ownership(
|
|
541
|
+
adapter: HazoConnectAdapter,
|
|
542
|
+
relationship_id: string,
|
|
543
|
+
parent_user_id: string,
|
|
544
|
+
): Promise<Record<string, unknown> | null> {
|
|
545
|
+
try {
|
|
546
|
+
const relationships_service = createCrudService(adapter, "hazo_user_relationships");
|
|
547
|
+
|
|
548
|
+
const rel = await relationships_service.findById(relationship_id);
|
|
549
|
+
if (!rel) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (rel.parent_user_id !== parent_user_id) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return rel as Record<string, unknown>;
|
|
558
|
+
} catch (err) {
|
|
559
|
+
const logger = create_app_logger();
|
|
560
|
+
logger.error("validate_relationship_ownership_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
@@ -9,6 +9,7 @@ import { get_filename, get_line_number } from "../utils/api_route_helpers.js";
|
|
|
9
9
|
export type SessionTokenPayload = {
|
|
10
10
|
user_id: string;
|
|
11
11
|
email: string;
|
|
12
|
+
managed_by_user_id?: string;
|
|
12
13
|
iat: number;
|
|
13
14
|
exp: number;
|
|
14
15
|
};
|
|
@@ -17,6 +18,7 @@ export type ValidateSessionTokenResult = {
|
|
|
17
18
|
valid: boolean;
|
|
18
19
|
user_id?: string;
|
|
19
20
|
email?: string;
|
|
21
|
+
managed_by_user_id?: string;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
// section: helpers
|
|
@@ -66,19 +68,22 @@ function get_session_token_expiry_seconds(): number {
|
|
|
66
68
|
export async function create_session_token(
|
|
67
69
|
user_id: string,
|
|
68
70
|
email: string,
|
|
71
|
+
managed_by_user_id?: string,
|
|
69
72
|
): Promise<string> {
|
|
70
73
|
const logger = create_app_logger();
|
|
71
|
-
|
|
74
|
+
|
|
72
75
|
try {
|
|
73
76
|
const secret = get_jwt_secret();
|
|
74
77
|
const now = Math.floor(Date.now() / 1000); // Current time in seconds
|
|
75
78
|
const expiry_seconds = get_session_token_expiry_seconds();
|
|
76
79
|
const exp = now + expiry_seconds;
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
80
|
+
|
|
81
|
+
const payload: Record<string, unknown> = { user_id, email };
|
|
82
|
+
if (managed_by_user_id) {
|
|
83
|
+
payload.managed_by_user_id = managed_by_user_id;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const jwt = await new SignJWT(payload)
|
|
82
87
|
.setProtectedHeader({ alg: "HS256" })
|
|
83
88
|
.setIssuedAt(now)
|
|
84
89
|
.setExpirationTime(exp)
|
|
@@ -128,10 +133,11 @@ export async function validate_session_token(
|
|
|
128
133
|
algorithms: ["HS256"],
|
|
129
134
|
});
|
|
130
135
|
|
|
131
|
-
// Extract user_id and
|
|
136
|
+
// Extract user_id, email, and managed_by_user_id from payload
|
|
132
137
|
const user_id = payload.user_id as string;
|
|
133
138
|
const email = payload.email as string;
|
|
134
|
-
|
|
139
|
+
const managed_by_user_id = payload.managed_by_user_id as string | undefined;
|
|
140
|
+
|
|
135
141
|
if (!user_id || !email) {
|
|
136
142
|
logger.warn("session_token_invalid_payload", {
|
|
137
143
|
filename: get_filename(),
|
|
@@ -153,6 +159,7 @@ export async function validate_session_token(
|
|
|
153
159
|
valid: true,
|
|
154
160
|
user_id,
|
|
155
161
|
email,
|
|
162
|
+
managed_by_user_id,
|
|
156
163
|
};
|
|
157
164
|
} catch (error) {
|
|
158
165
|
const error_message = error instanceof Error ? error.message : "Unknown error";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// file_description: Shared utility for rewriting request.url behind reverse proxies (Cloudflare Tunnel, nginx, AWS ALB)
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects the public-facing origin from proxy headers.
|
|
6
|
+
* Returns null if not behind a proxy (origins match).
|
|
7
|
+
*/
|
|
8
|
+
function detect_public_origin(request_url: URL, headers: Headers): string | null {
|
|
9
|
+
// Priority 1: NEXTAUTH_URL env var (explicitly configured)
|
|
10
|
+
const nextauth_url = process.env.NEXTAUTH_URL;
|
|
11
|
+
if (nextauth_url) {
|
|
12
|
+
try {
|
|
13
|
+
const public_origin = new URL(nextauth_url).origin;
|
|
14
|
+
if (public_origin !== request_url.origin) {
|
|
15
|
+
return public_origin;
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// Invalid NEXTAUTH_URL, fall through
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Priority 2: x-forwarded-host header (set by Cloudflare Tunnel, nginx, etc.)
|
|
23
|
+
const forwarded_host = headers.get("x-forwarded-host");
|
|
24
|
+
if (forwarded_host && forwarded_host !== request_url.hostname) {
|
|
25
|
+
const proto = headers.get("x-forwarded-proto") || "https";
|
|
26
|
+
return `${proto}://${forwarded_host}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Priority 3: host header (Cloudflare Tunnel also sets this)
|
|
30
|
+
const host_header = headers.get("host");
|
|
31
|
+
if (host_header && host_header !== request_url.host) {
|
|
32
|
+
const proto = headers.get("x-forwarded-proto") || "https";
|
|
33
|
+
return `${proto}://${host_header}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Rewrites request.url to use the public-facing origin when behind a reverse proxy.
|
|
41
|
+
*
|
|
42
|
+
* Behind proxies like Cloudflare Tunnel, Next.js sets request.url to the internal
|
|
43
|
+
* address (e.g., https://localhost:3000). This causes NextResponse.redirect() to
|
|
44
|
+
* resolve Location headers against the internal origin instead of the public domain.
|
|
45
|
+
*
|
|
46
|
+
* Apply this at the TOP of any route handler that uses NextResponse.redirect().
|
|
47
|
+
*
|
|
48
|
+
* @param request - The incoming NextRequest
|
|
49
|
+
* @returns A new NextRequest with corrected URL, or the original if no proxy detected
|
|
50
|
+
*/
|
|
51
|
+
export function rewrite_request_for_proxy(request: NextRequest): NextRequest {
|
|
52
|
+
try {
|
|
53
|
+
const request_url = new URL(request.url);
|
|
54
|
+
const public_origin = detect_public_origin(request_url, request.headers);
|
|
55
|
+
|
|
56
|
+
if (!public_origin) {
|
|
57
|
+
return request; // Not behind a proxy, no rewriting needed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Construct corrected URL: public origin + original path + query
|
|
61
|
+
const corrected_url = `${public_origin}${request_url.pathname}${request_url.search}`;
|
|
62
|
+
|
|
63
|
+
// Create new NextRequest with corrected URL, preserving everything else
|
|
64
|
+
return new NextRequest(corrected_url, {
|
|
65
|
+
method: request.method,
|
|
66
|
+
headers: request.headers,
|
|
67
|
+
body: request.body,
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return request;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gets the public-facing origin for constructing redirect URLs.
|
|
76
|
+
* Use this when you need the origin string but don't need to rewrite the request.
|
|
77
|
+
*/
|
|
78
|
+
export function get_public_origin(request: NextRequest): string {
|
|
79
|
+
const request_url = new URL(request.url);
|
|
80
|
+
return detect_public_origin(request_url, request.headers) || request_url.origin;
|
|
81
|
+
}
|