@ingram-tech/nk-auth 0.2.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/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # @ingram-tech/nk-auth
2
+
3
+ The Ingram **[Better Auth](https://better-auth.com) foundation**: a toolkit of
4
+ composable presets each Ingram Next.js site spreads into its *own*
5
+ `betterAuth()` call. It is **not** a `betterAuth()` wrapper — the site stays
6
+ plain Better Auth (prime directive), keeping full plugin type inference. Import
7
+ only what you need from focused subpaths.
8
+
9
+ `better-auth`, `pg`, `@better-auth/passkey`, `@supabase/supabase-js` are
10
+ **peer dependencies** so there's exactly one Better Auth copy in the app.
11
+
12
+ | Export (subpath) | For |
13
+ | --- | --- |
14
+ | `rlsJwtOptions` (`./jwt`) | Supabase RLS bridge token (`role:"authenticated"`) |
15
+ | `backendJwtOptions` / `verifyBackendJwt` (`./jwt`) | a JWT for the site's own backend API (custom `audience`) |
16
+ | `nkOrganizationDefaults`, `lastActiveOrganizationHooks`, `lastActiveOrganizationUserField` (`./organization`) | org-plugin defaults + active-org restore/persist |
17
+ | `createAuthPool` (`./pool`) | `pg` Pool with optional SSL CA verification |
18
+ | `bcryptPassword`, `makeEmailSenders`, `makePasskeyOptions`, `uuidGenerateId` (`./`) | password migration, email hooks, passkeys, UUID ids |
19
+ | `createServerSupabase` (`./`) | RLS-aware supabase-js client (attaches the session JWT) |
20
+
21
+ > **Supabase Auth → Better Auth + RLS migration?** Read
22
+ > [`docs/better-auth-migration.md`](../../docs/better-auth-migration.md) — the
23
+ > RLS bridge, the migration runbook, and the gotchas.
24
+ >
25
+ > **Backend-JWT + org sites** (e.g. integrain): compose `createAuthPool`,
26
+ > `backendJwtOptions({ audience })`, `nkOrganizationDefaults`, and
27
+ > `lastActiveOrganizationHooks(pool)` in your `betterAuth()`; verify backend
28
+ > tokens with `verifyBackendJwt`. Keep app-specific bits (SSO restrictions,
29
+ > permissions/roles, connectors) in the app.
30
+ >
31
+ > **Note:** pin `kysely@0.28.x` in the consuming app (0.29 moved
32
+ > `DEFAULT_MIGRATION_TABLE` out of its barrel, breaking the adapter + the
33
+ > Turbopack build).
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ bun add @ingram-tech/nk-auth @supabase/supabase-js better-auth @better-auth/passkey pg bcrypt
39
+ ```
40
+
41
+ Set the env contract (validated by `keys.ts`):
42
+
43
+ ```dotenv
44
+ BETTER_AUTH_SECRET=… # openssl rand -hex 32
45
+ BETTER_AUTH_URL=https://example.com
46
+ DATABASE_URL=… # DIRECT Postgres (session pooler / :5432), NOT PostgREST
47
+ NEXT_PUBLIC_SUPABASE_URL=…
48
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=…
49
+ ```
50
+
51
+ ## 1. Apply the schema
52
+
53
+ ```bash
54
+ cp node_modules/@ingram-tech/nk-auth/migrations/0001_better_auth.sql \
55
+ supabase/migrations/$(date +%Y%m%d%H%M%S)_better_auth.sql
56
+ ```
57
+
58
+ It creates Better Auth's tables (`user`, `session`, `account`, `verification`,
59
+ `jwks`, `passkey`), defaults new user ids to UUIDs, and puts **deny-all RLS** on
60
+ all of them (they're exposed via PostgREST; Better Auth uses its own privileged
61
+ connection). Reconcile against your pinned `better-auth` with
62
+ `npx @better-auth/cli generate` after upgrades.
63
+
64
+ ## 2. Configure the server
65
+
66
+ This package is **not** a `betterAuth()` wrapper — your site calls `betterAuth`
67
+ itself and spreads in our presets. That keeps full Better Auth type inference at
68
+ the call site (so `auth.api.*` stays typed) and keeps the site plain Better Auth,
69
+ per the prime directive. The presets carry the RLS-preserving bits.
70
+
71
+ ```ts
72
+ // lib/auth.ts
73
+ import { passkey } from "@better-auth/passkey";
74
+ import { fromAddress, sendEmail } from "@ingram-tech/email";
75
+ import {
76
+ authEnv,
77
+ bcryptPassword,
78
+ makeEmailSenders,
79
+ makePasskeyOptions,
80
+ rlsJwtOptions,
81
+ uuidGenerateId,
82
+ } from "@ingram-tech/nk-auth";
83
+ import { betterAuth } from "better-auth";
84
+ import { jwt } from "better-auth/plugins/jwt";
85
+ import { Pool } from "pg";
86
+
87
+ const env = authEnv();
88
+ const email = makeEmailSenders(({ to, subject, url }) =>
89
+ sendEmail({ to, from: fromAddress(), subject, text: url, html: url }),
90
+ );
91
+
92
+ export const auth = betterAuth({
93
+ database: new Pool({ connectionString: env.databaseUrl }),
94
+ secret: env.secret,
95
+ baseURL: env.baseURL,
96
+ advanced: { database: { generateId: uuidGenerateId } }, // UUID-shaped ids
97
+ emailAndPassword: {
98
+ enabled: true,
99
+ password: bcryptPassword, // verifies migrated Supabase bcrypt hashes
100
+ sendResetPassword: email.sendResetPassword,
101
+ },
102
+ emailVerification: { sendVerificationEmail: email.sendVerificationEmail },
103
+ socialProviders: {
104
+ google: {
105
+ clientId: process.env.GOOGLE_CLIENT_ID ?? "",
106
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
107
+ },
108
+ },
109
+ plugins: [
110
+ jwt(rlsJwtOptions), // the RLS bridge
111
+ passkey(makePasskeyOptions({ rpId: "example.com", rpName: "Example", origin: env.baseURL })),
112
+ ],
113
+ });
114
+ ```
115
+
116
+ ```ts
117
+ // app/api/auth/[...all]/route.ts — a standard Next.js route handler
118
+ import { auth } from "@/lib/auth";
119
+ export const { GET, POST } = auth.handler;
120
+ ```
121
+
122
+ ## 3. Query data with RLS intact
123
+
124
+ Always go through `createServerSupabase` instead of constructing supabase-js
125
+ yourself — it attaches the Better Auth JWT so `auth.uid()` keeps working. Wire
126
+ `getToken` to your instance's jwt endpoint (fully typed because *your* site owns
127
+ the `betterAuth` call):
128
+
129
+ ```ts
130
+ import { authEnv, createServerSupabase } from "@ingram-tech/nk-auth";
131
+ import { auth } from "@/lib/auth";
132
+
133
+ export async function GET(request: Request) {
134
+ const env = authEnv();
135
+ const supabase = createServerSupabase({
136
+ getToken: async () =>
137
+ (await auth.api.getToken({ headers: request.headers }))?.token ?? null,
138
+ supabaseUrl: env.supabaseUrl,
139
+ supabaseAnonKey: env.supabaseAnonKey,
140
+ });
141
+ // Reads/writes run as the signed-in user; existing RLS policies apply.
142
+ const { data, error } = await supabase.from("notes").select("*");
143
+ if (error) throw new Error(error.message);
144
+ return Response.json(data);
145
+ }
146
+ ```
147
+
148
+ ## 4. Client
149
+
150
+ Assemble the client in a `"use client"` module (full plugin inference is
151
+ preserved here too):
152
+
153
+ ```tsx
154
+ "use client";
155
+ import {
156
+ createAuthClient,
157
+ jwtClient,
158
+ passkeyClient,
159
+ } from "@ingram-tech/nk-auth/client";
160
+
161
+ export const authClient = createAuthClient({
162
+ baseURL: process.env.NEXT_PUBLIC_SITE_URL ?? "",
163
+ plugins: [jwtClient(), passkeyClient()],
164
+ });
165
+ // authClient.signIn.email(...), signIn.social(...), useSession(), passkey.*
166
+ ```
167
+
168
+ ## RLS bridge (the important part)
169
+
170
+ `auth.uid()` reads the `sub` claim of the JWT PostgREST receives. The `jwt`
171
+ plugin (configured here) mints an asymmetric token with `sub` = the user's UUID
172
+ and `role: "authenticated"`, exposed at `/api/auth/jwks`. Register that JWKS URL
173
+ as a Supabase **third-party auth** issuer, and every existing policy works
174
+ unchanged. Full rationale and the HS256 fallback:
175
+ [`docs/better-auth-migration.md`](../../docs/better-auth-migration.md).
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Browser auth re-exports, at "@ingram-tech/nk-auth/client" so the server entry
3
+ * never pulls in React. A site builds its own client to preserve full plugin
4
+ * type inference:
5
+ *
6
+ * "use client";
7
+ * import {
8
+ * createAuthClient, jwtClient, passkeyClient,
9
+ * } from "@ingram-tech/nk-auth/client";
10
+ *
11
+ * export const authClient = createAuthClient({
12
+ * baseURL: process.env.NEXT_PUBLIC_SITE_URL,
13
+ * plugins: [jwtClient(), passkeyClient()],
14
+ * });
15
+ *
16
+ * This exposes the call surface that replaces `supabase.auth.*`:
17
+ * signUp.email / signIn.email / signIn.social / signOut / useSession,
18
+ * plus passkey.* (register / authenticate).
19
+ */
20
+ export { passkeyClient } from "@better-auth/passkey/client";
21
+ export { jwtClient } from "better-auth/client/plugins";
22
+ export { createAuthClient } from "better-auth/react";
23
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/client.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Browser auth re-exports, at "@ingram-tech/nk-auth/client" so the server entry
3
+ * never pulls in React. A site builds its own client to preserve full plugin
4
+ * type inference:
5
+ *
6
+ * "use client";
7
+ * import {
8
+ * createAuthClient, jwtClient, passkeyClient,
9
+ * } from "@ingram-tech/nk-auth/client";
10
+ *
11
+ * export const authClient = createAuthClient({
12
+ * baseURL: process.env.NEXT_PUBLIC_SITE_URL,
13
+ * plugins: [jwtClient(), passkeyClient()],
14
+ * });
15
+ *
16
+ * This exposes the call surface that replaces `supabase.auth.*`:
17
+ * signUp.email / signIn.email / signIn.social / signOut / useSession,
18
+ * plus passkey.* (register / authenticate).
19
+ */
20
+ export { passkeyClient } from "@better-auth/passkey/client";
21
+ export { jwtClient } from "better-auth/client/plugins";
22
+ export { createAuthClient } from "better-auth/react";
23
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,7 @@
1
+ export { type BackendJwtConfig, backendJwtOptions, rlsJwtOptions, verifyBackendJwt, } from "./jwt";
2
+ export { type AuthEnv, authEnv, isConfigured } from "./keys";
3
+ export { bcryptPassword, makeEmailSenders, makePasskeyOptions, type PasskeyConfig, type SendEmail, uuidGenerateId, } from "./options";
4
+ export { lastActiveOrganizationHooks, lastActiveOrganizationUserField, nkOrganizationDefaults, } from "./organization";
5
+ export { createAuthPool } from "./pool";
6
+ export { createServerSupabase, type ServerSupabaseConfig, } from "./supabase";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EACN,KAAK,gBAAgB,EACrB,iBAAiB,EACjB,aAAa,EACb,gBAAgB,GAChB,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,KAAK,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EACN,cAAc,EACd,gBAAgB,EAChB,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,cAAc,GACd,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,2BAA2B,EAC3B,+BAA+B,EAC/B,sBAAsB,GACtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EACN,oBAAoB,EACpB,KAAK,oBAAoB,GACzB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Server entry — the Ingram Better Auth foundation. Browser re-exports live at
2
+ // "@ingram-tech/nk-auth/client" so importing the server presets never pulls in
3
+ // React. Focused subpaths (./jwt, ./organization, ./pool) let a site import
4
+ // only what it needs (e.g. avoid bcrypt/supabase when it uses neither).
5
+ export { backendJwtOptions, rlsJwtOptions, verifyBackendJwt, } from "./jwt";
6
+ export { authEnv, isConfigured } from "./keys";
7
+ export { bcryptPassword, makeEmailSenders, makePasskeyOptions, uuidGenerateId, } from "./options";
8
+ export { lastActiveOrganizationHooks, lastActiveOrganizationUserField, nkOrganizationDefaults, } from "./organization";
9
+ export { createAuthPool } from "./pool";
10
+ export { createServerSupabase, } from "./supabase";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,wEAAwE;AAExE,OAAO,EAEN,iBAAiB,EACjB,aAAa,EACb,gBAAgB,GAChB,MAAM,OAAO,CAAC;AACf,OAAO,EAAgB,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EACN,cAAc,EACd,gBAAgB,EAChB,kBAAkB,EAGlB,cAAc,GACd,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,2BAA2B,EAC3B,+BAA+B,EAC/B,sBAAsB,GACtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EACN,oBAAoB,GAEpB,MAAM,YAAY,CAAC"}
package/dist/jwt.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { JwtOptions } from "better-auth/plugins/jwt";
2
+ import { type JWTPayload } from "jose";
3
+ /**
4
+ * `jwt` plugin presets. Two shapes, because Ingram sites mint JWTs for two
5
+ * different audiences:
6
+ *
7
+ * - `rlsJwtOptions` — a Supabase RLS bridge token (`role: "authenticated"`),
8
+ * so PostgREST's `auth.uid()` keeps working. See docs/better-auth-migration.md.
9
+ * - `backendJwtOptions` — a short-lived token for a site's OWN backend API
10
+ * (a specific `audience`), typically carrying extra claims the site adds via
11
+ * `auth.api.signJWT`.
12
+ *
13
+ * Both default to EdDSA (Better Auth's default), exposed at `/api/auth/jwks`.
14
+ */
15
+ /** Supabase RLS bridge: mints `sub` = user id, `role`/`aud` = "authenticated". */
16
+ export declare const rlsJwtOptions: JwtOptions;
17
+ export interface BackendJwtConfig {
18
+ /** Expected audience of the site's backend, e.g. "ingram-wiki-backend". */
19
+ audience: string;
20
+ /** Token lifetime, e.g. "15m". */
21
+ expirationTime?: string;
22
+ /**
23
+ * Don't auto-attach the JWT to the `set-auth-jwt` response header — set when
24
+ * the site mints tokens on demand via `auth.api.signJWT` instead.
25
+ */
26
+ disableSettingJwtHeader?: boolean;
27
+ }
28
+ /** `jwt` plugin options for a backend-API token (custom audience). */
29
+ export declare const backendJwtOptions: (config: BackendJwtConfig) => JwtOptions;
30
+ /**
31
+ * Verify a Better-Auth-minted backend JWT (EdDSA) against the issuer's JWKS.
32
+ * For use by the backend that consumes `backendJwtOptions` tokens. Throws on an
33
+ * invalid/expired token or audience/issuer mismatch; returns the claims.
34
+ */
35
+ export declare const verifyBackendJwt: (params: {
36
+ token: string;
37
+ /** The issuer's JWKS endpoint, e.g. `${BETTER_AUTH_URL}/api/auth/jwks`. */
38
+ jwksUrl: string | URL;
39
+ audience: string;
40
+ issuer: string;
41
+ }) => Promise<JWTPayload>;
42
+ //# sourceMappingURL=jwt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwt.d.ts","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EAAsB,KAAK,UAAU,EAAa,MAAM,MAAM,CAAC;AAEtE;;;;;;;;;;;GAWG;AAEH,kFAAkF;AAClF,eAAO,MAAM,aAAa,EAAE,UAS3B,CAAC;AAEF,MAAM,WAAW,gBAAgB;IAChC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,GAAI,QAAQ,gBAAgB,KAAG,UAM3D,CAAC;AAIH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAU,QAAQ;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CACf,KAAG,OAAO,CAAC,UAAU,CAerB,CAAC"}
package/dist/jwt.js ADDED
@@ -0,0 +1,54 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ /**
3
+ * `jwt` plugin presets. Two shapes, because Ingram sites mint JWTs for two
4
+ * different audiences:
5
+ *
6
+ * - `rlsJwtOptions` — a Supabase RLS bridge token (`role: "authenticated"`),
7
+ * so PostgREST's `auth.uid()` keeps working. See docs/better-auth-migration.md.
8
+ * - `backendJwtOptions` — a short-lived token for a site's OWN backend API
9
+ * (a specific `audience`), typically carrying extra claims the site adds via
10
+ * `auth.api.signJWT`.
11
+ *
12
+ * Both default to EdDSA (Better Auth's default), exposed at `/api/auth/jwks`.
13
+ */
14
+ /** Supabase RLS bridge: mints `sub` = user id, `role`/`aud` = "authenticated". */
15
+ export const rlsJwtOptions = {
16
+ jwks: { keyPairConfig: { alg: "EdDSA" } },
17
+ jwt: {
18
+ definePayload: ({ user }) => ({
19
+ sub: user.id,
20
+ role: "authenticated",
21
+ aud: "authenticated",
22
+ }),
23
+ },
24
+ };
25
+ /** `jwt` plugin options for a backend-API token (custom audience). */
26
+ export const backendJwtOptions = (config) => ({
27
+ disableSettingJwtHeader: config.disableSettingJwtHeader,
28
+ jwt: {
29
+ audience: config.audience,
30
+ expirationTime: config.expirationTime,
31
+ },
32
+ });
33
+ const jwksCache = new Map();
34
+ /**
35
+ * Verify a Better-Auth-minted backend JWT (EdDSA) against the issuer's JWKS.
36
+ * For use by the backend that consumes `backendJwtOptions` tokens. Throws on an
37
+ * invalid/expired token or audience/issuer mismatch; returns the claims.
38
+ */
39
+ export const verifyBackendJwt = async (params) => {
40
+ const url = typeof params.jwksUrl === "string" ? new URL(params.jwksUrl) : params.jwksUrl;
41
+ const cacheKey = url.toString();
42
+ let jwks = jwksCache.get(cacheKey);
43
+ if (!jwks) {
44
+ jwks = createRemoteJWKSet(url);
45
+ jwksCache.set(cacheKey, jwks);
46
+ }
47
+ const { payload } = await jwtVerify(params.token, jwks, {
48
+ audience: params.audience,
49
+ issuer: params.issuer,
50
+ algorithms: ["EdDSA"],
51
+ });
52
+ return payload;
53
+ };
54
+ //# sourceMappingURL=jwt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwt.js","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAmB,SAAS,EAAE,MAAM,MAAM,CAAC;AAEtE;;;;;;;;;;;GAWG;AAEH,kFAAkF;AAClF,MAAM,CAAC,MAAM,aAAa,GAAe;IACxC,IAAI,EAAE,EAAE,aAAa,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE;IACzC,GAAG,EAAE;QACJ,aAAa,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;YAC7B,GAAG,EAAE,IAAI,CAAC,EAAE;YACZ,IAAI,EAAE,eAAe;YACrB,GAAG,EAAE,eAAe;SACpB,CAAC;KACF;CACD,CAAC;AAcF,sEAAsE;AACtE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,MAAwB,EAAc,EAAE,CAAC,CAAC;IAC3E,uBAAuB,EAAE,MAAM,CAAC,uBAAuB;IACvD,GAAG,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,cAAc,EAAE,MAAM,CAAC,cAAc;KACrC;CACD,CAAC,CAAC;AAEH,MAAM,SAAS,GAAG,IAAI,GAAG,EAAiD,CAAC;AAE3E;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,KAAK,EAAE,MAMtC,EAAuB,EAAE;IACzB,MAAM,GAAG,GACR,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;IAC/E,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,EAAE,CAAC;IAChC,IAAI,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACnC,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,IAAI,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC;QAC/B,SAAS,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAC/B,CAAC;IACD,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,EAAE;QACvD,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,UAAU,EAAE,CAAC,OAAO,CAAC;KACrB,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AAChB,CAAC,CAAC"}
package/dist/keys.d.ts ADDED
@@ -0,0 +1,32 @@
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 `createAuth` / `createServerSupabase`.
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
13
+ * (session-mode pooler or :5432, NOT PostgREST)
14
+ * NEXT_PUBLIC_SUPABASE_URL — Supabase project URL (for the data client)
15
+ * NEXT_PUBLIC_SUPABASE_ANON_KEY — Supabase anon key (RLS still enforced)
16
+ */
17
+ export interface AuthEnv {
18
+ secret: string;
19
+ baseURL: string;
20
+ databaseUrl: string;
21
+ supabaseUrl: string;
22
+ supabaseAnonKey: string;
23
+ }
24
+ /**
25
+ * Read and validate all nk-auth env vars at once. Throws a single error listing
26
+ * everything missing/invalid, so a misconfigured site fails fast at startup
27
+ * rather than at first sign-in.
28
+ */
29
+ export declare const authEnv: () => AuthEnv;
30
+ /** Whether nk-auth is fully configured (lets callers degrade in local/dev). */
31
+ export declare const isConfigured: () => boolean;
32
+ //# sourceMappingURL=keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAYH,MAAM,WAAW,OAAO;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,QAAO,OAgB1B,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,YAAY,QAAO,OAAgD,CAAC"}
package/dist/keys.js ADDED
@@ -0,0 +1,49 @@
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 `createAuth` / `createServerSupabase`.
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
13
+ * (session-mode pooler or :5432, NOT PostgREST)
14
+ * NEXT_PUBLIC_SUPABASE_URL — Supabase project URL (for the data client)
15
+ * NEXT_PUBLIC_SUPABASE_ANON_KEY — Supabase anon key (RLS still enforced)
16
+ */
17
+ import { z } from "zod";
18
+ const schema = z.object({
19
+ BETTER_AUTH_SECRET: z.string().min(1),
20
+ BETTER_AUTH_URL: z.string().url(),
21
+ DATABASE_URL: z.string().min(1),
22
+ NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
23
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
24
+ });
25
+ /**
26
+ * Read and validate all nk-auth env vars at once. Throws a single error listing
27
+ * everything missing/invalid, so a misconfigured site fails fast at startup
28
+ * rather than at first sign-in.
29
+ */
30
+ export const authEnv = () => {
31
+ const result = schema.safeParse(process.env);
32
+ if (!result.success) {
33
+ const issues = result.error.issues
34
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
35
+ .join(", ");
36
+ throw new Error(`@ingram-tech/nk-auth: invalid environment — ${issues}`);
37
+ }
38
+ const env = result.data;
39
+ return {
40
+ secret: env.BETTER_AUTH_SECRET,
41
+ baseURL: env.BETTER_AUTH_URL,
42
+ databaseUrl: env.DATABASE_URL,
43
+ supabaseUrl: env.NEXT_PUBLIC_SUPABASE_URL,
44
+ supabaseAnonKey: env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
45
+ };
46
+ };
47
+ /** Whether nk-auth is fully configured (lets callers degrade in local/dev). */
48
+ export const isConfigured = () => schema.safeParse(process.env).success;
49
+ //# sourceMappingURL=keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;IACvB,kBAAkB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACrC,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IACjC,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/B,wBAAwB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IAC1C,6BAA6B,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAChD,CAAC,CAAC;AAUH;;;;GAIG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,GAAY,EAAE;IACpC,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC7C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACrB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM;aAChC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;aAC3D,IAAI,CAAC,IAAI,CAAC,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,+CAA+C,MAAM,EAAE,CAAC,CAAC;IAC1E,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC;IACxB,OAAO;QACN,MAAM,EAAE,GAAG,CAAC,kBAAkB;QAC9B,OAAO,EAAE,GAAG,CAAC,eAAe;QAC5B,WAAW,EAAE,GAAG,CAAC,YAAY;QAC7B,WAAW,EAAE,GAAG,CAAC,wBAAwB;QACzC,eAAe,EAAE,GAAG,CAAC,6BAA6B;KAClD,CAAC;AACH,CAAC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,CAAC,MAAM,YAAY,GAAG,GAAY,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC"}
@@ -0,0 +1,61 @@
1
+ import type { PasskeyOptions } from "@better-auth/passkey";
2
+ /**
3
+ * Portable Better Auth building blocks for Ingram sites.
4
+ *
5
+ * Deliberately NOT a `betterAuth()` wrapper: a site assembles its own instance
6
+ * and spreads these presets in. That keeps full plugin type inference at the
7
+ * call site (where `declaration` is off) and respects the prime directive — the
8
+ * site stays plain Better Auth, we just ship the shared config. JWT + org
9
+ * presets live in `./jwt` and `./organization`. See docs/better-auth-migration.md.
10
+ *
11
+ * `uuidGenerateId` keeps new-user ids UUID-shaped (needed for Supabase
12
+ * `auth.uid()::uuid` on RLS sites).
13
+ */
14
+ /**
15
+ * `emailAndPassword.password` config. Verifies with bcrypt so passwords
16
+ * migrated from Supabase (bcrypt) keep working — Better Auth defaults to scrypt.
17
+ */
18
+ export declare const bcryptPassword: {
19
+ hash: (password: string) => Promise<string>;
20
+ verify: ({ hash, password, }: {
21
+ hash: string;
22
+ password: string;
23
+ }) => Promise<boolean>;
24
+ };
25
+ /** `advanced.database.generateId` — keeps new-user ids UUID-shaped. */
26
+ export declare const uuidGenerateId: () => string;
27
+ export interface PasskeyConfig {
28
+ /** Relying-party id: the registrable domain, e.g. "example.com". */
29
+ rpId: string;
30
+ /** Relying-party display name. */
31
+ rpName: string;
32
+ /** Expected origin(s), e.g. "https://example.com". */
33
+ origin: string | string[];
34
+ }
35
+ /** Build `passkey` plugin options. Use as `passkey(makePasskeyOptions(cfg))`. */
36
+ export declare const makePasskeyOptions: (cfg: PasskeyConfig) => PasskeyOptions;
37
+ /** Send one transactional email (wire to `@ingram-tech/email`'s `sendEmail`). */
38
+ export type SendEmail = (message: {
39
+ to: string;
40
+ subject: string;
41
+ url: string;
42
+ }) => Promise<unknown>;
43
+ /**
44
+ * Email callbacks for `emailAndPassword.sendResetPassword` and
45
+ * `emailVerification.sendVerificationEmail`, routed through your sender.
46
+ */
47
+ export declare const makeEmailSenders: (send: SendEmail) => {
48
+ sendResetPassword: ({ user, url, }: {
49
+ user: {
50
+ email: string;
51
+ };
52
+ url: string;
53
+ }) => Promise<void>;
54
+ sendVerificationEmail: ({ user, url, }: {
55
+ user: {
56
+ email: string;
57
+ };
58
+ url: string;
59
+ }) => Promise<void>;
60
+ };
61
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG3D;;;;;;;;;;;GAWG;AAEH;;;GAGG;AACH,eAAO,MAAM,cAAc;qBACT,MAAM,KAAG,OAAO,CAAC,MAAM,CAAC;kCAItC;QACF,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KACjB,KAAG,OAAO,CAAC,OAAO,CAAC;CACpB,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,cAAc,QAAO,MAAsB,CAAC;AAEzD,MAAM,WAAW,aAAa;IAC7B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAC1B;AAED,iFAAiF;AACjF,eAAO,MAAM,kBAAkB,GAAI,KAAK,aAAa,KAAG,cAItD,CAAC;AAEH,iFAAiF;AACjF,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,EAAE;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACZ,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEvB;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,MAAM,SAAS;wCAI5C;QACF,IAAI,EAAE;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC;QACxB,GAAG,EAAE,MAAM,CAAC;KACZ,KAAG,OAAO,CAAC,IAAI,CAAC;4CAMd;QACF,IAAI,EAAE;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC;QACxB,GAAG,EAAE,MAAM,CAAC;KACZ,KAAG,OAAO,CAAC,IAAI,CAAC;CAGhB,CAAC"}
@@ -0,0 +1,43 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import bcrypt from "bcrypt";
3
+ /**
4
+ * Portable Better Auth building blocks for Ingram sites.
5
+ *
6
+ * Deliberately NOT a `betterAuth()` wrapper: a site assembles its own instance
7
+ * and spreads these presets in. That keeps full plugin type inference at the
8
+ * call site (where `declaration` is off) and respects the prime directive — the
9
+ * site stays plain Better Auth, we just ship the shared config. JWT + org
10
+ * presets live in `./jwt` and `./organization`. See docs/better-auth-migration.md.
11
+ *
12
+ * `uuidGenerateId` keeps new-user ids UUID-shaped (needed for Supabase
13
+ * `auth.uid()::uuid` on RLS sites).
14
+ */
15
+ /**
16
+ * `emailAndPassword.password` config. Verifies with bcrypt so passwords
17
+ * migrated from Supabase (bcrypt) keep working — Better Auth defaults to scrypt.
18
+ */
19
+ export const bcryptPassword = {
20
+ hash: (password) => bcrypt.hash(password, 10),
21
+ verify: ({ hash, password, }) => bcrypt.compare(password, hash),
22
+ };
23
+ /** `advanced.database.generateId` — keeps new-user ids UUID-shaped. */
24
+ export const uuidGenerateId = () => randomUUID();
25
+ /** Build `passkey` plugin options. Use as `passkey(makePasskeyOptions(cfg))`. */
26
+ export const makePasskeyOptions = (cfg) => ({
27
+ rpID: cfg.rpId,
28
+ rpName: cfg.rpName,
29
+ origin: cfg.origin,
30
+ });
31
+ /**
32
+ * Email callbacks for `emailAndPassword.sendResetPassword` and
33
+ * `emailVerification.sendVerificationEmail`, routed through your sender.
34
+ */
35
+ export const makeEmailSenders = (send) => ({
36
+ sendResetPassword: async ({ user, url, }) => {
37
+ await send({ to: user.email, subject: "Reset your password", url });
38
+ },
39
+ sendVerificationEmail: async ({ user, url, }) => {
40
+ await send({ to: user.email, subject: "Verify your email", url });
41
+ },
42
+ });
43
+ //# sourceMappingURL=options.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.js","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,MAAM,MAAM,QAAQ,CAAC;AAE5B;;;;;;;;;;;GAWG;AAEH;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC7B,IAAI,EAAE,CAAC,QAAgB,EAAmB,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,CAAC;IACtE,MAAM,EAAE,CAAC,EACR,IAAI,EACJ,QAAQ,GAIR,EAAoB,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;CACtD,CAAC;AAEF,uEAAuE;AACvE,MAAM,CAAC,MAAM,cAAc,GAAG,GAAW,EAAE,CAAC,UAAU,EAAE,CAAC;AAWzD,iFAAiF;AACjF,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,GAAkB,EAAkB,EAAE,CAAC,CAAC;IAC1E,IAAI,EAAE,GAAG,CAAC,IAAI;IACd,MAAM,EAAE,GAAG,CAAC,MAAM;IAClB,MAAM,EAAE,GAAG,CAAC,MAAM;CAClB,CAAC,CAAC;AASH;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,IAAe,EAAE,EAAE,CAAC,CAAC;IACrD,iBAAiB,EAAE,KAAK,EAAE,EACzB,IAAI,EACJ,GAAG,GAIH,EAAiB,EAAE;QACnB,MAAM,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,qBAAqB,EAAE,GAAG,EAAE,CAAC,CAAC;IACrE,CAAC;IACD,qBAAqB,EAAE,KAAK,EAAE,EAC7B,IAAI,EACJ,GAAG,GAIH,EAAiB,EAAE;QACnB,MAAM,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,CAAC,CAAC;IACnE,CAAC;CACD,CAAC,CAAC"}
@@ -0,0 +1,52 @@
1
+ import type { Pool } from "pg";
2
+ /**
3
+ * Organization-plugin building blocks shared across Ingram sites. The site
4
+ * still calls `organization({ ...nkOrganizationDefaults, ac, roles, ... })`
5
+ * itself — these only supply the generic defaults + the active-org plumbing.
6
+ */
7
+ /** Sensible Ingram defaults to spread into the `organization()` plugin. */
8
+ export declare const nkOrganizationDefaults: {
9
+ /** Invitations valid for one week. */
10
+ readonly invitationExpiresIn: number;
11
+ /** The user who creates an org is its owner. */
12
+ readonly creatorRole: "owner";
13
+ };
14
+ /**
15
+ * `user.additionalFields` entry that records the user's last active org so it
16
+ * can be restored on the next sign-in. Spread into `user.additionalFields`.
17
+ */
18
+ export declare const lastActiveOrganizationUserField: {
19
+ readonly lastActiveOrganizationId: {
20
+ readonly type: "string";
21
+ readonly required: false;
22
+ readonly input: false;
23
+ readonly returned: true;
24
+ };
25
+ };
26
+ interface SessionLike {
27
+ userId: string;
28
+ activeOrganizationId?: string | null;
29
+ }
30
+ /**
31
+ * `databaseHooks` that restore the user's last active organization on session
32
+ * create (only if they're still a member) and persist it on session update.
33
+ * Requires `lastActiveOrganizationUserField` in `user.additionalFields` and the
34
+ * Better Auth organization tables. Spread as `databaseHooks: lastActiveOrganizationHooks(pool)`.
35
+ */
36
+ export declare const lastActiveOrganizationHooks: (pool: Pool) => {
37
+ session: {
38
+ create: {
39
+ before: (session: SessionLike) => Promise<{
40
+ data: {
41
+ activeOrganizationId: string;
42
+ userId: string;
43
+ };
44
+ } | undefined>;
45
+ };
46
+ update: {
47
+ after: (session: SessionLike) => Promise<void>;
48
+ };
49
+ };
50
+ };
51
+ export {};
52
+ //# sourceMappingURL=organization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"organization.d.ts","sourceRoot":"","sources":["../src/organization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAE/B;;;;GAIG;AAEH,2EAA2E;AAC3E,eAAO,MAAM,sBAAsB;IAClC,sCAAsC;;IAEtC,gDAAgD;;CAEvC,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,+BAA+B;;;;;;;CAOlC,CAAC;AAEX,UAAU,WAAW;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,IAAI;;;8BAG3B,WAAW;;;4BAb7B,MAAM;;;;;6BAqCW,WAAW;;;CAYnC,CAAC"}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Organization-plugin building blocks shared across Ingram sites. The site
3
+ * still calls `organization({ ...nkOrganizationDefaults, ac, roles, ... })`
4
+ * itself — these only supply the generic defaults + the active-org plumbing.
5
+ */
6
+ /** Sensible Ingram defaults to spread into the `organization()` plugin. */
7
+ export const nkOrganizationDefaults = {
8
+ /** Invitations valid for one week. */
9
+ invitationExpiresIn: 60 * 60 * 24 * 7,
10
+ /** The user who creates an org is its owner. */
11
+ creatorRole: "owner",
12
+ };
13
+ /**
14
+ * `user.additionalFields` entry that records the user's last active org so it
15
+ * can be restored on the next sign-in. Spread into `user.additionalFields`.
16
+ */
17
+ export const lastActiveOrganizationUserField = {
18
+ lastActiveOrganizationId: {
19
+ type: "string",
20
+ required: false,
21
+ input: false,
22
+ returned: true,
23
+ },
24
+ };
25
+ /**
26
+ * `databaseHooks` that restore the user's last active organization on session
27
+ * create (only if they're still a member) and persist it on session update.
28
+ * Requires `lastActiveOrganizationUserField` in `user.additionalFields` and the
29
+ * Better Auth organization tables. Spread as `databaseHooks: lastActiveOrganizationHooks(pool)`.
30
+ */
31
+ export const lastActiveOrganizationHooks = (pool) => ({
32
+ session: {
33
+ create: {
34
+ before: async (session) => {
35
+ if (session.activeOrganizationId) {
36
+ return;
37
+ }
38
+ const userResult = await pool.query('SELECT "lastActiveOrganizationId" FROM "user" WHERE id = $1', [
39
+ session.userId,
40
+ ]);
41
+ const lastOrgId = userResult.rows[0]?.lastActiveOrganizationId;
42
+ if (!lastOrgId) {
43
+ return;
44
+ }
45
+ const memberResult = await pool.query('SELECT 1 FROM member WHERE "userId" = $1 AND "organizationId" = $2 LIMIT 1', [session.userId, lastOrgId]);
46
+ if (memberResult.rows.length === 0) {
47
+ return;
48
+ }
49
+ return { data: { ...session, activeOrganizationId: lastOrgId } };
50
+ },
51
+ },
52
+ update: {
53
+ after: async (session) => {
54
+ const orgId = session.activeOrganizationId;
55
+ if (typeof orgId !== "string" || !orgId) {
56
+ return;
57
+ }
58
+ await pool.query('UPDATE "user" SET "lastActiveOrganizationId" = $1 WHERE id = $2', [orgId, session.userId]);
59
+ },
60
+ },
61
+ },
62
+ });
63
+ //# sourceMappingURL=organization.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"organization.js","sourceRoot":"","sources":["../src/organization.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,2EAA2E;AAC3E,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACrC,sCAAsC;IACtC,mBAAmB,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;IACrC,gDAAgD;IAChD,WAAW,EAAE,OAAO;CACX,CAAC;AAEX;;;GAGG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG;IAC9C,wBAAwB,EAAE;QACzB,IAAI,EAAE,QAAQ;QACd,QAAQ,EAAE,KAAK;QACf,KAAK,EAAE,KAAK;QACZ,QAAQ,EAAE,IAAI;KACd;CACQ,CAAC;AAOX;;;;;GAKG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,IAAU,EAAE,EAAE,CAAC,CAAC;IAC3D,OAAO,EAAE;QACR,MAAM,EAAE;YACP,MAAM,EAAE,KAAK,EAAE,OAAoB,EAAE,EAAE;gBACtC,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC;oBAClC,OAAO;gBACR,CAAC;gBACD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,KAAK,CAEhC,6DAA6D,EAAE;oBACjE,OAAO,CAAC,MAAM;iBACd,CAAC,CAAC;gBACH,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,wBAAwB,CAAC;gBAC/D,IAAI,CAAC,SAAS,EAAE,CAAC;oBAChB,OAAO;gBACR,CAAC;gBACD,MAAM,YAAY,GAAG,MAAM,IAAI,CAAC,KAAK,CACpC,4EAA4E,EAC5E,CAAC,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC,CAC3B,CAAC;gBACF,IAAI,YAAY,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACpC,OAAO;gBACR,CAAC;gBACD,OAAO,EAAE,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,oBAAoB,EAAE,SAAS,EAAE,EAAE,CAAC;YAClE,CAAC;SACD;QACD,MAAM,EAAE;YACP,KAAK,EAAE,KAAK,EAAE,OAAoB,EAAE,EAAE;gBACrC,MAAM,KAAK,GAAG,OAAO,CAAC,oBAAoB,CAAC;gBAC3C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,EAAE,CAAC;oBACzC,OAAO;gBACR,CAAC;gBACD,MAAM,IAAI,CAAC,KAAK,CACf,iEAAiE,EACjE,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,CACvB,CAAC;YACH,CAAC;SACD;KACD;CACD,CAAC,CAAC"}
package/dist/pool.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Pool } from "pg";
2
+ /**
3
+ * A `pg` Pool for Better Auth's direct database connection, with optional
4
+ * SSL CA verification (equivalent to `sslmode=verify-full`). Keep `sslmode` out
5
+ * of the connection string — `pg` discards the `ssl` object when the URL
6
+ * carries SSL settings.
7
+ */
8
+ export declare const createAuthPool: (config: {
9
+ connectionString: string;
10
+ /** PEM CA cert; when set, the server cert + hostname are verified. */
11
+ caCert?: string;
12
+ }) => Pool;
13
+ //# sourceMappingURL=pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAE1B;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,QAAQ;IACtC,gBAAgB,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB,KAAG,IAMD,CAAC"}
package/dist/pool.js ADDED
@@ -0,0 +1,14 @@
1
+ import { Pool } from "pg";
2
+ /**
3
+ * A `pg` Pool for Better Auth's direct database connection, with optional
4
+ * SSL CA verification (equivalent to `sslmode=verify-full`). Keep `sslmode` out
5
+ * of the connection string — `pg` discards the `ssl` object when the URL
6
+ * carries SSL settings.
7
+ */
8
+ export const createAuthPool = (config) => new Pool({
9
+ connectionString: config.connectionString,
10
+ ssl: config.caCert
11
+ ? { ca: config.caCert, rejectUnauthorized: true }
12
+ : undefined,
13
+ });
14
+ //# sourceMappingURL=pool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.js","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAE1B;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,MAI9B,EAAQ,EAAE,CACV,IAAI,IAAI,CAAC;IACR,gBAAgB,EAAE,MAAM,CAAC,gBAAgB;IACzC,GAAG,EAAE,MAAM,CAAC,MAAM;QACjB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE;QACjD,CAAC,CAAC,SAAS;CACZ,CAAC,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { type SupabaseClient } from "@supabase/supabase-js";
2
+ /**
3
+ * The RLS-aware Supabase data client — the single chokepoint that keeps Row
4
+ * Level Security working after the move off Supabase Auth.
5
+ *
6
+ * Instead of `supabase.auth`, it pulls the Better Auth session JWT (via the
7
+ * injected `getToken`) and hands it to supabase-js's v2 `accessToken` callback.
8
+ * PostgREST then sets `request.jwt.claims`, so `auth.uid()` (and every existing
9
+ * policy) keeps returning the right user. With no session, `getToken` yields
10
+ * `null` and the request runs as `anon` — RLS is still enforced, never bypassed.
11
+ *
12
+ * Sites should import THIS rather than constructing supabase-js directly, so the
13
+ * token bridge can't be forgotten. (Enforce with a Biome/GritQL rule banning raw
14
+ * `createClient` for data — see docs/better-auth-migration.md.)
15
+ *
16
+ * Wire `getToken` to the site's own Better Auth instance, e.g.:
17
+ * getToken: async () => (await auth.api.getToken({ headers }))?.token ?? null
18
+ */
19
+ export interface ServerSupabaseConfig {
20
+ /** Returns the current request's Better Auth JWT, or null when signed out. */
21
+ getToken: () => Promise<string | null>;
22
+ supabaseUrl: string;
23
+ supabaseAnonKey: string;
24
+ }
25
+ export declare const createServerSupabase: (config: ServerSupabaseConfig) => SupabaseClient;
26
+ //# sourceMappingURL=supabase.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../src/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE1E;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,oBAAoB;IACpC,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,oBAAoB,GAAI,QAAQ,oBAAoB,KAAG,cAMjE,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ export const createServerSupabase = (config) => createClient(config.supabaseUrl, config.supabaseAnonKey, {
3
+ accessToken: async () => {
4
+ const token = await config.getToken();
5
+ return token ?? "";
6
+ },
7
+ });
8
+ //# sourceMappingURL=supabase.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supabase.js","sourceRoot":"","sources":["../src/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,uBAAuB,CAAC;AA2B1E,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,MAA4B,EAAkB,EAAE,CACpF,YAAY,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,eAAe,EAAE;IACxD,WAAW,EAAE,KAAK,IAAI,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,QAAQ,EAAE,CAAC;QACtC,OAAO,KAAK,IAAI,EAAE,CAAC;IACpB,CAAC;CACD,CAAC,CAAC"}
@@ -0,0 +1,102 @@
1
+ -- @ingram-tech/nk-auth — Better Auth schema for Supabase, hardened for RLS.
2
+ --
3
+ -- This mirrors Better Auth's core tables (user / session / account /
4
+ -- verification), the `jwt` plugin's `jwks` table, and the `passkey` plugin's
5
+ -- `passkey` table, for the version of `better-auth` this package depends on.
6
+ -- Reconcile against the exact pinned version with:
7
+ -- npx @better-auth/cli generate
8
+ -- and re-run after upgrading better-auth.
9
+ --
10
+ -- TWO hardening steps the generator does NOT produce, both required (see
11
+ -- docs/better-auth-migration.md):
12
+ -- 1. New users default to a UUID id, because Supabase's auth.uid() casts the
13
+ -- JWT `sub` to `uuid`. A non-UUID id silently breaks RLS for new signups.
14
+ -- 2. Deny-all RLS on every Better Auth table. They live in `public` and are
15
+ -- exposed by PostgREST; Better Auth itself reaches them through its own
16
+ -- privileged DATABASE_URL connection, which bypasses RLS, so denying anon /
17
+ -- authenticated access here costs nothing and closes the leak.
18
+
19
+ create extension if not exists "pgcrypto" with schema "extensions";
20
+
21
+ create table if not exists "public"."user" (
22
+ "id" text primary key default gen_random_uuid()::text, -- hardening (1)
23
+ "name" text not null,
24
+ "email" text not null unique,
25
+ "emailVerified" boolean not null default false,
26
+ "image" text,
27
+ "createdAt" timestamptz not null default now(),
28
+ "updatedAt" timestamptz not null default now()
29
+ );
30
+
31
+ create table if not exists "public"."session" (
32
+ "id" text primary key,
33
+ "expiresAt" timestamptz not null,
34
+ "token" text not null unique,
35
+ "ipAddress" text,
36
+ "userAgent" text,
37
+ "userId" text not null references "public"."user" ("id") on delete cascade,
38
+ "createdAt" timestamptz not null default now(),
39
+ "updatedAt" timestamptz not null default now()
40
+ );
41
+
42
+ create table if not exists "public"."account" (
43
+ "id" text primary key,
44
+ "accountId" text not null,
45
+ "providerId" text not null,
46
+ "userId" text not null references "public"."user" ("id") on delete cascade,
47
+ "accessToken" text,
48
+ "refreshToken" text,
49
+ "idToken" text,
50
+ "accessTokenExpiresAt" timestamptz,
51
+ "refreshTokenExpiresAt" timestamptz,
52
+ "scope" text,
53
+ "password" text,
54
+ "createdAt" timestamptz not null default now(),
55
+ "updatedAt" timestamptz not null default now()
56
+ );
57
+
58
+ create table if not exists "public"."verification" (
59
+ "id" text primary key,
60
+ "identifier" text not null,
61
+ "value" text not null,
62
+ "expiresAt" timestamptz not null,
63
+ "createdAt" timestamptz not null default now(),
64
+ "updatedAt" timestamptz not null default now()
65
+ );
66
+
67
+ -- `jwt` plugin: holds the asymmetric keypair used to sign the RLS bridge token.
68
+ create table if not exists "public"."jwks" (
69
+ "id" text primary key,
70
+ "publicKey" text not null,
71
+ "privateKey" text not null,
72
+ "createdAt" timestamptz not null default now()
73
+ );
74
+
75
+ -- `passkey` plugin.
76
+ create table if not exists "public"."passkey" (
77
+ "id" text primary key,
78
+ "name" text,
79
+ "publicKey" text not null,
80
+ "userId" text not null references "public"."user" ("id") on delete cascade,
81
+ "credentialID" text not null,
82
+ "counter" integer not null,
83
+ "deviceType" text not null,
84
+ "backedUp" boolean not null,
85
+ "transports" text,
86
+ "aaguid" text,
87
+ "createdAt" timestamptz default now()
88
+ );
89
+
90
+ create index if not exists "idx_session_userId" on "public"."session" ("userId");
91
+ create index if not exists "idx_account_userId" on "public"."account" ("userId");
92
+ create index if not exists "idx_passkey_userId" on "public"."passkey" ("userId");
93
+ create index if not exists "idx_verification_identifier" on "public"."verification" ("identifier");
94
+
95
+ -- Hardening (2): deny-all RLS. No policies = no anon/authenticated access.
96
+ -- Better Auth's privileged connection bypasses RLS, so auth still works.
97
+ alter table "public"."user" enable row level security;
98
+ alter table "public"."session" enable row level security;
99
+ alter table "public"."account" enable row level security;
100
+ alter table "public"."verification" enable row level security;
101
+ alter table "public"."jwks" enable row level security;
102
+ alter table "public"."passkey" enable row level security;
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@ingram-tech/nk-auth",
3
+ "version": "0.2.0",
4
+ "description": "The Ingram Better Auth foundation: composable presets (org, dual-shape JWT, Supabase RLS bridge, active-org hooks, pg pool) for Next.js sites.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ingram-technologies/nextkit.git",
10
+ "directory": "packages/nk-auth"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "migrations"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./jwt": {
25
+ "types": "./dist/jwt.d.ts",
26
+ "import": "./dist/jwt.js"
27
+ },
28
+ "./organization": {
29
+ "types": "./dist/organization.d.ts",
30
+ "import": "./dist/organization.js"
31
+ },
32
+ "./pool": {
33
+ "types": "./dist/pool.d.ts",
34
+ "import": "./dist/pool.js"
35
+ },
36
+ "./client": {
37
+ "types": "./dist/client.d.ts",
38
+ "import": "./dist/client.js"
39
+ },
40
+ "./migrations/*": "./migrations/*"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "type-check": "tsc -p tsconfig.json --noEmit",
45
+ "test": "vitest run"
46
+ },
47
+ "dependencies": {
48
+ "bcrypt": "^5.1.1",
49
+ "jose": "^6.0.0",
50
+ "zod": "^4.0.0"
51
+ },
52
+ "peerDependencies": {
53
+ "@better-auth/passkey": "^1.6.0",
54
+ "@supabase/supabase-js": ">=2.0.0",
55
+ "better-auth": "^1.6.0",
56
+ "pg": "^8.13.0",
57
+ "react": "^18.0.0 || ^19.0.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@better-auth/passkey": {
61
+ "optional": true
62
+ },
63
+ "@supabase/supabase-js": {
64
+ "optional": true
65
+ },
66
+ "pg": {
67
+ "optional": true
68
+ },
69
+ "react": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@better-auth/passkey": "^1.6.0",
75
+ "@ingram-tech/typescript-config": "0.1.0",
76
+ "@supabase/supabase-js": "^2.45.0",
77
+ "@types/bcrypt": "^5.0.2",
78
+ "@types/node": "^20.0.0",
79
+ "@types/pg": "^8.11.0",
80
+ "@types/react": "^19.0.0",
81
+ "better-auth": "^1.6.0",
82
+ "pg": "^8.13.0",
83
+ "react": "^19.0.0",
84
+ "typescript": "^6.0.3",
85
+ "vitest": "^4.1.6"
86
+ }
87
+ }