hazo_auth 5.3.1 → 6.1.1

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 (126) hide show
  1. package/README.md +167 -17
  2. package/SETUP_CHECKLIST.md +99 -7
  3. package/cli-src/cli/generate.ts +10 -1
  4. package/cli-src/cli/validate.ts +4 -0
  5. package/cli-src/lib/auth/auth_types.ts +21 -12
  6. package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +25 -24
  7. package/cli-src/lib/auth/index.ts +2 -2
  8. package/cli-src/lib/auth/with_auth.server.ts +15 -15
  9. package/cli-src/lib/cookies_config.server.ts +1 -0
  10. package/cli-src/lib/login_config.server.ts +14 -0
  11. package/cli-src/lib/otp_config.server.ts +91 -0
  12. package/cli-src/lib/services/email_service.ts +3 -1
  13. package/cli-src/lib/services/email_template_manifest.ts +17 -0
  14. package/cli-src/lib/services/email_templates/otp_signin_code.html +13 -0
  15. package/cli-src/lib/services/email_templates/otp_signin_code.txt +5 -0
  16. package/cli-src/lib/services/index.ts +8 -2
  17. package/cli-src/lib/services/otp_service.ts +295 -0
  18. package/cli-src/lib/services/session_token_service.ts +4 -1
  19. package/config/hazo_auth_config.example.ini +38 -0
  20. package/dist/cli/generate.d.ts.map +1 -1
  21. package/dist/cli/generate.js +10 -1
  22. package/dist/cli/validate.d.ts.map +1 -1
  23. package/dist/cli/validate.js +4 -0
  24. package/dist/client.d.ts +2 -0
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +1 -0
  27. package/dist/components/layouts/login/index.d.ts +7 -1
  28. package/dist/components/layouts/login/index.d.ts.map +1 -1
  29. package/dist/components/layouts/login/index.js +2 -2
  30. package/dist/components/layouts/otp/index.d.ts +10 -0
  31. package/dist/components/layouts/otp/index.d.ts.map +1 -0
  32. package/dist/components/layouts/otp/index.js +14 -0
  33. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  34. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +8 -3
  35. package/dist/components/otp/OTPRequestForm.d.ts +11 -0
  36. package/dist/components/otp/OTPRequestForm.d.ts.map +1 -0
  37. package/dist/components/otp/OTPRequestForm.js +42 -0
  38. package/dist/components/otp/OTPVerifyForm.d.ts +16 -0
  39. package/dist/components/otp/OTPVerifyForm.d.ts.map +1 -0
  40. package/dist/components/otp/OTPVerifyForm.js +75 -0
  41. package/dist/components/otp/index.d.ts +5 -0
  42. package/dist/components/otp/index.d.ts.map +1 -0
  43. package/dist/components/otp/index.js +2 -0
  44. package/dist/components/ui/input-otp.d.ts +35 -0
  45. package/dist/components/ui/input-otp.d.ts.map +1 -0
  46. package/dist/components/ui/input-otp.js +44 -0
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/lib/auth/auth_types.d.ts +13 -12
  50. package/dist/lib/auth/auth_types.d.ts.map +1 -1
  51. package/dist/lib/auth/auth_types.js +8 -0
  52. package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts +8 -7
  53. package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -1
  54. package/dist/lib/auth/hazo_get_tenant_auth.server.js +23 -22
  55. package/dist/lib/auth/index.d.ts +2 -2
  56. package/dist/lib/auth/index.d.ts.map +1 -1
  57. package/dist/lib/auth/with_auth.server.d.ts +13 -13
  58. package/dist/lib/auth/with_auth.server.d.ts.map +1 -1
  59. package/dist/lib/auth/with_auth.server.js +2 -2
  60. package/dist/lib/cookies_config.server.d.ts +1 -0
  61. package/dist/lib/cookies_config.server.d.ts.map +1 -1
  62. package/dist/lib/cookies_config.server.js +1 -0
  63. package/dist/lib/login_config.server.d.ts +6 -0
  64. package/dist/lib/login_config.server.d.ts.map +1 -1
  65. package/dist/lib/login_config.server.js +7 -0
  66. package/dist/lib/otp_config.server.d.ts +49 -0
  67. package/dist/lib/otp_config.server.d.ts.map +1 -0
  68. package/dist/lib/otp_config.server.js +48 -0
  69. package/dist/lib/services/email_service.d.ts +1 -1
  70. package/dist/lib/services/email_service.d.ts.map +1 -1
  71. package/dist/lib/services/email_service.js +2 -0
  72. package/dist/lib/services/email_template_manifest.d.ts.map +1 -1
  73. package/dist/lib/services/email_template_manifest.js +17 -0
  74. package/dist/lib/services/email_templates/otp_signin_code.html +13 -0
  75. package/dist/lib/services/email_templates/otp_signin_code.txt +5 -0
  76. package/dist/lib/services/index.d.ts +2 -0
  77. package/dist/lib/services/index.d.ts.map +1 -1
  78. package/dist/lib/services/index.js +1 -0
  79. package/dist/lib/services/otp_service.d.ts +46 -0
  80. package/dist/lib/services/otp_service.d.ts.map +1 -0
  81. package/dist/lib/services/otp_service.js +238 -0
  82. package/dist/lib/services/session_token_service.d.ts +3 -1
  83. package/dist/lib/services/session_token_service.d.ts.map +1 -1
  84. package/dist/lib/services/session_token_service.js +4 -2
  85. package/dist/page_components/otp.d.ts +4 -0
  86. package/dist/page_components/otp.d.ts.map +1 -0
  87. package/dist/page_components/otp.js +5 -0
  88. package/dist/server/routes/index.d.ts +2 -0
  89. package/dist/server/routes/index.d.ts.map +1 -1
  90. package/dist/server/routes/index.js +3 -0
  91. package/dist/server/routes/me.d.ts.map +1 -1
  92. package/dist/server/routes/me.js +43 -1
  93. package/dist/server/routes/otp/request.d.ts +3 -0
  94. package/dist/server/routes/otp/request.d.ts.map +1 -0
  95. package/dist/server/routes/otp/request.js +33 -0
  96. package/dist/server/routes/otp/verify.d.ts +3 -0
  97. package/dist/server/routes/otp/verify.d.ts.map +1 -0
  98. package/dist/server/routes/otp/verify.js +58 -0
  99. package/dist/server-lib.d.ts +3 -0
  100. package/dist/server-lib.d.ts.map +1 -1
  101. package/dist/server-lib.js +2 -0
  102. package/dist/server_pages/forgot_password.d.ts +1 -1
  103. package/dist/server_pages/forgot_password.d.ts.map +1 -1
  104. package/dist/server_pages/forgot_password.js +2 -1
  105. package/dist/server_pages/login.d.ts +1 -1
  106. package/dist/server_pages/login.d.ts.map +1 -1
  107. package/dist/server_pages/login.js +3 -2
  108. package/dist/server_pages/login_client_wrapper.d.ts +1 -1
  109. package/dist/server_pages/login_client_wrapper.d.ts.map +1 -1
  110. package/dist/server_pages/login_client_wrapper.js +2 -2
  111. package/dist/server_pages/my_settings.d.ts +1 -1
  112. package/dist/server_pages/my_settings.d.ts.map +1 -1
  113. package/dist/server_pages/my_settings.js +2 -1
  114. package/dist/server_pages/otp.d.ts +42 -0
  115. package/dist/server_pages/otp.d.ts.map +1 -0
  116. package/dist/server_pages/otp.js +38 -0
  117. package/dist/server_pages/register.d.ts +1 -1
  118. package/dist/server_pages/register.d.ts.map +1 -1
  119. package/dist/server_pages/register.js +2 -1
  120. package/dist/server_pages/reset_password.d.ts +1 -1
  121. package/dist/server_pages/reset_password.d.ts.map +1 -1
  122. package/dist/server_pages/reset_password.js +2 -1
  123. package/dist/server_pages/verify_email.d.ts +1 -1
  124. package/dist/server_pages/verify_email.d.ts.map +1 -1
  125. package/dist/server_pages/verify_email.js +2 -1
  126. package/package.json +20 -3
