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.
Files changed (84) hide show
  1. package/README.md +79 -3
  2. package/SETUP_CHECKLIST.md +76 -4
  3. package/cli-src/cli/init.ts +35 -8
  4. package/cli-src/lib/auth/auth_types.ts +1 -0
  5. package/cli-src/lib/auth/hazo_get_auth.server.ts +1 -0
  6. package/cli-src/lib/auth/nextauth_config.ts +3 -3
  7. package/cli-src/lib/config/default_config.ts +21 -0
  8. package/cli-src/lib/oauth_config.server.ts +18 -0
  9. package/cli-src/lib/relationships_config.server.ts +47 -0
  10. package/cli-src/lib/schema/sqlite_schema.ts +21 -0
  11. package/cli-src/lib/services/relationship_service.ts +563 -0
  12. package/cli-src/lib/services/session_token_service.ts +15 -8
  13. package/cli-src/lib/utils/proxy_request.ts +81 -0
  14. package/cli-src/server/types/app_types.ts +2 -2
  15. package/config/hazo_auth_config.example.ini +52 -0
  16. package/dist/cli/init.d.ts.map +1 -1
  17. package/dist/cli/init.js +29 -3
  18. package/dist/components/layouts/dev_lock/index.d.ts.map +1 -1
  19. package/dist/components/layouts/dev_lock/index.js +9 -4
  20. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  21. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
  22. package/dist/lib/auth/auth_types.d.ts +1 -0
  23. package/dist/lib/auth/auth_types.d.ts.map +1 -1
  24. package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
  25. package/dist/lib/auth/hazo_get_auth.server.js +1 -0
  26. package/dist/lib/auth/nextauth_config.js +3 -3
  27. package/dist/lib/config/default_config.d.ts +36 -0
  28. package/dist/lib/config/default_config.d.ts.map +1 -1
  29. package/dist/lib/config/default_config.js +20 -0
  30. package/dist/lib/oauth_config.server.d.ts +4 -0
  31. package/dist/lib/oauth_config.server.d.ts.map +1 -1
  32. package/dist/lib/oauth_config.server.js +4 -0
  33. package/dist/lib/relationships_config.server.d.ts +19 -0
  34. package/dist/lib/relationships_config.server.d.ts.map +1 -0
  35. package/dist/lib/relationships_config.server.js +26 -0
  36. package/dist/lib/schema/sqlite_schema.d.ts +1 -1
  37. package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
  38. package/dist/lib/schema/sqlite_schema.js +21 -0
  39. package/dist/lib/services/relationship_service.d.ts +124 -0
  40. package/dist/lib/services/relationship_service.d.ts.map +1 -0
  41. package/dist/lib/services/relationship_service.js +431 -0
  42. package/dist/lib/services/session_token_service.d.ts +3 -1
  43. package/dist/lib/services/session_token_service.d.ts.map +1 -1
  44. package/dist/lib/services/session_token_service.js +9 -6
  45. package/dist/lib/utils/proxy_request.d.ts +20 -0
  46. package/dist/lib/utils/proxy_request.d.ts.map +1 -0
  47. package/dist/lib/utils/proxy_request.js +74 -0
  48. package/dist/page_components/forgot_password.js +2 -2
  49. package/dist/page_components/login.js +2 -2
  50. package/dist/page_components/register.js +1 -1
  51. package/dist/page_components/reset_password.js +2 -2
  52. package/dist/page_components/verify_email.js +2 -2
  53. package/dist/server/routes/index.d.ts +4 -0
  54. package/dist/server/routes/index.d.ts.map +1 -1
  55. package/dist/server/routes/index.js +5 -0
  56. package/dist/server/routes/me.d.ts.map +1 -1
  57. package/dist/server/routes/me.js +10 -1
  58. package/dist/server/routes/nextauth.d.ts.map +1 -1
  59. package/dist/server/routes/nextauth.js +94 -23
  60. package/dist/server/routes/oauth_google_callback.d.ts +1 -1
  61. package/dist/server/routes/oauth_google_callback.d.ts.map +1 -1
  62. package/dist/server/routes/oauth_google_callback.js +25 -14
  63. package/dist/server/routes/pin_login.d.ts +17 -0
  64. package/dist/server/routes/pin_login.d.ts.map +1 -0
  65. package/dist/server/routes/pin_login.js +123 -0
  66. package/dist/server/routes/relationship_self.d.ts +13 -0
  67. package/dist/server/routes/relationship_self.d.ts.map +1 -0
  68. package/dist/server/routes/relationship_self.js +59 -0
  69. package/dist/server/routes/relationship_upgrade.d.ts +13 -0
  70. package/dist/server/routes/relationship_upgrade.d.ts.map +1 -0
  71. package/dist/server/routes/relationship_upgrade.js +66 -0
  72. package/dist/server/routes/relationships.d.ts +42 -0
  73. package/dist/server/routes/relationships.d.ts.map +1 -0
  74. package/dist/server/routes/relationships.js +217 -0
  75. package/dist/server/routes/remove_profile_picture.d.ts.map +1 -1
  76. package/dist/server/routes/remove_profile_picture.js +22 -1
  77. package/dist/server/routes/upload_profile_picture.d.ts.map +1 -1
  78. package/dist/server/routes/upload_profile_picture.js +25 -4
  79. package/dist/server/types/app_types.d.ts +2 -2
  80. package/dist/server/types/app_types.d.ts.map +1 -1
  81. package/package.json +122 -38
  82. package/dist/lib/index.d.ts +0 -32
  83. package/dist/lib/index.d.ts.map +0 -1
  84. 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 jwt = await new SignJWT({
79
- user_id,
80
- email,
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 email from payload
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
+ }