@ingram-tech/nk-auth 0.13.5 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/keys.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Environment contract for @ingram-tech/nk-auth.
3
+ *
4
+ * Following the "each package owns its own env validation" pattern. Env vars are
5
+ * external input, so we parse them with Zod (per docs/code-style.md) rather than
6
+ * reading `process.env` ad hoc. `authEnv()` returns a validated, config-shaped
7
+ * object you can spread straight into your `betterAuth()` call.
8
+ *
9
+ * Required:
10
+ * BETTER_AUTH_SECRET — session/CSRF signing key (`openssl rand -hex 32`)
11
+ * BETTER_AUTH_URL — canonical site origin, e.g. "https://example.com"
12
+ * DATABASE_URL — direct Postgres connection for Better Auth (:5432)
13
+ *
14
+ * Outside production, BETTER_AUTH_SECRET falls back to a well-known insecure
15
+ * placeholder so local dev and tests run without hand-setting it (`nk dev` and
16
+ * plain `next dev` alike). In production it stays strictly required — a missing
17
+ * secret throws at startup rather than silently signing sessions with a value
18
+ * anyone could guess.
19
+ *
20
+ * Sites that derive their own `baseURL` / database connection can take just the
21
+ * secret's prod/dev rule via `authSecret()` instead of the whole `authEnv()`
22
+ * bundle — so that security-sensitive default is never re-implemented app-side.
23
+ */
24
+
25
+ import { z } from "zod";
26
+
27
+ /**
28
+ * Well-known, deliberately insecure secret used ONLY when NODE_ENV is not
29
+ * "production". Never applied to a production build — see `secretField`.
30
+ */
31
+ const DEV_SECRET_PLACEHOLDER = "nk-dev-insecure-placeholder-not-for-production";
32
+
33
+ /**
34
+ * In production the secret is required; everywhere else it defaults to the
35
+ * insecure placeholder. Read NODE_ENV at parse time (not module load) so the
36
+ * same process can be exercised under either mode.
37
+ */
38
+ const secretField = () =>
39
+ process.env.NODE_ENV === "production"
40
+ ? z.string().min(1)
41
+ : z.string().min(1).default(DEV_SECRET_PLACEHOLDER);
42
+
43
+ const buildSchema = () =>
44
+ z.object({
45
+ BETTER_AUTH_SECRET: secretField(),
46
+ BETTER_AUTH_URL: z.string().url(),
47
+ DATABASE_URL: z.string().min(1),
48
+ });
49
+
50
+ let warnedPlaceholder = false;
51
+
52
+ /**
53
+ * Warn once when the insecure dev placeholder is in use, so a missing secret is
54
+ * visible in local logs without nagging on every call. Never fires in
55
+ * production, where `secretField()` makes the secret required.
56
+ */
57
+ const warnIfPlaceholder = (secret: string): void => {
58
+ if (secret === DEV_SECRET_PLACEHOLDER && !warnedPlaceholder) {
59
+ warnedPlaceholder = true;
60
+ console.warn(
61
+ "@ingram-tech/nk-auth: BETTER_AUTH_SECRET is unset — using an insecure dev placeholder. Set it before deploying.",
62
+ );
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Resolve just the session-signing secret, applying the same rule `authEnv()`
68
+ * does: strictly required in production, an insecure dev placeholder otherwise.
69
+ *
70
+ * For sites that derive their own `baseURL` and open their own database
71
+ * connection (so they don't want `authEnv()`'s all-or-nothing bundle) but still
72
+ * want nk-auth to own the secret's prod/dev fallback. Reaching for this keeps
73
+ * the security-sensitive default in one place instead of hand-copied into the
74
+ * app. Backed by the same `secretField()` schema as `authEnv()`.
75
+ */
76
+ export const authSecret = (): string => {
77
+ const result = secretField().safeParse(process.env.BETTER_AUTH_SECRET);
78
+ if (!result.success) {
79
+ // secretField() only rejects a missing/empty secret in production (dev
80
+ // supplies the placeholder default), so that is the sole failure here.
81
+ throw new Error(
82
+ "@ingram-tech/nk-auth: BETTER_AUTH_SECRET is required (an unset secret is only allowed outside production)",
83
+ );
84
+ }
85
+ warnIfPlaceholder(result.data);
86
+ return result.data;
87
+ };
88
+
89
+ export interface AuthEnv {
90
+ secret: string;
91
+ baseURL: string;
92
+ databaseUrl: string;
93
+ }
94
+
95
+ /**
96
+ * Read and validate all nk-auth env vars at once. Throws a single error listing
97
+ * everything missing/invalid, so a misconfigured site fails fast at startup
98
+ * rather than at first sign-in.
99
+ */
100
+ export const authEnv = (): AuthEnv => {
101
+ const result = buildSchema().safeParse(process.env);
102
+ if (!result.success) {
103
+ const issues = result.error.issues
104
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
105
+ .join(", ");
106
+ throw new Error(`@ingram-tech/nk-auth: invalid environment — ${issues}`);
107
+ }
108
+ const env = result.data;
109
+ warnIfPlaceholder(env.BETTER_AUTH_SECRET);
110
+ return {
111
+ secret: env.BETTER_AUTH_SECRET,
112
+ baseURL: env.BETTER_AUTH_URL,
113
+ databaseUrl: env.DATABASE_URL,
114
+ };
115
+ };
116
+
117
+ /** Whether nk-auth is fully configured (lets callers degrade in local/dev). */
118
+ export const isConfigured = (): boolean => buildSchema().safeParse(process.env).success;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A loop-safe Next.js auth middleware factory, at
3
+ * "@ingram-tech/nk-auth/middleware". Middleware runs at the edge before a route
4
+ * renders, so it can only afford an **optimistic** check — the presence of a
5
+ * session cookie (`getSessionCookie`), not a database-validated session.
6
+ *
7
+ * That optimism is exactly what makes naive auth middleware loop. The validated
8
+ * server guard (see "@ingram-tech/nk-auth/server") sends an invalid-but-present
9
+ * session to the sign-in page; if the optimistic layer then bounces the sign-in
10
+ * page back ("you have a cookie, go to the app"), a stale cookie ping-pongs
11
+ * forever — and an RSC render can't clear the cookie to break out.
12
+ *
13
+ * So this factory enforces the one safe invariant *at construction*:
14
+ *
15
+ * The sign-in path is never the target of an optimistic redirect.
16
+ *
17
+ * It also owns the two things only middleware can do here:
18
+ *
19
+ * - **Preserve the destination.** When it sends an unauthenticated user to
20
+ * sign-in, it appends `?next=<requested path>`; and it injects an
21
+ * `x-nk-auth-path` request header so the server guards can do the same for
22
+ * the cookie-present-but-invalid case (an RSC can't otherwise learn its URL).
23
+ * - **Clear a stale cookie.** The guard parks an invalid session at
24
+ * `${signInPath}?stale=1`; middleware (the only pre-render place that can set
25
+ * cookies) deletes the dead Better Auth cookies and bounces to a clean
26
+ * sign-in URL, so a bad session self-heals instead of failing every request.
27
+ */
28
+ import { getSessionCookie } from "better-auth/cookies";
29
+ import { type NextRequest, NextResponse } from "next/server";
30
+ import { NK_AUTH_PATH_HEADER, safeNextParam } from "./gating-internals.js";
31
+
32
+ export interface AuthMiddlewareConfig {
33
+ /**
34
+ * Path prefixes that require a session. A request without a session cookie
35
+ * to any of these is redirected to `signInPath`. Matched with `startsWith`.
36
+ */
37
+ protectedPaths: string[];
38
+ /** Where unauthenticated users are sent. Default `/login`. */
39
+ signInPath?: string;
40
+ /**
41
+ * Optional front-door redirect: when a session cookie is present and the
42
+ * path *exactly* equals one of these, redirect to `signedInRedirect`. Safe
43
+ * because it never targets the sign-in path. Typically `["/"]`.
44
+ */
45
+ frontDoorPaths?: string[];
46
+ /** Destination for the front-door redirect. Required when `frontDoorPaths` is set. */
47
+ signedInRedirect?: string;
48
+ /**
49
+ * Cookie-name fragment for the cookies cleared on a stale session. Default
50
+ * `better-auth`, which matches `better-auth.session_token` and the
51
+ * `__Secure-…` production variant.
52
+ */
53
+ sessionCookiePrefix?: string;
54
+ }
55
+
56
+ export function createAuthMiddleware(config: AuthMiddlewareConfig) {
57
+ const signInPath = config.signInPath ?? "/login";
58
+ const frontDoorPaths = config.frontDoorPaths ?? [];
59
+ const cookiePrefix = config.sessionCookiePrefix ?? "better-auth";
60
+
61
+ // Segment-boundary match: "/app" gates "/app" and "/app/x" but not
62
+ // "/application". Used for both the construction-time loop check and the
63
+ // per-request gate, so the two can't drift (a broader construction check
64
+ // would reject safe configs like protectedPaths ["/log"] + signInPath
65
+ // "/login", where "/login" is never actually gated).
66
+ const isProtected = (path: string): boolean =>
67
+ config.protectedPaths.some((p) => path === p || path.startsWith(`${p}/`));
68
+
69
+ // Loop-safety, enforced once at construction rather than hoped-for per
70
+ // request. The sign-in path is where the validated guard parks an
71
+ // invalid-but-present session; it must never be the target of an optimistic
72
+ // redirect, or a stale cookie loops.
73
+ if (isProtected(signInPath)) {
74
+ throw new Error(
75
+ `@ingram-tech/nk-auth: signInPath "${signInPath}" must not fall under protectedPaths — a cookie-less visit would redirect to itself forever.`,
76
+ );
77
+ }
78
+ if (frontDoorPaths.includes(signInPath)) {
79
+ throw new Error(
80
+ `@ingram-tech/nk-auth: signInPath "${signInPath}" must not be a frontDoorPath — it would reintroduce the stale-cookie redirect loop.`,
81
+ );
82
+ }
83
+ if (frontDoorPaths.length > 0 && !config.signedInRedirect) {
84
+ throw new Error(
85
+ "@ingram-tech/nk-auth: signedInRedirect is required when frontDoorPaths is set.",
86
+ );
87
+ }
88
+
89
+ return function middleware(request: NextRequest): NextResponse {
90
+ const path = request.nextUrl.pathname;
91
+
92
+ // 1. Stale-session handshake. The validated guard sends an invalid session
93
+ // to `${signInPath}?stale=1&next=…`. Clear the dead Better Auth cookies
94
+ // (only middleware can, pre-render) and bounce to a clean sign-in URL
95
+ // that keeps `next`. After this the cookie is gone, so it can't re-arm.
96
+ if (path === signInPath && request.nextUrl.searchParams.get("stale") === "1") {
97
+ const to = request.nextUrl.clone();
98
+ to.searchParams.delete("stale");
99
+ const res = NextResponse.redirect(to);
100
+ for (const cookie of request.cookies.getAll()) {
101
+ if (cookie.name.includes(cookiePrefix)) {
102
+ // `__Secure-`/`__Host-` cookies: browsers reject the deletion
103
+ // Set-Cookie unless it carries the Secure attribute itself, so a
104
+ // bare delete() would silently leave the dead cookie in place on
105
+ // HTTPS and re-run this handshake on every visit.
106
+ res.cookies.delete({
107
+ name: cookie.name,
108
+ path: "/",
109
+ secure:
110
+ cookie.name.startsWith("__Secure-") ||
111
+ cookie.name.startsWith("__Host-"),
112
+ });
113
+ }
114
+ }
115
+ return res;
116
+ }
117
+
118
+ const hasSessionCookie = !!getSessionCookie(request);
119
+
120
+ // 2. Unauthenticated (no cookie) on a protected path -> sign in, and
121
+ // remember where they were going.
122
+ if (!hasSessionCookie && isProtected(path)) {
123
+ const original = request.nextUrl.pathname + request.nextUrl.search;
124
+ const to = request.nextUrl.clone();
125
+ to.pathname = signInPath;
126
+ to.search = "";
127
+ const next = safeNextParam(original);
128
+ if (next) to.searchParams.set("next", next);
129
+ return NextResponse.redirect(to);
130
+ }
131
+
132
+ // 3. Front door: a cookie-bearing visit to "/" (or configured) -> the app.
133
+ if (
134
+ hasSessionCookie &&
135
+ config.signedInRedirect &&
136
+ frontDoorPaths.includes(path)
137
+ ) {
138
+ const to = request.nextUrl.clone();
139
+ to.pathname = config.signedInRedirect;
140
+ to.search = "";
141
+ return NextResponse.redirect(to);
142
+ }
143
+
144
+ // 4. Pass through, injecting the requested path so server guards can build
145
+ // `next` for the cookie-present-but-invalid case.
146
+ const requestHeaders = new Headers(request.headers);
147
+ requestHeaders.set(NK_AUTH_PATH_HEADER, path + request.nextUrl.search);
148
+ return NextResponse.next({ request: { headers: requestHeaders } });
149
+ };
150
+ }
package/src/options.ts ADDED
@@ -0,0 +1,236 @@
1
+ import type { PasskeyOptions } from "@better-auth/passkey";
2
+ import type { BetterAuthOptions } from "better-auth";
3
+ import bcrypt from "bcrypt";
4
+
5
+ // `uuidGenerateId` (the `advanced.database.generateId` UUIDv7 generator) and the
6
+ // base58 skin now live in the dependency-light `./id` module; re-exported here so
7
+ // existing `from "@ingram-tech/nk-auth"` imports keep resolving.
8
+ export { uuidGenerateId } from "./id.js";
9
+
10
+ /**
11
+ * Portable Better Auth building blocks for Ingram sites.
12
+ *
13
+ * Deliberately NOT a `betterAuth()` wrapper: a site assembles its own instance
14
+ * and spreads these presets in. That keeps full plugin type inference at the
15
+ * call site (where `declaration` is off) and respects the prime directive — the
16
+ * site stays plain Better Auth, we just ship the shared config. JWT + org
17
+ * presets live in `./jwt` and `./organization`. See the package README.
18
+ */
19
+
20
+ /**
21
+ * `emailAndPassword.password` config that hashes/verifies with bcrypt.
22
+ *
23
+ * @deprecated LEGACY SUPPORT ONLY. Do not wire this into a new site. It exists
24
+ * solely so sites with pre-existing **bcrypt** password hashes keep verifying.
25
+ * Better Auth's default is scrypt — new sites should omit this and use that
26
+ * default. Sites still on bcrypt should migrate hashes to scrypt and drop this;
27
+ * see the nk-auth README (§"Migrating bcrypt passwords to scrypt") for the path.
28
+ *
29
+ * NOTE: bcrypt silently truncates the password at **72 bytes**, so two passwords
30
+ * sharing their first 72 bytes verify as equal — even though the password policy
31
+ * allows up to {@link DEFAULT_MAX_PASSWORD_LENGTH} (128) characters. This is a
32
+ * property of bcrypt, not a bug here, and it's deliberately not length-guarded:
33
+ * a guard would break verification of the legacy long-password hashes this
34
+ * preset exists to support (bcrypt already truncated them at hash time). It is
35
+ * one more reason to migrate off bcrypt — scrypt has no such ceiling.
36
+ */
37
+ export const bcryptPassword = {
38
+ hash: (password: string): Promise<string> => bcrypt.hash(password, 10),
39
+ verify: ({
40
+ hash,
41
+ password,
42
+ }: {
43
+ hash: string;
44
+ password: string;
45
+ }): Promise<boolean> => bcrypt.compare(password, hash),
46
+ };
47
+
48
+ export interface PasskeyConfig {
49
+ /** Relying-party id: the registrable domain, e.g. "example.com". */
50
+ rpId: string;
51
+ /** Relying-party display name. */
52
+ rpName: string;
53
+ /** Expected origin(s), e.g. "https://example.com". */
54
+ origin: string | string[];
55
+ }
56
+
57
+ /** Build `passkey` plugin options. Use as `passkey(makePasskeyOptions(cfg))`. */
58
+ export const makePasskeyOptions = (cfg: PasskeyConfig): PasskeyOptions => ({
59
+ rpID: cfg.rpId,
60
+ rpName: cfg.rpName,
61
+ origin: cfg.origin,
62
+ });
63
+
64
+ /**
65
+ * Derive passkey options from the site's single base URL: `rpID` = the URL's
66
+ * hostname (the WebAuthn effective domain — host only, no scheme or port) and
67
+ * `origin` = the URL itself. This is the common case (one sign-in origin), and
68
+ * it keeps the relying-party id and the registered origin in lockstep so they
69
+ * can't drift. Pass the same value the instance signs sessions for, e.g.:
70
+ *
71
+ * passkey(passkeyOptionsForBaseUrl(env.baseURL, "Example"))
72
+ *
73
+ * Reach for `makePasskeyOptions` directly when a site spans multiple origins or
74
+ * must register against a parent registrable domain (e.g. rpID "example.com"
75
+ * for an "app.example.com" origin).
76
+ */
77
+ export const passkeyOptionsForBaseUrl = (
78
+ baseURL: string,
79
+ rpName: string,
80
+ ): PasskeyOptions =>
81
+ makePasskeyOptions({
82
+ rpId: new URL(baseURL).hostname,
83
+ rpName,
84
+ origin: baseURL,
85
+ });
86
+
87
+ /**
88
+ * Which auth mail is being sent. Switch on this to pick a template — never on
89
+ * `subject`, which is English default copy a site is expected to replace.
90
+ */
91
+ export type AuthEmailKind = "verify-email" | "reset-password" | "change-email";
92
+
93
+ /** The subset of the Better Auth user these callbacks pass through. */
94
+ export interface AuthEmailUser {
95
+ id: string;
96
+ email: string;
97
+ name?: string;
98
+ }
99
+
100
+ /** One outgoing auth mail, handed to the site's sender. */
101
+ export interface AuthEmailMessage {
102
+ /** Discriminator — pick the template off this. */
103
+ kind: AuthEmailKind;
104
+ /**
105
+ * Recipient. For `change-email` this is deliberately the user's CURRENT
106
+ * address: confirming the move from the address that already owns the
107
+ * account is what stops a hijacked session from walking off with it.
108
+ */
109
+ to: string;
110
+ /**
111
+ * Default English subject, for sites that don't localize. Prefer your own
112
+ * translated copy — see the README.
113
+ */
114
+ subject: string;
115
+ /** The one-time action link. */
116
+ url: string;
117
+ /** The raw token behind `url`, if you need to build your own link. */
118
+ token: string;
119
+ /** `id` lets you look up a locale/preferences; `name` personalizes copy. */
120
+ user: AuthEmailUser;
121
+ /** `change-email` only: the address the user is moving to. */
122
+ newEmail?: string;
123
+ /** The originating request — `Accept-Language` is a locale source. */
124
+ request?: Request;
125
+ }
126
+
127
+ /** Send one transactional email (wire to `@ingram-tech/nk-email`'s `sendEmail`). */
128
+ export type SendEmail = (message: AuthEmailMessage) => Promise<unknown>;
129
+
130
+ /**
131
+ * Email callbacks for `emailAndPassword.sendResetPassword`,
132
+ * `emailVerification.sendVerificationEmail` and
133
+ * `user.changeEmail.sendChangeEmailConfirmation`, routed through your sender.
134
+ *
135
+ * Spread all three in. Hand-writing them is a trap: `betterAuth()` infers its
136
+ * options generically, so TypeScript does NOT excess-property-check that object
137
+ * literal — a callback under a misremembered name (`sendChangeEmailVerification`
138
+ * is the one people reach for) compiles clean and simply never fires. The
139
+ * `PinnedEmailSenders` assertion below ties these three names to the real Better
140
+ * Auth options, so a rename upstream breaks this build instead of your site.
141
+ */
142
+ export const makeEmailSenders = (send: SendEmail) => ({
143
+ sendResetPassword: async (
144
+ { user, url, token }: { user: AuthEmailUser; url: string; token: string },
145
+ request?: Request,
146
+ ): Promise<void> => {
147
+ await send({
148
+ kind: "reset-password",
149
+ to: user.email,
150
+ subject: "Reset your password",
151
+ url,
152
+ token,
153
+ user,
154
+ request,
155
+ });
156
+ },
157
+ sendVerificationEmail: async (
158
+ { user, url, token }: { user: AuthEmailUser; url: string; token: string },
159
+ request?: Request,
160
+ ): Promise<void> => {
161
+ await send({
162
+ kind: "verify-email",
163
+ to: user.email,
164
+ subject: "Verify your email",
165
+ url,
166
+ token,
167
+ user,
168
+ request,
169
+ });
170
+ },
171
+ sendChangeEmailConfirmation: async (
172
+ {
173
+ user,
174
+ newEmail,
175
+ url,
176
+ token,
177
+ }: {
178
+ user: AuthEmailUser;
179
+ newEmail: string;
180
+ url: string;
181
+ token: string;
182
+ },
183
+ request?: Request,
184
+ ): Promise<void> => {
185
+ await send({
186
+ kind: "change-email",
187
+ to: user.email, // the CURRENT address — see AuthEmailMessage.to
188
+ subject: "Confirm your email change",
189
+ url,
190
+ token,
191
+ user,
192
+ newEmail,
193
+ request,
194
+ });
195
+ },
196
+ });
197
+
198
+ /**
199
+ * Compile-time pin: each sender above must stay assignable to the Better Auth
200
+ * option slot it is meant to fill, *addressed by that option's real name*.
201
+ *
202
+ * This exists because `betterAuth()` receives its options through a generic, so
203
+ * TypeScript does NOT excess-property-check the object literal a site passes it.
204
+ * A callback under a wrong-but-plausible name — `sendChangeEmailVerification`
205
+ * for `sendChangeEmailConfirmation`, say — therefore compiles cleanly at every
206
+ * call site and simply never fires, which is a silent no-op in production and
207
+ * exactly the bug this pin is here to prevent recurring.
208
+ *
209
+ * Indexing by key is what makes it bite: rename any of the three upstream and
210
+ * `NonNullable<…["theName"]>` stops resolving, breaking this build. It lives in
211
+ * source rather than in a `.test.ts` on purpose — `tsconfig.json` excludes test
212
+ * files, so a type-level assertion in one is never checked by anything.
213
+ */
214
+ type PinnedEmailSenders = {
215
+ reset: NonNullable<
216
+ NonNullable<BetterAuthOptions["emailAndPassword"]>["sendResetPassword"]
217
+ >;
218
+ verify: NonNullable<
219
+ NonNullable<BetterAuthOptions["emailVerification"]>["sendVerificationEmail"]
220
+ >;
221
+ change: NonNullable<
222
+ NonNullable<
223
+ NonNullable<BetterAuthOptions["user"]>["changeEmail"]
224
+ >["sendChangeEmailConfirmation"]
225
+ >;
226
+ };
227
+
228
+ const _pinEmailSenders = (send: SendEmail): PinnedEmailSenders => {
229
+ const senders = makeEmailSenders(send);
230
+ return {
231
+ reset: senders.sendResetPassword,
232
+ verify: senders.sendVerificationEmail,
233
+ change: senders.sendChangeEmailConfirmation,
234
+ };
235
+ };
236
+ void _pinEmailSenders;
@@ -0,0 +1,80 @@
1
+ import type { Pool } from "pg";
2
+
3
+ /**
4
+ * Organization-plugin building blocks shared across Ingram sites. The site
5
+ * still calls `organization({ ...nkOrganizationDefaults, ac, roles, ... })`
6
+ * itself — these only supply the generic defaults + the active-org plumbing.
7
+ */
8
+
9
+ /** Sensible Ingram defaults to spread into the `organization()` plugin. */
10
+ export const nkOrganizationDefaults = {
11
+ /** Invitations valid for one week. */
12
+ invitationExpiresIn: 60 * 60 * 24 * 7,
13
+ /** The user who creates an org is its owner. */
14
+ creatorRole: "owner",
15
+ } as const;
16
+
17
+ /**
18
+ * `user.additionalFields` entry that records the user's last active org so it
19
+ * can be restored on the next sign-in. Spread into `user.additionalFields`.
20
+ */
21
+ export const lastActiveOrganizationUserField = {
22
+ lastActiveOrganizationId: {
23
+ type: "string",
24
+ required: false,
25
+ input: false,
26
+ returned: true,
27
+ },
28
+ } as const;
29
+
30
+ interface SessionLike {
31
+ userId: string;
32
+ activeOrganizationId?: string | null;
33
+ }
34
+
35
+ /**
36
+ * `databaseHooks` that restore the user's last active organization on session
37
+ * create (only if they're still a member) and persist it on session update.
38
+ * Requires `lastActiveOrganizationUserField` in `user.additionalFields` and the
39
+ * Better Auth organization tables. Spread as `databaseHooks: lastActiveOrganizationHooks(pool)`.
40
+ */
41
+ export const lastActiveOrganizationHooks = (pool: Pool) => ({
42
+ session: {
43
+ create: {
44
+ before: async (session: SessionLike) => {
45
+ if (session.activeOrganizationId) {
46
+ return;
47
+ }
48
+ const userResult = await pool.query<{
49
+ lastActiveOrganizationId: string | null;
50
+ }>('SELECT "lastActiveOrganizationId" FROM "user" WHERE id = $1', [
51
+ session.userId,
52
+ ]);
53
+ const lastOrgId = userResult.rows[0]?.lastActiveOrganizationId;
54
+ if (!lastOrgId) {
55
+ return;
56
+ }
57
+ const memberResult = await pool.query(
58
+ 'SELECT 1 FROM member WHERE "userId" = $1 AND "organizationId" = $2 LIMIT 1',
59
+ [session.userId, lastOrgId],
60
+ );
61
+ if (memberResult.rows.length === 0) {
62
+ return;
63
+ }
64
+ return { data: { ...session, activeOrganizationId: lastOrgId } };
65
+ },
66
+ },
67
+ update: {
68
+ after: async (session: SessionLike) => {
69
+ const orgId = session.activeOrganizationId;
70
+ if (typeof orgId !== "string" || !orgId) {
71
+ return;
72
+ }
73
+ await pool.query(
74
+ 'UPDATE "user" SET "lastActiveOrganizationId" = $1 WHERE id = $2',
75
+ [orgId, session.userId],
76
+ );
77
+ },
78
+ },
79
+ },
80
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Password-policy primitives, shared by both ends of a site so the client form
3
+ * and the server validate against the *same* numbers. Pure (no `next`, `react`,
4
+ * or server imports) so it resolves identically from "@ingram-tech/nk-auth" and
5
+ * "@ingram-tech/nk-auth/client". Import from the "./password" subpath.
6
+ *
7
+ * The values mirror Better Auth's `emailAndPassword` defaults; if a site
8
+ * overrides `minPasswordLength` / `maxPasswordLength` in its `betterAuth()`
9
+ * config, pass the same numbers to `passwordSchema()` so the two stay in step.
10
+ */
11
+ import { z } from "zod";
12
+
13
+ /**
14
+ * The Better Auth `account.providerId` value for an email/password credential
15
+ * (as opposed to a social provider like "google"). This is Better Auth's own
16
+ * table shape, not ours — centralised here so no consuming site hard-codes the
17
+ * string. See {@link import("./server.js").createAuthHelpers}'s
18
+ * `hasCredentialAccount`.
19
+ */
20
+ export const CREDENTIAL_PROVIDER_ID = "credential";
21
+
22
+ /** Better Auth's default `emailAndPassword.minPasswordLength`. */
23
+ export const DEFAULT_MIN_PASSWORD_LENGTH = 8;
24
+
25
+ /** Better Auth's default `emailAndPassword.maxPasswordLength`. */
26
+ export const DEFAULT_MAX_PASSWORD_LENGTH = 128;
27
+
28
+ export interface PasswordPolicy {
29
+ /** Minimum length. Default {@link DEFAULT_MIN_PASSWORD_LENGTH}. */
30
+ minLength?: number;
31
+ /** Maximum length. Default {@link DEFAULT_MAX_PASSWORD_LENGTH}. */
32
+ maxLength?: number;
33
+ }
34
+
35
+ /**
36
+ * A Zod schema for a new password, honouring the resolved policy. Use it on both
37
+ * the client (form validation) and the server (before calling Better Auth) so
38
+ * neither side drifts from the other or from the instance's configured bounds.
39
+ */
40
+ export function passwordSchema(policy: PasswordPolicy = {}): z.ZodString {
41
+ const min = policy.minLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
42
+ const max = policy.maxLength ?? DEFAULT_MAX_PASSWORD_LENGTH;
43
+ return z
44
+ .string()
45
+ .min(min, `Password must be at least ${min} characters.`)
46
+ .max(max, `Password must be at most ${max} characters.`);
47
+ }
48
+
49
+ /**
50
+ * The failure reasons of a "choose a new password + confirm it" form, as a
51
+ * stable `code` (switch on it for i18n) plus an English `message` fallback.
52
+ * `reset_failed` is the server rejecting the reset token (invalid / expired).
53
+ */
54
+ export type ResetPasswordErrorCode =
55
+ | "password_too_short"
56
+ | "password_too_long"
57
+ | "passwords_do_not_match"
58
+ | "reset_failed";
59
+
60
+ export interface ResetPasswordError {
61
+ code: ResetPasswordErrorCode;
62
+ message: string;
63
+ /** Present on `password_too_short`. */
64
+ minLength?: number;
65
+ /** Present on `password_too_long`. */
66
+ maxLength?: number;
67
+ }
68
+
69
+ /**
70
+ * Validate a new-password pair (the field and its confirmation) against the
71
+ * policy. Returns the first {@link ResetPasswordError}, or null when valid. Pure
72
+ * so it is unit-testable and reusable outside React; {@link
73
+ * import("./client.js").useResetPassword} calls it before hitting the server.
74
+ */
75
+ export function validateNewPassword(
76
+ newPassword: string,
77
+ confirmPassword: string,
78
+ policy: PasswordPolicy = {},
79
+ ): ResetPasswordError | null {
80
+ const min = policy.minLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
81
+ const max = policy.maxLength ?? DEFAULT_MAX_PASSWORD_LENGTH;
82
+ if (newPassword.length < min) {
83
+ return {
84
+ code: "password_too_short",
85
+ message: `Password must be at least ${min} characters.`,
86
+ minLength: min,
87
+ };
88
+ }
89
+ if (newPassword.length > max) {
90
+ return {
91
+ code: "password_too_long",
92
+ message: `Password must be at most ${max} characters.`,
93
+ maxLength: max,
94
+ };
95
+ }
96
+ if (newPassword !== confirmPassword) {
97
+ return {
98
+ code: "passwords_do_not_match",
99
+ message: "Passwords do not match.",
100
+ };
101
+ }
102
+ return null;
103
+ }