package/README.md CHANGED
@@ -2,6 +2,154 @@
2
2
 
3
3
  A reusable authentication UI component package powered by Next.js, TailwindCSS, and shadcn. It integrates `hazo_config` for configuration management and `hazo_connect` for data access, enabling future components to stay aligned with platform conventions.
4
4
 
5
+ ## What's New in v6.1.0
6
+
7
+ **Email-OTP sign-in** — a passwordless, OAuth-free way to sign in via a 6-digit code emailed to the user. Targeted at single-operator deployments that don't want to wire Google OAuth or manage passwords.
8
+
9
+ ### Highlights
10
+
11
+ - **New routes** — `POST /api/hazo_auth/otp/request` and `POST /api/hazo_auth/otp/verify`
12
+ - **New components** — `<OTPRequestForm/>` and `<OTPVerifyForm/>` exported from `hazo_auth/client`
13
+ - **New page** — `/hazo_auth/otp` (zero-config) via `hazo_auth/pages/otp`
14
+ - **Sliding 7-day session** — OTP sessions auto-extend on `/me` when within 24h of expiry. OAuth/password sessions keep their existing 30-day fixed behaviour.
15
+ - **Auto-register (opt-in)** — set `otp_auto_register = true` to let unknown emails sign in on first successful /verify
16
+ - **Rate limited** — 3 requests/email/15min, 20 requests/IP/hour; per-code attempts capped at 5
17
+
18
+ ### Consumer example
19
+
20
+ Mount the routes:
21
+
22
+ ```ts
23
+ // app/api/hazo_auth/otp/request/route.ts
24
+ export { otpRequestPOST as POST } from "hazo_auth/server/routes";
25
+
26
+ // app/api/hazo_auth/otp/verify/route.ts
27
+ export { otpVerifyPOST as POST } from "hazo_auth/server/routes";
28
+ ```
29
+
30
+ Render the zero-config page:
31
+
32
+ ```tsx
33
+ // app/hazo_auth/otp/page.tsx
34
+ export { default } from "hazo_auth/pages/otp";
35
+ ```
36
+
37
+ Or compose components yourself:
38
+
39
+ ```tsx
40
+ "use client";
41
+ import { OTPRequestForm, OTPVerifyForm } from "hazo_auth/client";
42
+ import { useState } from "react";
43
+
44
+ export default function CustomOtp() {
45
+ const [sent_to, set_sent_to] = useState<string | null>(null);
46
+ return sent_to
47
+ ? <OTPVerifyForm email={sent_to} redirect_url="/" />
48
+ : <OTPRequestForm on_sent={set_sent_to} />;
49
+ }
50
+ ```
51
+
52
+ ### Required setup
53
+
54
+ 1. Apply migration: `npm run migrate migrations/015_email_otp.sql`
55
+ 2. Add `[hazo_auth__otp]` section to `config/hazo_auth_config.ini` (template in `hazo_auth_config.example.ini`)
56
+ 3. `hazo_notify ^3.0.0` (existing peer dep) must be configured to deliver email
57
+ 4. New peer dep: `input-otp` (optional; required only if you render `<OTPVerifyForm/>`)
58
+ 5. **Add OTP routes to your middleware/proxy `public_routes`** — unauthenticated users land on the OTP page, so the page route and its API routes must bypass auth guards:
59
+
60
+ ```typescript
61
+ // middleware.ts / proxy.ts (in your consuming app)
62
+ const public_routes = [
63
+ // ... existing entries ...
64
+ "/hazo_auth/otp", // OTP sign-in page (public — users arrive unauthenticated)
65
+ "/api/hazo_auth/otp", // OTP request + verify API routes
66
+ ];
67
+ ```
68
+
69
+ Without this your middleware redirects unauthenticated users away from the sign-in page before they can authenticate.
70
+
71
+ See `MIGRATION.md` "v6.0.x → v6.1" for the full upgrade walkthrough.
72
+
73
+ ---
74
+
75
+ ### What's New in v6.0.0 🚨 BREAKING CHANGE
76
+
77
+ **`TenantAuthResult.organization` / `.organization_id` renamed to `.selected_scope` / `.selected_scope_id`.**
78
+
79
+ The multi-tenancy model has been scope-based for several releases — `auth.user_scopes`, `auth.scope_access_via`, `auth.scope_ok`, the `hazo_auth_scope_id` cookie, the `X-Hazo-Scope-Id` header. The only holdouts using the legacy "organization" name were two fields on `TenantAuthResult` that were always just *"the currently selected scope"* under the hood. This release eliminates the inconsistency.
80
+
81
+ > 📖 **Full migration guide:** [MIGRATION.md → v5.x → v6.0](./MIGRATION.md#v5x--v60-organization--selected_scope)
82
+
83
+ **No behavioral change.** `selected_scope_id` is derived from the same scope-selection cookie/header that `organization_id` was. Wire-format auth responses change field names, but the underlying scope lookup is identical.
84
+
85
+ **No deprecation shim.** A clean find-replace is the upgrade path. If you absolutely need a transitional period, pin to `hazo_auth@^5.3` until you can do the rename in one shot.
86
+
87
+ **What to replace (find ↔ replace, all in your app code):**
88
+
89
+ | Find | Replace with |
90
+ | ------------------------------------------------- | -------------------------------------------------- |
91
+ | `auth.organization` | `auth.selected_scope` |
92
+ | `auth.organization_id` | `auth.selected_scope_id` |
93
+ | `import type { TenantOrganization }` | `import type { SelectedScope }` |
94
+ | `: TenantOrganization` | `: SelectedScope` |
95
+ | `AuthenticatedTenantAuthWithOrg` | `AuthenticatedTenantAuthWithSelectedScope` |
96
+
97
+ **Before / after example:**
98
+
99
+ ```typescript
100
+ // BEFORE (v5.x)
101
+ import { hazo_get_tenant_auth } from "hazo_auth/server-lib";
102
+ import type { TenantOrganization } from "hazo_auth";
103
+
104
+ export async function GET(request: NextRequest) {
105
+ const auth = await hazo_get_tenant_auth(request);
106
+ if (!auth.authenticated || !auth.organization) {
107
+ return NextResponse.json({ error: "no tenant" }, { status: 403 });
108
+ }
109
+ const data = await getData(auth.organization_id); // or auth.organization.id
110
+ return NextResponse.json({ org: auth.organization, data });
111
+ }
112
+
113
+ // AFTER (v6.0)
114
+ import { hazo_get_tenant_auth } from "hazo_auth/server-lib";
115
+ import type { SelectedScope } from "hazo_auth";
116
+
117
+ export async function GET(request: NextRequest) {
118
+ const auth = await hazo_get_tenant_auth(request);
119
+ if (!auth.authenticated || !auth.selected_scope) {
120
+ return NextResponse.json({ error: "no tenant scope" }, { status: 403 });
121
+ }
122
+ const data = await getData(auth.selected_scope_id); // or auth.selected_scope.id
123
+ return NextResponse.json({ selected_scope: auth.selected_scope, data });
124
+ }
125
+ ```
126
+
127
+ **What did NOT change** (same names, same behavior, same wire format):
128
+ - `auth.user_scopes` — array of all scopes the user has access to
129
+ - `auth.scope_ok`, `auth.scope_access_via` — scope-access fields
130
+ - `hazo_auth_scope_id` cookie name and `X-Hazo-Scope-Id` request header
131
+ - All `Tenant*` type and class names: `TenantAuthResult`, `TenantAuthOptions`, `RequiredTenantAuthResult`, `TenantRequiredError`, `TenantAccessDeniedError`, `hazo_get_tenant_auth`, `require_tenant_auth`, `withAuth`'s `require_tenant` option
132
+ - The error response `{ code: "TENANT_REQUIRED" }` shape — only the human-readable `error` string was rephrased ("Organization context required" → "Tenant scope context required")
133
+
134
+ **One-liner upgrade for typical consumers:**
135
+
136
+ ```bash
137
+ # Run from your app repo (NOT inside hazo_auth itself).
138
+ # Update the path glob to match your code layout.
139
+ git grep -l "organization\b\|TenantOrganization\|AuthenticatedTenantAuthWithOrg" -- 'src/**' 'app/**' 'lib/**' \
140
+ | xargs sed -i.bak '
141
+ s/auth\.organization_id\b/auth.selected_scope_id/g;
142
+ s/auth\.organization\b/auth.selected_scope/g;
143
+ s/\bTenantOrganization\b/SelectedScope/g;
144
+ s/\bAuthenticatedTenantAuthWithOrg\b/AuthenticatedTenantAuthWithSelectedScope/g;
145
+ '
146
+ # Then: review the diff carefully — sed is blunt. Remove .bak files when satisfied.
147
+ ```
148
+
149
+ > ⚠️ The sed line is a starting point, not a blanket "trust me." It only catches `auth.organization*` and the type names. If you destructure (`const { organization } = auth`), aliased imports (`TenantOrganization as Org`), or reference `organization` for unrelated reasons (HTML autocomplete attribute, your own variables), the script either misses or wrongly rewrites them. **Always diff before committing.**
150
+
151
+ ---
152
+
5
153
  ### What's New in v5.3.1 🔧
6
154
 
7
155
  **`get_client_ip(request)` exported from `hazo_auth/server-lib`** — extracts the client IP from `x-forwarded-for` (first element), falling back to `x-real-ip`, then `"unknown"`. Previously private to `hazo_get_auth.server.ts`. Useful for consumers that need consistent IP extraction across handlers (e.g., `hazo_feedback` audit logging).
@@ -1644,7 +1792,7 @@ export async function proxy(request: NextRequest) {
1644
1792
 
1645
1793
  #### `hazo_get_tenant_auth` (Recommended for Multi-Tenant Apps)
1646
1794
 
1647
- **New:** Tenant-aware authentication function that extracts scope context from request headers or cookies and returns enriched result with organization information.
1795
+ **New:** Tenant-aware authentication function that extracts scope context from request headers or cookies and returns enriched result including the currently selected scope.
1648
1796
 
1649
1797
  **Location:** `src/lib/auth/hazo_get_tenant_auth.server.ts`
1650
1798
 
@@ -1678,8 +1826,9 @@ type TenantAuthResult =
1678
1826
  permissions: string[];
1679
1827
  permission_ok: boolean;
1680
1828
  missing_permissions?: string[];
1681
- organization: TenantOrganization | null; // NEW: Tenant context
1682
- user_scopes: ScopeDetails[]; // NEW: All user's scopes for switching
1829
+ selected_scope: SelectedScope | null; // Currently selected tenant/scope
1830
+ selected_scope_id: string | null; // Shorthand for selected_scope?.id
1831
+ user_scopes: ScopeDetails[]; // All user's scopes for switching
1683
1832
  scope_ok: boolean;
1684
1833
  }
1685
1834
  | {
@@ -1687,12 +1836,13 @@ type TenantAuthResult =
1687
1836
  user: null;
1688
1837
  permissions: [];
1689
1838
  permission_ok: false;
1690
- organization: null;
1839
+ selected_scope: null;
1840
+ selected_scope_id: null;
1691
1841
  user_scopes: [];
1692
1842
  scope_ok: false;
1693
1843
  };
1694
1844
 
1695
- type TenantOrganization = {
1845
+ type SelectedScope = {
1696
1846
  id: string;
1697
1847
  name: string;
1698
1848
  slug: string | null; // URL-friendly identifier
@@ -1734,10 +1884,10 @@ export async function GET(request: NextRequest) {
1734
1884
  return NextResponse.json({ error: "Authentication required" }, { status: 401 });
1735
1885
  }
1736
1886
 
1737
- if (!auth.organization) {
1887
+ if (!auth.selected_scope) {
1738
1888
  return NextResponse.json(
1739
1889
  {
1740
- error: "No organization context",
1890
+ error: "No tenant scope context",
1741
1891
  available_scopes: auth.user_scopes.map(s => ({ id: s.id, name: s.name }))
1742
1892
  },
1743
1893
  { status: 403 }
@@ -1745,10 +1895,10 @@ export async function GET(request: NextRequest) {
1745
1895
  }
1746
1896
 
1747
1897
  // Access tenant-specific data
1748
- const data = await getTenantData(auth.organization.id);
1898
+ const data = await getTenantData(auth.selected_scope.id);
1749
1899
 
1750
1900
  return NextResponse.json({
1751
- organization: auth.organization,
1901
+ selected_scope: auth.selected_scope,
1752
1902
  data,
1753
1903
  // Include available scopes for UI scope switcher
1754
1904
  available_scopes: auth.user_scopes,
@@ -1766,8 +1916,8 @@ export async function GET(request: NextRequest) {
1766
1916
  required_permissions: ["view_reports"],
1767
1917
  });
1768
1918
 
1769
- // auth.organization is guaranteed non-null here
1770
- const reports = await getReports(auth.organization.id);
1919
+ // auth.selected_scope is guaranteed non-null here
1920
+ const reports = await getReports(auth.selected_scope.id);
1771
1921
  return NextResponse.json({ reports });
1772
1922
  } catch (error) {
1773
1923
  if (error instanceof HazoAuthError) {
@@ -1817,16 +1967,16 @@ Helper function that wraps `hazo_get_tenant_auth` and throws typed errors for co
1817
1967
  - `TenantRequiredError` (403) - No tenant context in request
1818
1968
  - `TenantAccessDeniedError` (403) - User lacks access to requested tenant
1819
1969
 
1820
- **Returns:** `RequiredTenantAuthResult` with guaranteed non-null `organization`
1970
+ **Returns:** `RequiredTenantAuthResult` with guaranteed non-null `selected_scope`
1821
1971
 
1822
1972
  **Example:**
1823
1973
  ```typescript
1824
1974
  export async function GET(request: NextRequest) {
1825
1975
  try {
1826
- // organization is guaranteed to exist
1827
- const { organization, user, permissions } = await require_tenant_auth(request);
1976
+ // selected_scope is guaranteed to exist
1977
+ const { selected_scope, user, permissions } = await require_tenant_auth(request);
1828
1978
 
1829
- const data = await getData(organization.id);
1979
+ const data = await getData(selected_scope.id);
1830
1980
  return NextResponse.json(data);
1831
1981
  } catch (error) {
1832
1982
  if (error instanceof HazoAuthError) {
@@ -1879,10 +2029,10 @@ export const DELETE = withAuth<{ id: string }>(
1879
2029
  { required_permissions: ["admin_system"] }
1880
2030
  );
1881
2031
 
1882
- // With tenant requirement (auth.organization guaranteed non-null)
2032
+ // With tenant requirement (auth.selected_scope guaranteed non-null)
1883
2033
  export const GET = withAuth<{ id: string }>(
1884
2034
  async (request, auth, { id }) => {
1885
- const data = await getData(auth.organization.id, id);
2035
+ const data = await getData(auth.selected_scope.id, id);
1886
2036
  return NextResponse.json(data);
1887
2037
  },
1888
2038
  { require_tenant: true }
@@ -1443,6 +1443,98 @@ export { POST } from "hazo_auth/server/routes/pin_login";
1443
1443
 
1444
1444
  ---
1445
1445
 
1446
+ ## Phase 5.3: Email-OTP Sign-in Setup (Optional)
1447
+
1448
+ Email-OTP sign-in lets users authenticate with a 6-digit code sent to their email — no password or Google OAuth required. Skip this phase if your app uses only password or OAuth authentication.
1449
+
1450
+ ### Step 5.3.1: Apply the OTP migration
1451
+
1452
+ ```bash
1453
+ npm run migrate migrations/015_email_otp.sql
1454
+ ```
1455
+
1456
+ This creates the `hazo_email_otps` table (stores hashed codes with expiry).
1457
+
1458
+ **Verify the table exists:**
1459
+ ```bash
1460
+ sqlite3 data/hazo_auth.sqlite ".tables" | tr ' ' '\n' | grep hazo_email_otps
1461
+ # Expected: hazo_email_otps
1462
+ ```
1463
+
1464
+ ### Step 5.3.2: Add OTP config section
1465
+
1466
+ Add to `config/hazo_auth_config.ini`:
1467
+
1468
+ ```ini
1469
+ [hazo_auth__otp]
1470
+ # Whether OTP sign-in is enabled
1471
+ enabled = true
1472
+
1473
+ # Auto-register unknown emails on first successful verify (default: false)
1474
+ otp_auto_register = false
1475
+
1476
+ # Code expiry in minutes (default: 10)
1477
+ code_expiry_minutes = 10
1478
+ ```
1479
+
1480
+ ### Step 5.3.3: Create OTP API routes
1481
+
1482
+ ```bash
1483
+ npx hazo_auth generate-routes
1484
+ ```
1485
+
1486
+ Or manually:
1487
+
1488
+ `app/api/hazo_auth/otp/request/route.ts`:
1489
+ ```typescript
1490
+ export { otpRequestPOST as POST } from "hazo_auth/server/routes";
1491
+ ```
1492
+
1493
+ `app/api/hazo_auth/otp/verify/route.ts`:
1494
+ ```typescript
1495
+ export { otpVerifyPOST as POST } from "hazo_auth/server/routes";
1496
+ ```
1497
+
1498
+ ### Step 5.3.4: Create the OTP sign-in page
1499
+
1500
+ `app/hazo_auth/otp/page.tsx`:
1501
+ ```typescript
1502
+ export { default } from "hazo_auth/pages/otp";
1503
+ ```
1504
+
1505
+ ### Step 5.3.5: Add OTP routes to middleware/proxy public_routes (REQUIRED)
1506
+
1507
+ **This step is critical.** Unauthenticated users arrive at the OTP sign-in page before they have auth cookies. If the OTP routes are not in `public_routes`, your middleware will redirect them to `/login` in a loop.
1508
+
1509
+ Edit your `middleware.ts` or `proxy.ts`:
1510
+
1511
+ ```typescript
1512
+ const public_routes = [
1513
+ // ... existing entries ...
1514
+ "/hazo_auth/otp", // OTP sign-in page (public — users arrive unauthenticated)
1515
+ "/api/hazo_auth/otp", // OTP request + verify API routes
1516
+ ];
1517
+ ```
1518
+
1519
+ ### Step 5.3.6: Install optional peer dep (if using OTPVerifyForm)
1520
+
1521
+ If you render `<OTPVerifyForm/>` directly (rather than using the zero-config page), install:
1522
+
1523
+ ```bash
1524
+ npm install input-otp
1525
+ ```
1526
+
1527
+ **OTP Setup Checklist:**
1528
+ - [ ] `hazo_email_otps` table created (`npm run migrate migrations/015_email_otp.sql`)
1529
+ - [ ] `[hazo_auth__otp]` section added to `hazo_auth_config.ini`
1530
+ - [ ] OTP API routes created (`/api/hazo_auth/otp/request` and `/api/hazo_auth/otp/verify`)
1531
+ - [ ] OTP page created (`/hazo_auth/otp`)
1532
+ - [ ] `/hazo_auth/otp` and `/api/hazo_auth/otp` added to middleware `public_routes`
1533
+ - [ ] `input-otp` installed (only if using `<OTPVerifyForm/>` directly)
1534
+ - [ ] Email delivery configured (`hazo_notify` + valid `ZEPTOMAIL_API_KEY`)
1535
+
1536
+ ---
1537
+
1446
1538
  ## Phase 6: Verification Tests
1447
1539
 
1448
1540
  Run these tests to verify your setup is working correctly.
@@ -1924,10 +2016,10 @@ import { hazo_get_tenant_auth } from "hazo_auth/server-lib";
1924
2016
  export async function GET(request: NextRequest) {
1925
2017
  const auth = await hazo_get_tenant_auth(request);
1926
2018
 
1927
- if (auth.authenticated && auth.organization) {
1928
- // auth.organization contains tenant details
2019
+ if (auth.authenticated && auth.selected_scope) {
2020
+ // auth.selected_scope contains the currently selected tenant/scope
1929
2021
  // auth.user_scopes contains all scopes user can access (for UI switcher)
1930
- const data = await getTenantData(auth.organization.id);
2022
+ const data = await getTenantData(auth.selected_scope.id);
1931
2023
  }
1932
2024
  }
1933
2025
  ```
@@ -1939,8 +2031,8 @@ import { require_tenant_auth, HazoAuthError } from "hazo_auth/server-lib";
1939
2031
  export async function GET(request: NextRequest) {
1940
2032
  try {
1941
2033
  const auth = await require_tenant_auth(request);
1942
- // auth.organization is guaranteed non-null
1943
- return NextResponse.json(await getData(auth.organization.id));
2034
+ // auth.selected_scope is guaranteed non-null
2035
+ return NextResponse.json(await getData(auth.selected_scope.id));
1944
2036
  } catch (error) {
1945
2037
  if (error instanceof HazoAuthError) {
1946
2038
  return NextResponse.json(
@@ -1960,7 +2052,7 @@ import { withAuth } from "hazo_auth/server-lib";
1960
2052
  // Auth + params + error handling all automatic
1961
2053
  export const GET = withAuth<{ id: string }>(
1962
2054
  async (request, auth, { id }) => {
1963
- const data = await getData(auth.organization.id, id);
2055
+ const data = await getData(auth.selected_scope.id, id);
1964
2056
  return NextResponse.json(data);
1965
2057
  },
1966
2058
  { require_tenant: true }
@@ -2009,7 +2101,7 @@ const auth = await hazo_get_tenant_auth(request);
2009
2101
 
2010
2102
  // Return available scopes to frontend
2011
2103
  return NextResponse.json({
2012
- current_scope: auth.organization,
2104
+ current_scope: auth.selected_scope,
2013
2105
  available_scopes: auth.user_scopes.map(s => ({
2014
2106
  id: s.id,
2015
2107
  name: s.name,
@@ -127,11 +127,20 @@ ${exports}
127
127
  }
128
128
 
129
129
  function generate_page_content(page: PageDefinition): string {
130
+ // Next 16 requires page default exports to satisfy `PageProps` (i.e. accept
131
+ // `{ params?, searchParams? }` and nothing else). Re-exporting the named
132
+ // component directly fails that check because the component carries custom
133
+ // props (image_src, layout, …). The wrapper below is a parameter-less
134
+ // function that returns the component — Next sees a `() => JSX` signature
135
+ // which trivially satisfies PageProps. Direct JSX consumers still use the
136
+ // named export from `hazo_auth/pages` and get the full custom-prop API.
130
137
  return `// Generated by hazo_auth - do not edit manually
131
138
  // Page: /${page.path}
132
139
  import { ${page.component_name} } from "hazo_auth/pages";
133
140
 
134
- export default ${page.component_name};
141
+ export default function Page() {
142
+ return <${page.component_name} />;
143
+ }
135
144
  `;
136
145
  }
137
146
 
@@ -64,6 +64,9 @@ const REQUIRED_API_ROUTES = [
64
64
  { path: "api/hazo_auth/user_management/users/roles", method: "GET" },
65
65
  { path: "api/hazo_auth/user_management/users/roles", method: "POST" },
66
66
  { path: "api/hazo_auth/user_management/users/roles", method: "PUT" },
67
+ // OTP routes
68
+ { path: "api/hazo_auth/otp/request", method: "POST" },
69
+ { path: "api/hazo_auth/otp/verify", method: "POST" },
67
70
  ];
68
71
 
69
72
  // section: helpers
@@ -534,6 +537,7 @@ const REQUIRED_TABLES = [
534
537
  "hazo_role_permissions",
535
538
  "hazo_invitations",
536
539
  "hazo_refresh_tokens",
540
+ "hazo_email_otps",
537
541
  ];
538
542
 
539
543
  const TEXT_ID_TABLES = [
@@ -1,4 +1,12 @@
1
1
  // file_description: Type definitions and error classes for hazo_get_auth utility
2
+ //
3
+ // Naming note (v6.0.0): the field previously called `organization` (and
4
+ // `organization_id`) on `TenantAuthResult` was renamed to `selected_scope`
5
+ // (and `selected_scope_id`), and the type `TenantOrganization` was renamed
6
+ // to `SelectedScope`. The multi-tenancy model is scopes throughout; the
7
+ // old name was a legacy synonym for "the currently selected scope" derived
8
+ // from the scope-selection cookie/header. No deprecation shim is provided.
9
+ //
2
10
  // section: types
3
11
 
4
12
  /**
@@ -123,10 +131,11 @@ export type ScopeDetails = {
123
131
  };
124
132
 
125
133
  /**
126
- * Tenant/organization information returned in tenant auth results
127
- * Simplified view of scope for API responses
134
+ * Currently selected scope information returned in tenant auth results.
135
+ * Simplified view of the scope chosen via the scope-selection cookie or
136
+ * `X-Hazo-Scope-Id` header.
128
137
  */
129
- export type TenantOrganization = {
138
+ export type SelectedScope = {
130
139
  id: string;
131
140
  name: string;
132
141
  slug: string | null;
@@ -167,9 +176,9 @@ export type TenantAuthResult =
167
176
  permissions: string[];
168
177
  permission_ok: boolean;
169
178
  missing_permissions?: string[];
170
- organization: TenantOrganization | null;
171
- /** Shorthand for organization?.id - commonly used for DB query filters */
172
- organization_id: string | null;
179
+ selected_scope: SelectedScope | null;
180
+ /** Shorthand for selected_scope?.id - commonly used for DB query filters. */
181
+ selected_scope_id: string | null;
173
182
  user_scopes: ScopeDetails[];
174
183
  scope_ok?: boolean;
175
184
  scope_access_via?: ScopeAccessInfo;
@@ -179,20 +188,20 @@ export type TenantAuthResult =
179
188
  user: null;
180
189
  permissions: [];
181
190
  permission_ok: false;
182
- organization: null;
183
- /** Shorthand for organization?.id - commonly used for DB query filters */
184
- organization_id: null;
191
+ selected_scope: null;
192
+ /** Shorthand for selected_scope?.id - commonly used for DB query filters. */
193
+ selected_scope_id: null;
185
194
  user_scopes: [];
186
195
  scope_ok?: false;
187
196
  };
188
197
 
189
198
  /**
190
- * Guaranteed authenticated result with non-null organization
191
- * Returned by require_tenant_auth when validation passes
199
+ * Guaranteed authenticated result with non-null selected_scope.
200
+ * Returned by require_tenant_auth when validation passes.
192
201
  */
193
202
  export type RequiredTenantAuthResult = TenantAuthResult & {
194
203
  authenticated: true;
195
- organization: TenantOrganization;
204
+ selected_scope: SelectedScope;
196
205
  };
197
206
 
198
207
  // section: tenant_error_classes
@@ -14,7 +14,7 @@ import type {
14
14
  TenantAuthOptions,
15
15
  TenantAuthResult,
16
16
  RequiredTenantAuthResult,
17
- TenantOrganization,
17
+ SelectedScope,
18
18
  ScopeDetails,
19
19
  } from "./auth_types";
20
20
  import {
@@ -62,15 +62,15 @@ export function extract_scope_id_from_request(
62
62
  }
63
63
 
64
64
  /**
65
- * Builds TenantOrganization from scope details and access info
65
+ * Builds SelectedScope from scope details and access info.
66
66
  * @param scope_details - Full scope details from cache
67
67
  * @param is_super_admin - Whether user is accessing as super admin
68
- * @returns TenantOrganization object
68
+ * @returns SelectedScope object
69
69
  */
70
- function build_tenant_organization(
70
+ function build_selected_scope(
71
71
  scope_details: ScopeDetails,
72
72
  is_super_admin: boolean,
73
- ): TenantOrganization {
73
+ ): SelectedScope {
74
74
  return {
75
75
  id: scope_details.id,
76
76
  name: scope_details.name,
@@ -96,20 +96,21 @@ function build_tenant_organization(
96
96
  * Tenant-aware authentication function
97
97
  *
98
98
  * Extracts tenant/scope context from request headers or cookies,
99
- * validates access, and returns enriched result with organization info.
99
+ * validates access, and returns enriched result including the currently
100
+ * selected scope.
100
101
  *
101
102
  * Header priority: X-Hazo-Scope-Id > Cookie
102
103
  *
103
104
  * @param request - NextRequest object
104
105
  * @param options - TenantAuthOptions for customization
105
- * @returns TenantAuthResult with user, permissions, organization, and user_scopes
106
+ * @returns TenantAuthResult with user, permissions, selected_scope, and user_scopes
106
107
  *
107
108
  * @example
108
109
  * ```typescript
109
110
  * const auth = await hazo_get_tenant_auth(request);
110
- * if (auth.authenticated && auth.organization) {
111
+ * if (auth.authenticated && auth.selected_scope) {
111
112
  * // Access tenant-specific data
112
- * const data = await getData(auth.organization.id);
113
+ * const data = await getData(auth.selected_scope.id);
113
114
  * }
114
115
  * ```
115
116
  */
@@ -136,8 +137,8 @@ export async function hazo_get_tenant_auth(
136
137
  user: null,
137
138
  permissions: [],
138
139
  permission_ok: false,
139
- organization: null,
140
- organization_id: null,
140
+ selected_scope: null,
141
+ selected_scope_id: null,
141
142
  user_scopes: [],
142
143
  scope_ok: false,
143
144
  };
@@ -155,8 +156,8 @@ export async function hazo_get_tenant_auth(
155
156
  // User scopes from cache or empty array
156
157
  const user_scopes: ScopeDetails[] = cached?.scopes || [];
157
158
 
158
- // Build organization info if scope access was successful
159
- let organization: TenantOrganization | null = null;
159
+ // Build selected_scope info if scope access was successful
160
+ let selected_scope: SelectedScope | null = null;
160
161
 
161
162
  if (scope_id && auth_result.scope_ok && auth_result.scope_access_via) {
162
163
  // Find the scope in user's scopes that matches the access_via scope
@@ -165,7 +166,7 @@ export async function hazo_get_tenant_auth(
165
166
  );
166
167
 
167
168
  if (access_scope) {
168
- organization = build_tenant_organization(
169
+ selected_scope = build_selected_scope(
169
170
  access_scope,
170
171
  auth_result.scope_access_via.is_super_admin || false,
171
172
  );
@@ -174,7 +175,7 @@ export async function hazo_get_tenant_auth(
174
175
  const hazoConnect = get_hazo_connect_instance();
175
176
  const scope_result = await get_scope_by_id(hazoConnect, scope_id);
176
177
  if (scope_result.success && scope_result.scope) {
177
- organization = {
178
+ selected_scope = {
178
179
  id: scope_result.scope.id,
179
180
  name: scope_result.scope.name,
180
181
  slug: null, // Could fetch from scope if slug column exists
@@ -200,8 +201,8 @@ export async function hazo_get_tenant_auth(
200
201
  permissions: auth_result.permissions,
201
202
  permission_ok: auth_result.permission_ok,
202
203
  missing_permissions: auth_result.missing_permissions,
203
- organization,
204
- organization_id: organization?.id || null,
204
+ selected_scope,
205
+ selected_scope_id: selected_scope?.id || null,
205
206
  user_scopes,
206
207
  scope_ok: auth_result.scope_ok,
207
208
  scope_access_via: auth_result.scope_access_via,
@@ -218,15 +219,15 @@ export async function hazo_get_tenant_auth(
218
219
  *
219
220
  * @param request - NextRequest object
220
221
  * @param options - TenantAuthOptions for customization
221
- * @returns RequiredTenantAuthResult with guaranteed non-null organization
222
+ * @returns RequiredTenantAuthResult with guaranteed non-null selected_scope
222
223
  * @throws AuthenticationRequiredError, TenantRequiredError, TenantAccessDeniedError
223
224
  *
224
225
  * @example
225
226
  * ```typescript
226
227
  * try {
227
228
  * const auth = await require_tenant_auth(request);
228
- * // auth.organization is guaranteed non-null here
229
- * const data = await getData(auth.organization.id);
229
+ * // auth.selected_scope is guaranteed non-null here
230
+ * const data = await getData(auth.selected_scope.id);
230
231
  * } catch (error) {
231
232
  * if (error instanceof HazoAuthError) {
232
233
  * return NextResponse.json(
@@ -257,14 +258,14 @@ export async function require_tenant_auth(
257
258
  throw new TenantAccessDeniedError(scope_id, result.user_scopes);
258
259
  }
259
260
 
260
- // Check if organization context is required but missing
261
- if (!result.organization) {
261
+ // Check if scope context is required but missing
262
+ if (!result.selected_scope) {
262
263
  throw new TenantRequiredError(
263
- "No organization context provided. Include X-Hazo-Scope-Id header or scope cookie.",
264
+ "No tenant scope context provided. Include X-Hazo-Scope-Id header or scope cookie.",
264
265
  result.user_scopes,
265
266
  );
266
267
  }
267
268
 
268
- // Type assertion: at this point we know organization is non-null
269
+ // Type assertion: at this point we know selected_scope is non-null
269
270
  return result as RequiredTenantAuthResult;
270
271
  }
@@ -22,7 +22,7 @@ export {
22
22
  } from "./hazo_get_tenant_auth.server.js";
23
23
  export type {
24
24
  ScopeDetails,
25
- TenantOrganization,
25
+ SelectedScope,
26
26
  TenantAuthOptions,
27
27
  TenantAuthResult,
28
28
  RequiredTenantAuthResult,
@@ -50,7 +50,7 @@ export {
50
50
  } from "./with_auth.server.js";
51
51
  export type {
52
52
  AuthenticatedTenantAuth,
53
- AuthenticatedTenantAuthWithOrg,
53
+ AuthenticatedTenantAuthWithSelectedScope,
54
54
  WithAuthOptions,
55
55
  } from "./with_auth.server";
56
56