@helloleo/plugins 0.2.0-beta.6 → 0.2.0-beta.9

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/dist/index.js CHANGED
@@ -24944,6 +24944,7 @@ var scaffold = tool({
24944
24944
  "- Always fetch data with react-query (useQuery and useMutation).",
24945
24945
  "",
24946
24946
  "Next: read src/routes/index.tsx and continue with the user's request.",
24947
+ "For authentication (password and/or OAuth), call setup_authentication.",
24947
24948
  "For NetSuite projects, call setup_netsuite."
24948
24949
  ].join(`
24949
24950
  `);
@@ -24954,631 +24955,901 @@ var ScaffoldPlugin = async () => {
24954
24955
  tool: { scaffold }
24955
24956
  };
24956
24957
  };
24957
- // src/tools/setup-netsuite.ts
24958
- import { mkdir as mkdir2 } from "fs/promises";
24959
- import path4 from "path";
24960
- function netsuiteTs() {
24961
- return `/*
24962
- * NetSuite OAuth client helper (bearer-token-in-fragment).
24963
- *
24964
- * Two NetSuite Integration Records back this:
24965
- * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
24966
- * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
24967
- * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
24968
- * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
24969
- * the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
24970
- * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
24971
- * third-party cookies in the HelloLeo preview iframe.
24972
- *
24973
- * Flow:
24974
- * 1. User clicks "Sign in with NetSuite".
24975
- * 2. Browser navigates to the Worker /auth/netsuite/start.
24976
- * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
24977
- * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
24978
- * 5. Worker exchanges the code, mints a session id, then redirects to
24979
- * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
24980
- * 6. This module reads the fragment on load, stashes the sid in
24981
- * sessionStorage, and sends it as Authorization: Bearer on every
24982
- * Worker call.
24983
- *
24984
- * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
24985
- * authoritative auth state -- the only acceptable check is /api/me on the
24986
- * Worker. sessionStorage here is just a cache of the bearer token.
24987
- */
24958
+ // src/tools/setup-authentication/index.ts
24959
+ import path6 from "path";
24988
24960
 
24989
- export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
24990
- const TOKEN_KEY = 'nssid';
24961
+ // src/tools/setup-authentication/auth-files.ts
24962
+ function authLibTs(providers) {
24963
+ const hasPassword = providers.includes("password");
24964
+ const oauth = providers.filter((p) => p !== "password");
24965
+ const envFields = [
24966
+ "BETTER_AUTH_SECRET?: string",
24967
+ "HELLOLEO_URL?: string",
24968
+ ...oauth.flatMap((p) => {
24969
+ const prefix = p.toUpperCase();
24970
+ return [
24971
+ `${prefix}_CLIENT_ID?: string`,
24972
+ `${prefix}_CLIENT_SECRET?: string`
24973
+ ];
24974
+ })
24975
+ ].join(`
24976
+ `);
24977
+ const socialProvidersBlock = oauth.length ? `
24978
+ socialProviders: {
24979
+ ${oauth.map((p) => {
24980
+ const prefix = p.toUpperCase();
24981
+ return ` ${p}: { clientId: requireEnv(e.${prefix}_CLIENT_ID, '${prefix}_CLIENT_ID'), clientSecret: requireEnv(e.${prefix}_CLIENT_SECRET, '${prefix}_CLIENT_SECRET') },`;
24982
+ }).join(`
24983
+ `)}
24984
+ },` : "";
24985
+ const requireEnvFn = oauth.length ? `
24986
+ function requireEnv(value: string | undefined, name: string): string {
24987
+ if (!value) {
24988
+ throw new Error(\`\${name} is not set. Add it to .env (dev) or run \\\`wrangler secret put \${name}\\\` (prod).\`)
24989
+ }
24990
+ return value
24991
+ }
24992
+ ` : "";
24993
+ return `import { betterAuth } from 'better-auth'
24994
+ import { drizzleAdapter } from '@better-auth/drizzle-adapter'
24995
+ import { tanstackStartCookies } from 'better-auth/tanstack-start'
24996
+ import { db } from '#/db'
24997
+ import * as schema from '#/db/schema'
24998
+ import { env } from '@helloleo/runtime'
24991
24999
 
24992
- // Capture-and-strip the #nssid=... fragment as early as possible so URLs
24993
- // shown to the user stay clean. Runs once at module load.
24994
- (function bootstrapTokenFromHash() {
24995
- if (typeof window === 'undefined') return;
24996
- const hash = window.location.hash.replace(/^#/, '');
24997
- if (!hash) return;
24998
- const params = new URLSearchParams(hash);
24999
- const sid = params.get(TOKEN_KEY);
25000
- if (sid) {
25001
- try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
25002
- params.delete(TOKEN_KEY);
25003
- const remaining = params.toString();
25004
- const newHash = remaining ? '#' + remaining : '';
25005
- window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
25000
+ /*
25001
+ * buildAuth() only runs from INSIDE a request (see getAuth() below) --
25002
+ * never call it at module scope. \`db\` is a lazy Proxy over the D1 binding
25003
+ * and env.* Worker secrets don't exist until a request is in flight;
25004
+ * touching either while workerd boots the isolate throws.
25005
+ */
25006
+ ${requireEnvFn}
25007
+ function buildAuth() {
25008
+ const e = env as {
25009
+ ${envFields}
25006
25010
  }
25007
- })();
25008
25011
 
25009
- export function getSessionToken(): string | null {
25010
- try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
25012
+ return betterAuth({
25013
+ secret: e.BETTER_AUTH_SECRET,
25014
+ baseURL: e.HELLOLEO_URL || 'http://localhost:3000',
25015
+ database: drizzleAdapter(db, { provider: 'sqlite', schema }),
25016
+ emailAndPassword: {
25017
+ enabled: ${hasPassword},
25018
+ },${socialProvidersBlock}
25019
+ plugins: [tanstackStartCookies()],
25020
+ })
25011
25021
  }
25012
25022
 
25013
- export function signInWithNetSuite(returnTo: string = window.location.href) {
25014
- if (!WORKER_URL) {
25015
- throw new Error(
25016
- 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
25017
- );
25018
- }
25019
- const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
25020
- url.searchParams.set('return_to', returnTo);
25021
- // The app usually renders inside the HelloLeo preview iframe. NetSuite's
25022
- // OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
25023
- // Worker (which 302s to NetSuite) ends up loading a page the browser
25024
- // refuses to render -- looks like "nothing happens" to the user. Navigate
25025
- // the top frame instead so the OAuth flow runs at the top level and the
25026
- // callback redirect lands on the same top frame, which then reads the
25027
- // #nssid= fragment. Use \`location.href =\` (NOT \`location.assign()\`):
25028
- // reading the \`.assign\` method off a cross-origin \`window.top.location\`
25029
- // throws a SecurityError, which would drop us into the same-frame fallback
25030
- // and load NetSuite inside the iframe (X-Frame-Options: DENY -> "refused to
25031
- // connect"). Assigning \`.href\` is a navigation, allowed cross-origin.
25032
- try {
25033
- if (window.top && window.top !== window) {
25034
- window.top.location.href = url.toString();
25035
- return;
25036
- }
25037
- } catch {
25038
- /* top window not accessible \u2014 fall through to same-frame navigation */
25039
- }
25040
- window.location.href = url.toString();
25041
- }
25023
+ let instance: ReturnType<typeof buildAuth> | undefined
25042
25024
 
25043
- export interface NetSuiteSession {
25044
- authenticated: boolean;
25045
- account_id?: string;
25046
- user_id?: string;
25047
- email?: string;
25025
+ export function getAuth() {
25026
+ if (!instance) instance = buildAuth()
25027
+ return instance
25048
25028
  }
25049
-
25050
- export async function getNetSuiteSession(): Promise<NetSuiteSession> {
25051
- if (!WORKER_URL) return { authenticated: false };
25052
- const sid = getSessionToken();
25053
- if (!sid) return { authenticated: false };
25054
- const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
25055
- headers: { Authorization: 'Bearer ' + sid },
25056
- });
25057
- if (!res.ok) return { authenticated: false };
25058
- const data = await res.json();
25059
- return { authenticated: Boolean(data?.account_id), ...data };
25029
+ `;
25060
25030
  }
25031
+ function authClientTs() {
25032
+ return `import { createAuthClient } from 'better-auth/react'
25061
25033
 
25062
- export async function signOutNetSuite(): Promise<void> {
25063
- if (!WORKER_URL) return;
25064
- const sid = getSessionToken();
25065
- if (sid) {
25066
- try {
25067
- await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
25068
- method: 'POST',
25069
- headers: { Authorization: 'Bearer ' + sid },
25070
- });
25071
- } catch {}
25072
- }
25073
- try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
25074
- window.location.reload();
25034
+ export const authClient = createAuthClient()
25035
+ `;
25075
25036
  }
25037
+ function authServerTs() {
25038
+ return `import { createServerFn } from '@tanstack/react-start'
25039
+ import { getRequestHeaders } from '@tanstack/react-start/server'
25040
+ import { getAuth } from '#/lib/auth'
25076
25041
 
25077
- /**
25078
- * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
25079
- * Always go through this instead of bare fetch -- it injects the bearer
25080
- * token and routes through the Worker so NetSuite tokens stay server-side.
25081
- */
25082
- export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
25083
- if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
25084
- const url = pathOrUrl.startsWith('http')
25085
- ? pathOrUrl
25086
- : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
25087
- const sid = getSessionToken();
25088
- const headers = new Headers(init.headers ?? {});
25089
- if (sid) headers.set('Authorization', 'Bearer ' + sid);
25090
- return fetch(url, { ...init, headers });
25091
- }
25042
+ export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
25043
+ const headers = getRequestHeaders()
25044
+ return getAuth().api.getSession({ headers })
25045
+ })
25046
+
25047
+ export const ensureSession = createServerFn({ method: 'GET' }).handler(async () => {
25048
+ const headers = getRequestHeaders()
25049
+ const session = await getAuth().api.getSession({ headers })
25050
+ if (!session) throw new Error('Unauthorized')
25051
+ return session
25052
+ })
25092
25053
  `;
25093
25054
  }
25094
- function signInButtonTsx() {
25095
- return `import { signInWithNetSuite } from '../../lib/netsuite';
25055
+ function authRouteTs() {
25056
+ return `import { createFileRoute } from '@tanstack/react-router'
25057
+ import { getAuth } from '#/lib/auth'
25096
25058
 
25097
- export function SignInWithNetSuite() {
25098
- return (
25099
- <button
25100
- onClick={() => signInWithNetSuite()}
25101
- className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
25102
- >
25103
- Sign in with NetSuite
25104
- </button>
25105
- );
25106
- }
25059
+ export const Route = createFileRoute('/api/auth/$')({
25060
+ server: {
25061
+ handlers: {
25062
+ GET: ({ request }: { request: Request }) => getAuth().handler(request),
25063
+ POST: ({ request }: { request: Request }) => getAuth().handler(request),
25064
+ },
25065
+ },
25066
+ })
25107
25067
  `;
25108
25068
  }
25109
- function netsuiteLoginPageTsx() {
25110
- return `import { SignInWithNetSuite } from './SignInWithNetSuite';
25111
25069
 
25112
- /**
25113
- * Default unauthenticated landing page. Replaces any password gate.
25114
- * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
25115
- * only call-to-action -- it points to the Worker's /auth/netsuite/start.
25116
- */
25117
- export function LoginPage() {
25118
- return (
25119
- <div className="min-h-screen bg-background text-foreground flex">
25120
- <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
25121
- <div className="text-xl font-semibold">NetSuite App</div>
25122
- <div>
25123
- <h1 className="text-4xl font-bold leading-tight">
25124
- Connect your NetSuite account
25125
- </h1>
25126
- <p className="mt-4 text-primary-foreground/80 max-w-md">
25127
- Sign in with your NetSuite credentials to access your data securely.
25128
- Tokens stay server-side on HelloLeo Cloud -- they never reach the
25129
- browser.
25130
- </p>
25131
- </div>
25132
- <div className="text-xs text-primary-foreground/60">
25133
- Powered by HelloLeo
25134
- </div>
25135
- </div>
25136
- <div className="flex-1 flex items-center justify-center px-6 py-12">
25137
- <div className="w-full max-w-sm space-y-6">
25138
- <div>
25139
- <h2 className="text-2xl font-semibold">Welcome</h2>
25140
- <p className="mt-2 text-sm text-muted-foreground">
25141
- Use the button below to sign in. You'll be redirected to NetSuite
25142
- to authorise access, then back here.
25143
- </p>
25144
- </div>
25145
- <SignInWithNetSuite />
25146
- </div>
25147
- </div>
25148
- </div>
25149
- );
25150
- }
25070
+ // src/tools/setup-authentication/constants.ts
25071
+ var PROVIDERS = [
25072
+ "password",
25073
+ "google",
25074
+ "github",
25075
+ "discord",
25076
+ "microsoft"
25077
+ ];
25078
+ var PROVIDER_LABELS = {
25079
+ google: "Google",
25080
+ github: "GitHub",
25081
+ discord: "Discord",
25082
+ microsoft: "Microsoft"
25083
+ };
25084
+ var BETTER_AUTH_VERSION = "^1.6.23";
25085
+
25086
+ // src/tools/setup-authentication/db-schema.ts
25087
+ import { readFile } from "fs/promises";
25088
+ import path4 from "path";
25089
+ var SCHEMA_MARKER = "sqliteTable('user'";
25090
+ function authTablesBlock() {
25091
+ return `
25092
+ export const user = sqliteTable('user', {
25093
+ id: text('id').primaryKey(),
25094
+ name: text('name').notNull(),
25095
+ email: text('email').notNull().unique(),
25096
+ emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
25097
+ image: text('image'),
25098
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
25099
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
25100
+ .notNull(),
25101
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
25102
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
25103
+ .$onUpdate(() => new Date())
25104
+ .notNull(),
25105
+ })
25106
+
25107
+ export const session = sqliteTable('session', {
25108
+ id: text('id').primaryKey(),
25109
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
25110
+ token: text('token').notNull().unique(),
25111
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
25112
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
25113
+ .notNull(),
25114
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
25115
+ .$onUpdate(() => new Date())
25116
+ .notNull(),
25117
+ ipAddress: text('ip_address'),
25118
+ userAgent: text('user_agent'),
25119
+ userId: text('user_id')
25120
+ .notNull()
25121
+ .references(() => user.id, { onDelete: 'cascade' }),
25122
+ })
25123
+
25124
+ export const account = sqliteTable('account', {
25125
+ id: text('id').primaryKey(),
25126
+ accountId: text('account_id').notNull(),
25127
+ providerId: text('provider_id').notNull(),
25128
+ userId: text('user_id')
25129
+ .notNull()
25130
+ .references(() => user.id, { onDelete: 'cascade' }),
25131
+ accessToken: text('access_token'),
25132
+ refreshToken: text('refresh_token'),
25133
+ idToken: text('id_token'),
25134
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
25135
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
25136
+ scope: text('scope'),
25137
+ password: text('password'),
25138
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
25139
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
25140
+ .notNull(),
25141
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
25142
+ .$onUpdate(() => new Date())
25143
+ .notNull(),
25144
+ })
25145
+
25146
+ export const verification = sqliteTable('verification', {
25147
+ id: text('id').primaryKey(),
25148
+ identifier: text('identifier').notNull(),
25149
+ value: text('value').notNull(),
25150
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
25151
+ createdAt: integer('created_at', { mode: 'timestamp_ms' })
25152
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
25153
+ .notNull(),
25154
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
25155
+ .default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
25156
+ .$onUpdate(() => new Date())
25157
+ .notNull(),
25158
+ })
25151
25159
  `;
25152
25160
  }
25153
- function authTsx() {
25154
- return `import * as React from 'react';
25155
- import {
25156
- getNetSuiteSession,
25157
- signInWithNetSuite,
25158
- signOutNetSuite,
25159
- type NetSuiteSession,
25160
- } from './netsuite';
25161
+ async function appendAuthTables(dir) {
25162
+ const schemaPath = path4.join(dir, "src", "db", "schema.ts");
25163
+ const current = await readFile(schemaPath, "utf8").catch(() => null);
25164
+ if (current === null) {
25165
+ throw new Error("src/db/schema.ts not found -- run scaffold first.");
25166
+ }
25167
+ if (current.includes(SCHEMA_MARKER))
25168
+ return false;
25169
+ await Bun.write(schemaPath, `${current.trimEnd()}
25170
+ ${authTablesBlock()}`);
25171
+ return true;
25172
+ }
25161
25173
 
25162
- /**
25163
- * NetSuite auth context. Holds the current session (resolved from the Worker's
25164
- * /api/me via getNetSuiteSession) and exposes sign-in / sign-out. The only
25165
- * authoritative auth check is /api/me -- never trust sessionStorage directly.
25166
- */
25167
- export interface NetSuiteAuthContextValue {
25168
- pending: boolean;
25169
- isAuthenticated: boolean;
25170
- session: NetSuiteSession | null;
25171
- signIn: () => void;
25172
- signOut: () => Promise<void>;
25173
- refresh: () => Promise<void>;
25174
+ // src/tools/setup-authentication/package-json.ts
25175
+ import { readFile as readFile2 } from "fs/promises";
25176
+ import path5 from "path";
25177
+ async function addAuthDependencies(dir) {
25178
+ const pkgPath = path5.join(dir, "package.json");
25179
+ const pkg = JSON.parse(await readFile2(pkgPath, "utf8"));
25180
+ pkg.dependencies ??= {};
25181
+ if (pkg.dependencies["better-auth"])
25182
+ return false;
25183
+ pkg.dependencies["better-auth"] = BETTER_AUTH_VERSION;
25184
+ pkg.dependencies["@better-auth/drizzle-adapter"] = BETTER_AUTH_VERSION;
25185
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}
25186
+ `);
25187
+ return true;
25174
25188
  }
25175
25189
 
25176
- const NetSuiteAuthContext =
25177
- React.createContext<NetSuiteAuthContextValue | null>(null);
25190
+ // src/tools/setup-authentication/pages.ts
25191
+ function socialButtonsJsx(oauth) {
25192
+ return oauth.map((p) => ` <Button
25193
+ type="button"
25194
+ variant="outline"
25195
+ className="w-full"
25196
+ onClick={() => authClient.signIn.social({ provider: '${p}', callbackURL: '/' })}
25197
+ >
25198
+ Continue with ${PROVIDER_LABELS[p]}
25199
+ </Button>`).join(`
25200
+ `);
25201
+ }
25202
+ function authedLayoutTsx() {
25203
+ return `import { createFileRoute, redirect } from '@tanstack/react-router'
25204
+ import { getSession } from '#/lib/auth-server'
25178
25205
 
25179
- export function NetSuiteAuthProvider({
25180
- children,
25181
- }: {
25182
- children: React.ReactNode;
25183
- }) {
25184
- const [session, setSession] = React.useState<NetSuiteSession | null>(null);
25185
- const [pending, setPending] = React.useState(true);
25206
+ // Pathless guard -- no URL segment of its own. Put protected pages under
25207
+ // src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard) and they
25208
+ // inherit this beforeLoad. See src/routes/README.md, "Authenticated routes".
25209
+ export const Route = createFileRoute('/_authed')({
25210
+ beforeLoad: async () => {
25211
+ const session = await getSession()
25212
+ if (!session) throw redirect({ to: '/login' })
25213
+ return { user: session.user }
25214
+ },
25215
+ })
25216
+ `;
25217
+ }
25218
+ function loginPageTsx(hasPassword, oauth) {
25219
+ const social = oauth.length ? `
25220
+ ${socialButtonsJsx(oauth)}
25221
+ ` : "";
25222
+ const divider = oauth.length && hasPassword ? `
25223
+ <div className="relative">
25224
+ <div className="absolute inset-0 flex items-center">
25225
+ <span className="w-full border-t" />
25226
+ </div>
25227
+ <div className="relative flex justify-center text-xs uppercase">
25228
+ <span className="bg-card px-2 text-muted-foreground">Or continue with email</span>
25229
+ </div>
25230
+ </div>
25231
+ ` : "";
25232
+ const passwordForm = hasPassword ? `
25233
+ <form onSubmit={handleSubmit} className="space-y-4">
25234
+ <div className="space-y-2">
25235
+ <Label htmlFor="email">Email</Label>
25236
+ <Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
25237
+ </div>
25238
+ <div className="space-y-2">
25239
+ <Label htmlFor="password">Password</Label>
25240
+ <Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
25241
+ </div>
25242
+ {error && <p className="text-sm text-destructive">{error}</p>}
25243
+ <Button type="submit" className="w-full" disabled={loading}>
25244
+ {loading ? 'Logging in\u2026' : 'Log in'}
25245
+ </Button>
25246
+ <p className="text-sm text-center text-muted-foreground">
25247
+ No account? <Link to="/signup" className="underline">Sign up</Link>
25248
+ </p>
25249
+ </form>
25250
+ ` : "";
25251
+ const stateAndHandler = hasPassword ? `
25252
+ const router = useRouter()
25253
+ const [email, setEmail] = useState('')
25254
+ const [password, setPassword] = useState('')
25255
+ const [error, setError] = useState('')
25256
+ const [loading, setLoading] = useState(false)
25186
25257
 
25187
- const refresh = React.useCallback(async () => {
25188
- setPending(true);
25189
- try {
25190
- setSession(await getNetSuiteSession());
25191
- } finally {
25192
- setPending(false);
25258
+ async function handleSubmit(e: React.FormEvent) {
25259
+ e.preventDefault()
25260
+ setError('')
25261
+ setLoading(true)
25262
+ const { error } = await authClient.signIn.email({ email, password })
25263
+ setLoading(false)
25264
+ if (error) {
25265
+ setError(error.message ?? 'Something went wrong.')
25266
+ return
25193
25267
  }
25194
- }, []);
25195
-
25196
- React.useEffect(() => {
25197
- refresh();
25198
- }, [refresh]);
25268
+ router.navigate({ to: '/' })
25269
+ }
25270
+ ` : "";
25271
+ const imports = [
25272
+ hasPassword ? "import { createFileRoute, Link, useRouter } from '@tanstack/react-router'" : "import { createFileRoute } from '@tanstack/react-router'",
25273
+ hasPassword ? "import { useState } from 'react'" : "",
25274
+ "import { authClient } from '#/lib/auth-client'",
25275
+ "import { Button } from '#/components/ui/button'",
25276
+ hasPassword ? "import { Input } from '#/components/ui/input'" : "",
25277
+ hasPassword ? "import { Label } from '#/components/ui/label'" : "",
25278
+ "import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'"
25279
+ ].filter(Boolean).join(`
25280
+ `);
25281
+ return `${imports}
25199
25282
 
25200
- const value: NetSuiteAuthContextValue = {
25201
- pending,
25202
- isAuthenticated: Boolean(session?.authenticated),
25203
- session,
25204
- signIn: () => signInWithNetSuite(),
25205
- signOut: signOutNetSuite,
25206
- refresh,
25207
- };
25283
+ export const Route = createFileRoute('/login')({
25284
+ component: LoginPage,
25285
+ })
25208
25286
 
25287
+ function LoginPage() {${stateAndHandler}
25209
25288
  return (
25210
- <NetSuiteAuthContext.Provider value={value}>
25211
- {children}
25212
- </NetSuiteAuthContext.Provider>
25213
- );
25289
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
25290
+ <Card className="w-full max-w-sm">
25291
+ <CardHeader>
25292
+ <CardTitle>Log in</CardTitle>
25293
+ </CardHeader>
25294
+ <CardContent className="space-y-4">${social}${divider}${passwordForm}
25295
+ </CardContent>
25296
+ </Card>
25297
+ </div>
25298
+ )
25299
+ }
25300
+ `;
25214
25301
  }
25302
+ function signupPageTsx() {
25303
+ return `import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
25304
+ import { useState } from 'react'
25305
+ import { authClient } from '#/lib/auth-client'
25306
+ import { Button } from '#/components/ui/button'
25307
+ import { Input } from '#/components/ui/input'
25308
+ import { Label } from '#/components/ui/label'
25309
+ import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
25215
25310
 
25216
- export function useNetSuiteAuth(): NetSuiteAuthContextValue {
25217
- const ctx = React.useContext(NetSuiteAuthContext);
25218
- if (!ctx) {
25219
- throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>');
25311
+ export const Route = createFileRoute('/signup')({
25312
+ component: SignupPage,
25313
+ })
25314
+
25315
+ function SignupPage() {
25316
+ const router = useRouter()
25317
+ const [name, setName] = useState('')
25318
+ const [email, setEmail] = useState('')
25319
+ const [password, setPassword] = useState('')
25320
+ const [error, setError] = useState('')
25321
+ const [loading, setLoading] = useState(false)
25322
+
25323
+ async function handleSubmit(e: React.FormEvent) {
25324
+ e.preventDefault()
25325
+ setError('')
25326
+ setLoading(true)
25327
+ const { error } = await authClient.signUp.email({ name, email, password })
25328
+ setLoading(false)
25329
+ if (error) {
25330
+ setError(error.message ?? 'Something went wrong.')
25331
+ return
25332
+ }
25333
+ router.navigate({ to: '/' })
25220
25334
  }
25221
- return ctx;
25335
+
25336
+ return (
25337
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
25338
+ <Card className="w-full max-w-sm">
25339
+ <CardHeader>
25340
+ <CardTitle>Sign up</CardTitle>
25341
+ </CardHeader>
25342
+ <CardContent>
25343
+ <form onSubmit={handleSubmit} className="space-y-4">
25344
+ <div className="space-y-2">
25345
+ <Label htmlFor="name">Name</Label>
25346
+ <Input id="name" type="text" value={name} onChange={e => setName(e.target.value)} required autoComplete="name" />
25347
+ </div>
25348
+ <div className="space-y-2">
25349
+ <Label htmlFor="email">Email</Label>
25350
+ <Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
25351
+ </div>
25352
+ <div className="space-y-2">
25353
+ <Label htmlFor="password">Password</Label>
25354
+ <Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
25355
+ </div>
25356
+ {error && <p className="text-sm text-destructive">{error}</p>}
25357
+ <Button type="submit" className="w-full" disabled={loading}>
25358
+ {loading ? 'Signing up\u2026' : 'Sign up'}
25359
+ </Button>
25360
+ <p className="text-sm text-center text-muted-foreground">
25361
+ Already have an account? <Link to="/login" className="underline">Log in</Link>
25362
+ </p>
25363
+ </form>
25364
+ </CardContent>
25365
+ </Card>
25366
+ </div>
25367
+ )
25222
25368
  }
25223
25369
  `;
25224
25370
  }
25225
- function authLayoutTsx() {
25226
- return `import type { ReactNode } from 'react';
25227
- import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth';
25228
- import { LoginPage } from './LoginPage';
25371
+ function dashboardPageTsx() {
25372
+ return `import { createFileRoute, useRouter } from '@tanstack/react-router'
25373
+ import { authClient } from '#/lib/auth-client'
25374
+ import { Button } from '#/components/ui/button'
25229
25375
 
25230
- /**
25231
- * NetSuite auth layout. This is the ONLY wiring you need to add by hand --
25232
- * wrap the root route's <Outlet /> with it:
25233
- *
25234
- * // src/routes/__root.tsx
25235
- * import { AuthLayout } from '../components/auth/AuthLayout';
25236
- * ...
25237
- * function RootComponent() {
25238
- * return (
25239
- * <>
25240
- * <AuthLayout>
25241
- * <Outlet />
25242
- * </AuthLayout>
25243
- * { ...devtools... }
25244
- * </>
25245
- * );
25246
- * }
25247
- *
25248
- * It provides NetSuite auth context to the whole tree and gates rendering:
25249
- * while the session resolves it shows a spinner, unauthenticated users see
25250
- * <LoginPage />, authenticated users see your routes. Use useNetSuiteAuth()
25251
- * anywhere below it to read the session or sign out. NEVER swap this for a
25252
- * password gate -- real OAuth is the only supported login.
25253
- */
25254
- export function AuthLayout({ children }: { children: ReactNode }) {
25255
- return (
25256
- <NetSuiteAuthProvider>
25257
- <Gate>{children}</Gate>
25258
- </NetSuiteAuthProvider>
25259
- );
25260
- }
25376
+ // Example protected page -- _authed.tsx needs at least one child route or
25377
+ // it collides with index.tsx on path "/" and \`tsr generate\` fails. Replace
25378
+ // this, or add more pages alongside it under src/routes/_authed/.
25379
+ export const Route = createFileRoute('/_authed/dashboard')({
25380
+ component: DashboardPage,
25381
+ })
25261
25382
 
25262
- function Gate({ children }: { children: ReactNode }) {
25263
- const { pending, isAuthenticated } = useNetSuiteAuth();
25383
+ function DashboardPage() {
25384
+ const router = useRouter()
25385
+ const { data: session } = authClient.useSession()
25264
25386
 
25265
- if (pending) {
25266
- return (
25267
- <div className="min-h-screen flex items-center justify-center bg-background">
25268
- <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
25387
+ return (
25388
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
25389
+ <div className="text-center space-y-4">
25390
+ <p className="text-lg">Signed in as {session?.user.email}</p>
25391
+ <Button
25392
+ onClick={() =>
25393
+ authClient.signOut({
25394
+ fetchOptions: { onSuccess: () => router.navigate({ to: '/login' }) },
25395
+ })
25396
+ }
25397
+ >
25398
+ Sign out
25399
+ </Button>
25269
25400
  </div>
25270
- );
25271
- }
25272
-
25273
- if (!isAuthenticated) return <LoginPage />;
25274
-
25275
- return <>{children}</>;
25401
+ </div>
25402
+ )
25276
25403
  }
25277
25404
  `;
25278
25405
  }
25279
- var setupNetsuite = tool({
25280
- description: "Add NetSuite OAuth wiring to an existing project. Call scaffold first, " + "then this. Writes the OAuth client, auth provider and login components. " + "All files are additive, nothing is overwritten. Real OAuth is wired " + "from day one, never replace <AuthLayout> with a password placeholder.",
25281
- args: {},
25282
- async execute(_args, context) {
25406
+
25407
+ // src/tools/setup-authentication/index.ts
25408
+ var setupAuthentication = tool({
25409
+ description: "Add authentication to an existing project via better-auth. Call scaffold first, " + "then this. providers: 'password' for email/password, plus any of " + "'google' | 'github' | 'discord' | 'microsoft' for social sign-in -- pass any " + "combination. Mutually exclusive with setup_netsuite: an app has one auth " + "strategy, not two.",
25410
+ args: {
25411
+ providers: exports_external2.array(exports_external2.enum(PROVIDERS)).min(1).describe("Auth methods to enable, e.g. ['password', 'google']")
25412
+ },
25413
+ async execute({ providers }, context) {
25283
25414
  const dir = context.directory;
25284
- await mkdir2(path4.join(dir, "src", "components", "auth"), {
25285
- recursive: true
25286
- });
25287
- const nsFiles = [
25288
- [path4.join("src", "lib", "netsuite.ts"), netsuiteTs()],
25289
- [path4.join("src", "lib", "auth.tsx"), authTsx()],
25290
- [
25291
- path4.join("src", "components", "auth", "AuthLayout.tsx"),
25292
- authLayoutTsx()
25293
- ],
25415
+ const hasPassword = providers.includes("password");
25416
+ const oauth = providers.filter((p) => p !== "password");
25417
+ const files = [
25418
+ [path6.join("src", "lib", "auth.ts"), authLibTs(providers)],
25419
+ [path6.join("src", "lib", "auth-client.ts"), authClientTs()],
25420
+ [path6.join("src", "lib", "auth-server.ts"), authServerTs()],
25421
+ [path6.join("src", "routes", "api", "auth", "$.ts"), authRouteTs()],
25422
+ [path6.join("src", "routes", "_authed.tsx"), authedLayoutTsx()],
25294
25423
  [
25295
- path4.join("src", "components", "auth", "LoginPage.tsx"),
25296
- netsuiteLoginPageTsx()
25424
+ path6.join("src", "routes", "_authed", "dashboard.tsx"),
25425
+ dashboardPageTsx()
25297
25426
  ],
25298
25427
  [
25299
- path4.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
25300
- signInButtonTsx()
25428
+ path6.join("src", "routes", "login.tsx"),
25429
+ loginPageTsx(hasPassword, oauth)
25301
25430
  ]
25302
25431
  ];
25303
- for (const [filePath, content] of nsFiles) {
25304
- await Bun.write(path4.join(dir, filePath), content);
25432
+ if (hasPassword) {
25433
+ files.push([path6.join("src", "routes", "signup.tsx"), signupPageTsx()]);
25434
+ }
25435
+ for (const [filePath, content] of files) {
25436
+ await Bun.write(path6.join(dir, filePath), content);
25437
+ }
25438
+ const schemaAppended = await appendAuthTables(dir);
25439
+ const depsAdded = await addAuthDependencies(dir);
25440
+ const envLines = [
25441
+ " - dev (.env): BETTER_AUTH_SECRET=<random>",
25442
+ " - prod: `wrangler secret put BETTER_AUTH_SECRET`",
25443
+ " - HELLOLEO_URL is auto-injected by the platform (preview + production) -- nothing to set for baseURL"
25444
+ ];
25445
+ for (const p of oauth) {
25446
+ const prefix = p.toUpperCase();
25447
+ envLines.push(` - ${PROVIDER_LABELS[p]}: ${prefix}_CLIENT_ID / ${prefix}_CLIENT_SECRET (dev: .env, prod: wrangler secret put)`);
25305
25448
  }
25306
25449
  return [
25307
- `Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
25308
- " - src/lib/netsuite.ts (OAuth client: signIn, session, workerFetch)",
25309
- " - src/lib/auth.tsx (NetSuiteAuthProvider and useNetSuiteAuth)",
25310
- " - src/components/auth/AuthLayout.tsx (provider and auth gate)",
25311
- " - src/components/auth/LoginPage.tsx (unauthenticated view)",
25312
- " - src/components/auth/SignInWithNetSuite.tsx (sign-in button)",
25450
+ `Wrote authentication (better-auth, providers: ${providers.join(", ")}):`,
25451
+ " - src/lib/auth.ts (lazy betterAuth instance -- read the comment on why it's not a module-scope export)",
25452
+ " - src/lib/auth-client.ts (React client -- authClient.signIn.email/social, signUp.email, signOut)",
25453
+ " - src/lib/auth-server.ts (getSession/ensureSession server functions, for SSR route guards)",
25454
+ " - src/routes/api/auth/$.ts (catch-all handler)",
25455
+ " - src/routes/_authed.tsx (pathless guard for protected routes)",
25456
+ " - src/routes/_authed/dashboard.tsx (example protected page -- _authed needs at least one child route or it collides with index.tsx on '/')",
25457
+ ` - src/routes/login.tsx${hasPassword ? ", src/routes/signup.tsx" : ""}`,
25458
+ ` - src/db/schema.ts ${schemaAppended ? "(appended user/session/account/verification tables)" : "(already had a `user` table, left as-is)"}`,
25459
+ ` - package.json ${depsAdded ? "(added better-auth, @better-auth/drizzle-adapter)" : "(dependencies already present)"}`,
25313
25460
  "",
25314
- "Notes:",
25315
- "- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
25316
- "- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
25317
- "- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
25318
- "- The auth worker (deployed via the cloudflare-backend MCP) serves /auth/netsuite/start, /auth/netsuite/callback, /api/me and /auth/netsuite/logout. It reads env.NETSUITE_ACCOUNT_ID, _CLIENT_ID and _CLIENT_SECRET. VITE_WORKER_URL must be set (HelloLeo Cloud seeds it).",
25461
+ "Run `bun install`, then `bun run db:push` (or just `bun dev`, which runs it first) to create the new tables.",
25319
25462
  "",
25320
- "Next: in src/routes/__root.tsx, import AuthLayout and wrap <Outlet /> with it. Keep any existing providers.",
25463
+ "Required env vars/secrets:",
25464
+ ...envLines,
25321
25465
  "",
25322
- " import { AuthLayout } from '../components/auth/AuthLayout'",
25466
+ "Add more protected pages under src/routes/_authed/ (e.g. _authed/settings.tsx -> /settings); they inherit the guard.",
25323
25467
  "",
25324
- " <AuthLayout>",
25325
- " <Outlet />",
25326
- " </AuthLayout>"
25468
+ "Verify before shipping: password hashing defaults to scrypt via Node's crypto, exposed through the " + "nodejs_compat flag (already on for this template) -- confirm a real sign-up/login round-trip works in `bun dev`.",
25469
+ "",
25470
+ "Mutually exclusive with setup_netsuite -- an app has one auth strategy, not two."
25327
25471
  ].join(`
25328
25472
  `);
25329
25473
  }
25330
25474
  });
25331
- var SetupNetsuitePlugin = async () => {
25475
+ var SetupAuthenticationPlugin = async () => {
25332
25476
  return {
25333
- tool: { setup_netsuite: setupNetsuite }
25477
+ tool: { setup_authentication: setupAuthentication }
25334
25478
  };
25335
25479
  };
25336
- // src/tools/setup-password-auth.ts
25337
- import { readFile } from "fs/promises";
25338
- import path5 from "path";
25339
- var SCHEMA_MARKER = "sqliteTable('users'";
25340
- function usersTableBlock() {
25480
+ // src/tools/setup-netsuite.ts
25481
+ import { mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
25482
+ import path7 from "path";
25483
+ function schemaAppend() {
25341
25484
  return `
25342
- export const users = sqliteTable('users', {
25343
- id: integer('id').primaryKey({ autoIncrement: true }),
25344
- email: text('email').notNull().unique(),
25345
- passwordHash: text('password_hash').notNull(),
25485
+ // --- NetSuite OAuth (added by setup_netsuite) ---
25486
+ // Short-lived PKCE + return-to state between /auth/netsuite/start and its
25487
+ // callback. Single-use: the callback deletes the row after reading it.
25488
+ export const netsuiteFlows = sqliteTable('netsuite_flows', {
25489
+ flowId: text('flow_id').primaryKey(),
25490
+ codeVerifier: text('code_verifier').notNull(),
25491
+ returnTo: text('return_to').notNull(),
25346
25492
  createdAt: integer('created_at')
25347
25493
  .notNull()
25348
25494
  .default(sql\`(unixepoch())\`),
25349
25495
  })
25350
25496
 
25351
- export type User = typeof users.$inferSelect
25497
+ // Opaque session id -> NetSuite tokens. Tokens stay server-side; the browser
25498
+ // only ever holds the sid (sent as a bearer).
25499
+ export const netsuiteSessions = sqliteTable('netsuite_sessions', {
25500
+ sid: text('sid').primaryKey(),
25501
+ accountId: text('account_id').notNull(),
25502
+ accessToken: text('access_token').notNull(),
25503
+ refreshToken: text('refresh_token'),
25504
+ expiresAt: integer('expires_at'),
25505
+ userId: text('user_id'),
25506
+ email: text('email'),
25507
+ createdAt: integer('created_at')
25508
+ .notNull()
25509
+ .default(sql\`(unixepoch())\`),
25510
+ })
25352
25511
  `;
25353
25512
  }
25354
- function authLibTs() {
25355
- return `import { db } from '#/db'
25356
- import { users } from '#/db/schema'
25357
- import { env } from '@helloleo/runtime'
25358
- import { eq } from 'drizzle-orm'
25359
-
25360
- /*
25361
- * Password hashing, session JWTs, and user lookup. Used ONLY from
25362
- * src/routes/api/auth/*.ts server routes -- never imported by a client
25363
- * route file. Sessions are a stateless HMAC-signed JWT in an httpOnly
25364
- * cookie, not a server-side session store: verifying a session costs one
25365
- * signature check, no database or KV round-trip.
25513
+ function netsuiteServerTs() {
25514
+ return `/*
25515
+ * NetSuite runtime OAuth -- server side (TanStack Start server routes).
25366
25516
  *
25367
- * AUTH_JWT_SECRET must be set for signing/verifying to work:
25368
- * - dev: add \`AUTH_JWT_SECRET=<random value>\` to .env (gitignored;
25369
- * the cf-shim exposes process.env vars as-is in dev).
25370
- * - prod: \`wrangler secret put AUTH_JWT_SECRET\`.
25371
- * Generate a value with: openssl rand -hex 32
25517
+ * Runs wherever the app runs: \`vite dev\` (workerd) in the Fly preview machine
25518
+ * in dev, and the published Cloudflare Worker in prod. There is no separate
25519
+ * backend Worker -- these helpers back the /auth/netsuite/* + /api/me routes.
25520
+ *
25521
+ * Config is read from the Cloudflare env binding via @helloleo/runtime.
25522
+ * Set NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET (secret optional for a
25523
+ * PKCE public client) and optional PUBLIC_APP_URL as HelloLeo Cloud secrets
25524
+ * (prod) or dev vars (preview). They never reach the browser.
25372
25525
  */
25526
+ import { eq } from 'drizzle-orm'
25527
+ import { env as runtimeEnv } from '@helloleo/runtime'
25528
+ import { db } from '#/db'
25529
+ import { netsuiteSessions } from '#/db/schema'
25373
25530
 
25374
- const encoder = new TextEncoder()
25375
- const SESSION_COOKIE = 'session'
25376
- const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7
25531
+ interface NsEnv {
25532
+ NETSUITE_ACCOUNT_ID?: string
25533
+ NETSUITE_CLIENT_ID?: string
25534
+ NETSUITE_CLIENT_SECRET?: string
25535
+ PUBLIC_APP_URL?: string
25536
+ HELLOLEO_URL?: string
25537
+ }
25538
+ const nsEnv = runtimeEnv as unknown as NsEnv
25377
25539
 
25378
- function getAuthSecret(): string {
25379
- const secret = (env as { AUTH_JWT_SECRET?: string }).AUTH_JWT_SECRET
25380
- if (!secret) {
25540
+ // Runtime record scope. The app signs end-users in for REST/SuiteQL.
25541
+ export const SCOPE = 'rest_webservices'
25542
+ const REFRESH_LEEWAY_SECONDS = 60
25543
+
25544
+ export function requireConfig() {
25545
+ const accountId = nsEnv.NETSUITE_ACCOUNT_ID
25546
+ const clientId = nsEnv.NETSUITE_CLIENT_ID
25547
+ if (!accountId || !clientId) {
25381
25548
  throw new Error(
25382
- 'AUTH_JWT_SECRET is not set. Add it to .env (dev) or run ' +
25383
- '\`wrangler secret put AUTH_JWT_SECRET\` (prod).',
25549
+ 'NetSuite not configured: set NETSUITE_ACCOUNT_ID and NETSUITE_CLIENT_ID (and NETSUITE_CLIENT_SECRET for a confidential client) as HelloLeo Cloud secrets / dev vars.',
25384
25550
  )
25385
25551
  }
25386
- return secret
25552
+ return { accountId, clientId, clientSecret: nsEnv.NETSUITE_CLIENT_SECRET || '' }
25387
25553
  }
25388
25554
 
25389
- function toHex(bytes: Uint8Array): string {
25390
- return Array.from(bytes)
25391
- .map(b => b.toString(16).padStart(2, '0'))
25392
- .join('')
25555
+ // Account id -> DNS host form: lowercase, underscores to hyphens.
25556
+ function host(accountId: string) {
25557
+ return accountId.trim().toLowerCase().replace(/_/g, '-')
25393
25558
  }
25394
-
25395
- function timingSafeEqual(a: string, b: string): boolean {
25396
- if (a.length !== b.length) return false
25397
- let diff = 0
25398
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
25399
- return diff === 0
25559
+ export function authorizeUrl(accountId: string) {
25560
+ return \`https://\${host(accountId)}.app.netsuite.com/app/login/oauth2/authorize.nl\`
25400
25561
  }
25401
-
25402
- // ---- password hashing (salted SHA-256; sufficient for a single-tenant
25403
- // demo app -- swap for a WASM bcrypt/argon2 if this ever guards real data) ----
25404
-
25405
- export function generateSalt(): string {
25406
- return toHex(crypto.getRandomValues(new Uint8Array(16)))
25562
+ export function tokenUrl(accountId: string) {
25563
+ return \`https://\${host(accountId)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token\`
25407
25564
  }
25408
-
25409
- async function sha256Hex(input: string): Promise<string> {
25410
- const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input))
25411
- return toHex(new Uint8Array(digest))
25565
+ export function restBase(accountId: string) {
25566
+ return \`https://\${host(accountId)}.suitetalk.api.netsuite.com\`
25412
25567
  }
25413
25568
 
25414
- export async function hashPassword(password: string, salt: string): Promise<string> {
25415
- return \`\${salt}:\${await sha256Hex(salt + password)}\`
25569
+ function stripTrailingSlash(s: string) {
25570
+ return s.endsWith('/') ? s.slice(0, -1) : s
25416
25571
  }
25417
25572
 
25418
- export async function verifyPassword(password: string, stored: string): Promise<boolean> {
25419
- const [salt, hash] = stored.split(':')
25420
- if (!salt || !hash) return false
25421
- return timingSafeEqual(await sha256Hex(salt + password), hash)
25573
+ // Public origin of THIS app, used to build the redirect URI. Prefer an explicit
25574
+ // PUBLIC_APP_URL; else derive from the request (honouring the preview proxy's
25575
+ // forwarded headers). Must match a redirect URI registered on the NetSuite
25576
+ // Integration Record exactly.
25577
+ export function appOrigin(request: Request) {
25578
+ // 1) Explicit override wins (set by the platform per environment).
25579
+ if (nsEnv.PUBLIC_APP_URL) return stripTrailingSlash(nsEnv.PUBLIC_APP_URL)
25580
+ // 2) HELLOLEO_URL is injected on every deploy (the app's canonical origin), so
25581
+ // it's set even when the NetSuite wizard's PUBLIC_APP_URL pin wasn't.
25582
+ if (nsEnv.HELLOLEO_URL) return stripTrailingSlash(nsEnv.HELLOLEO_URL)
25583
+ // 3) Preview/prod proxies rewrite Host, so trust the forwarded/original host
25584
+ // they set (HelloLeo's nginx sends X-Original-Host) \u2014 that's the public URL
25585
+ // the browser loaded, not an internal upstream host.
25586
+ const url = new URL(request.url)
25587
+ const proto = request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')
25588
+ const hostHeader =
25589
+ request.headers.get('x-forwarded-host') ||
25590
+ request.headers.get('x-original-host') ||
25591
+ url.host
25592
+ return \`\${proto}://\${hostHeader}\`
25593
+ }
25594
+ export function redirectUri(request: Request) {
25595
+ return \`\${appOrigin(request)}/auth/netsuite/callback\`
25422
25596
  }
25423
25597
 
25424
- // ---- session JWT (HS256, hand-rolled -- no dependency needed for one HMAC) ----
25425
-
25426
- function base64url(bytes: Uint8Array): string {
25427
- let binary = ''
25428
- for (const b of bytes) binary += String.fromCharCode(b)
25429
- return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
25598
+ // Coerce ?return_to= to our OWN origin. The callback appends the session bearer
25599
+ // as a #nssid=... fragment; an off-origin return_to would leak that live
25600
+ // credential to an attacker-controlled page (open-redirect -> token theft).
25601
+ export function safeReturnTo(request: Request, raw: string | null): string {
25602
+ const origin = appOrigin(request)
25603
+ if (!raw) return origin
25604
+ try {
25605
+ const u = new URL(raw, origin)
25606
+ return u.origin === origin ? u.toString() : origin
25607
+ } catch {
25608
+ return origin
25609
+ }
25430
25610
  }
25431
25611
 
25432
- function base64urlDecode(value: string): string {
25433
- const padded = value.replace(/-/g, '+').replace(/_/g, '/')
25434
- return atob(padded)
25612
+ // --- PKCE (Web Crypto: works on workerd and Node) ---
25613
+ function b64url(bytes: Uint8Array) {
25614
+ let s = ''
25615
+ for (const b of bytes) s += String.fromCharCode(b)
25616
+ return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
25617
+ }
25618
+ export async function pkce() {
25619
+ const verifier = b64url(crypto.getRandomValues(new Uint8Array(48)))
25620
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
25621
+ const challenge = b64url(new Uint8Array(digest))
25622
+ return { verifier, challenge }
25435
25623
  }
25436
25624
 
25437
- async function hmacKey(secret: string) {
25438
- return crypto.subtle.importKey(
25439
- 'raw',
25440
- encoder.encode(secret),
25441
- { name: 'HMAC', hash: 'SHA-256' },
25442
- false,
25443
- ['sign'],
25444
- )
25625
+ interface TokenResponse {
25626
+ access_token?: string
25627
+ refresh_token?: string
25628
+ expires_in?: number
25629
+ error?: string
25630
+ error_description?: string
25445
25631
  }
25446
25632
 
25447
- export async function signSession(userId: number): Promise<string> {
25448
- const header = base64url(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })))
25449
- const payload = base64url(
25450
- encoder.encode(
25451
- JSON.stringify({ sub: userId, exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS }),
25452
- ),
25453
- )
25454
- const key = await hmacKey(getAuthSecret())
25455
- const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(\`\${header}.\${payload}\`))
25456
- return \`\${header}.\${payload}.\${base64url(new Uint8Array(sig))}\`
25633
+ // Confidential client -> HTTP Basic. Public (PKCE-only) -> client_id in body.
25634
+ function authHeaders(clientId: string, clientSecret: string, params: Record<string, string>) {
25635
+ const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded' }
25636
+ if (clientSecret) headers.Authorization = 'Basic ' + btoa(\`\${clientId}:\${clientSecret}\`)
25637
+ else params.client_id = clientId
25638
+ return headers
25457
25639
  }
25458
25640
 
25459
- export async function verifySession(token: string): Promise<number | null> {
25460
- const parts = token.split('.')
25461
- if (parts.length !== 3) return null
25462
- const [header, payload, sig] = parts
25463
- const key = await hmacKey(getAuthSecret())
25464
- const expectedSig = base64url(
25465
- new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(\`\${header}.\${payload}\`))),
25466
- )
25467
- if (!timingSafeEqual(expectedSig, sig)) return null
25641
+ // POST the token endpoint and parse tolerantly. NetSuite returns JSON for both
25642
+ // success and OAuth errors, but a gateway (502/504) can return HTML -- never let
25643
+ // res.json() throw into the route handler (that would surface as an opaque 500
25644
+ // instead of a clean "token exchange failed").
25645
+ async function postToken(
25646
+ accountId: string,
25647
+ params: Record<string, string>,
25648
+ headers: Record<string, string>,
25649
+ ): Promise<TokenResponse> {
25650
+ let res: Response
25468
25651
  try {
25469
- const claims = JSON.parse(base64urlDecode(payload)) as { sub: number; exp: number }
25470
- if (claims.exp < Math.floor(Date.now() / 1000)) return null
25471
- return claims.sub
25652
+ res = await fetch(tokenUrl(accountId), { method: 'POST', headers, body: new URLSearchParams(params) })
25653
+ } catch (e) {
25654
+ return { error: 'network_error', error_description: String(e) }
25655
+ }
25656
+ const text = await res.text()
25657
+ let data: TokenResponse = {}
25658
+ try {
25659
+ data = JSON.parse(text) as TokenResponse
25472
25660
  } catch {
25473
- return null
25661
+ // non-JSON body (e.g. gateway HTML) -- leave data empty, fall through
25662
+ }
25663
+ if (!res.ok && !data.error) {
25664
+ data = { error: 'token_request_failed', error_description: 'HTTP ' + res.status }
25474
25665
  }
25666
+ return data
25475
25667
  }
25476
25668
 
25477
- export function sessionCookieHeader(token: string): string {
25478
- return \`\${SESSION_COOKIE}=\${token}; Path=/; Max-Age=\${SESSION_TTL_SECONDS}; HttpOnly; SameSite=Lax\`
25669
+ export async function exchangeCode(request: Request, code: string, verifier: string) {
25670
+ const { accountId, clientId, clientSecret } = requireConfig()
25671
+ const params: Record<string, string> = {
25672
+ grant_type: 'authorization_code',
25673
+ code,
25674
+ redirect_uri: redirectUri(request),
25675
+ code_verifier: verifier,
25676
+ }
25677
+ return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
25479
25678
  }
25480
25679
 
25481
- export function clearSessionCookieHeader(): string {
25482
- return \`\${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax\`
25680
+ async function refreshToken(refresh: string) {
25681
+ const { accountId, clientId, clientSecret } = requireConfig()
25682
+ const params: Record<string, string> = { grant_type: 'refresh_token', refresh_token: refresh }
25683
+ return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
25483
25684
  }
25484
25685
 
25485
- export function readSessionToken(request: Request): string | null {
25486
- const cookieHeader = request.headers.get('cookie') ?? ''
25487
- for (const part of cookieHeader.split(/;\\s*/)) {
25488
- const eq = part.indexOf('=')
25489
- if (eq === -1) continue
25490
- if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
25491
- }
25492
- return null
25686
+ export interface NsSession {
25687
+ sid: string
25688
+ accountId: string
25689
+ accessToken: string
25690
+ refreshToken: string | null
25691
+ expiresAt: number | null
25692
+ userId: string | null
25693
+ email: string | null
25493
25694
  }
25494
25695
 
25495
- export async function getUserFromRequest(request: Request) {
25496
- const token = readSessionToken(request)
25497
- if (!token) return null
25498
- const userId = await verifySession(token)
25499
- if (userId === null) return null
25500
- const [user] = await db
25501
- .select({ id: users.id, email: users.email })
25502
- .from(users)
25503
- .where(eq(users.id, userId))
25504
- .limit(1)
25505
- return user ?? null
25696
+ // Load a session by sid, transparently refreshing the access token when it's
25697
+ // within the leeway of expiry. Returns null if unknown or unrefreshable.
25698
+ export async function getSession(sid: string): Promise<NsSession | null> {
25699
+ if (!sid) return null
25700
+ const rows = await db.select().from(netsuiteSessions).where(eq(netsuiteSessions.sid, sid)).limit(1)
25701
+ const s = rows[0]
25702
+ if (!s) return null
25703
+ const now = Math.floor(Date.now() / 1000)
25704
+ // A missing expiry is treated as EXPIRED (force a refresh) rather than trusting
25705
+ // the token forever -- NetSuite normally sends expires_in, so this is defensive.
25706
+ const expired = s.expiresAt == null ? true : now + REFRESH_LEEWAY_SECONDS >= s.expiresAt
25707
+ if (expired) {
25708
+ // Token expired (or about to). It MUST refresh; if it can't, the session is
25709
+ // dead \u2014 delete it and return null so /api/me reports signed-out and the user
25710
+ // re-auths, rather than a "logged in" app whose every NetSuite call 401s.
25711
+ if (!s.refreshToken) { await deleteSession(sid); return null }
25712
+ const tok = await refreshToken(s.refreshToken)
25713
+ if (!tok.access_token) { await deleteSession(sid); return null }
25714
+ const expiresAt = now + (tok.expires_in || 3600)
25715
+ const refreshTok = tok.refresh_token ?? s.refreshToken
25716
+ await db
25717
+ .update(netsuiteSessions)
25718
+ .set({ accessToken: tok.access_token, refreshToken: refreshTok, expiresAt })
25719
+ .where(eq(netsuiteSessions.sid, sid))
25720
+ return { ...s, accessToken: tok.access_token, refreshToken: refreshTok, expiresAt }
25721
+ }
25722
+ return s
25723
+ }
25724
+
25725
+ export async function deleteSession(sid: string) {
25726
+ if (sid) await db.delete(netsuiteSessions).where(eq(netsuiteSessions.sid, sid))
25727
+ }
25728
+
25729
+ export function bearer(request: Request) {
25730
+ const h = request.headers.get('authorization') || ''
25731
+ return h.startsWith('Bearer ') ? h.slice(7) : ''
25732
+ }
25733
+
25734
+ /**
25735
+ * Call NetSuite REST / SuiteQL for the signed-in session. Use this from your
25736
+ * data routes (src/routes/api/*). \`path\` is relative to the account's
25737
+ * suitetalk host, e.g. '/services/rest/query/v1/suiteql'. Returns the raw
25738
+ * fetch Response (401 if the sid is unknown). Access token is auto-refreshed.
25739
+ */
25740
+ export async function netsuiteFetch(sid: string, path: string, init: RequestInit = {}): Promise<Response> {
25741
+ const session = await getSession(sid)
25742
+ if (!session) return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })
25743
+ const url = path.startsWith('http') ? path : \`\${restBase(session.accountId)}\${path}\`
25744
+ const headers = new Headers(init.headers)
25745
+ headers.set('Authorization', \`Bearer \${session.accessToken}\`)
25746
+ if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
25747
+ return fetch(url, { ...init, headers })
25506
25748
  }
25507
25749
  `;
25508
25750
  }
25509
- function signupRouteTs() {
25751
+ function startRouteTs() {
25510
25752
  return `import { createFileRoute } from '@tanstack/react-router'
25511
25753
  import { db } from '#/db'
25512
- import { users } from '#/db/schema'
25513
- import { generateSalt, hashPassword, sessionCookieHeader, signSession } from '#/lib/auth'
25514
- import { eq } from 'drizzle-orm'
25515
- import { z } from 'zod'
25516
-
25517
- const bodySchema = z.object({ email: z.string().email(), password: z.string().min(8) })
25754
+ import { netsuiteFlows } from '#/db/schema'
25755
+ import {
25756
+ authorizeUrl,
25757
+ pkce,
25758
+ redirectUri,
25759
+ requireConfig,
25760
+ safeReturnTo,
25761
+ SCOPE,
25762
+ } from '#/lib/netsuite-server'
25518
25763
 
25519
- export const Route = createFileRoute('/api/auth/signup')({
25764
+ // GET /auth/netsuite/start -- begin the NetSuite OAuth (PKCE) flow.
25765
+ export const Route = createFileRoute('/auth/netsuite/start')({
25520
25766
  server: {
25521
25767
  handlers: {
25522
- POST: async ({ request }) => {
25523
- const parsed = bodySchema.safeParse(await request.json().catch(() => null))
25524
- if (!parsed.success) {
25525
- return Response.json({ error: 'Invalid email or password (min 8 characters).' }, { status: 400 })
25526
- }
25527
- const email = parsed.data.email.toLowerCase()
25768
+ GET: async ({ request }) => {
25769
+ const { accountId, clientId } = requireConfig()
25770
+ const url = new URL(request.url)
25771
+ // Same-origin only: the callback returns the bearer in a URL fragment.
25772
+ const returnTo = safeReturnTo(request, url.searchParams.get('return_to'))
25528
25773
 
25529
- const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1)
25530
- if (existing) {
25531
- return Response.json({ error: 'An account with this email already exists.' }, { status: 409 })
25532
- }
25774
+ const { verifier, challenge } = await pkce()
25775
+ const flowId = crypto.randomUUID()
25776
+ await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
25533
25777
 
25534
- const salt = generateSalt()
25535
- const passwordHash = await hashPassword(parsed.data.password, salt)
25536
- const [user] = await db
25537
- .insert(users)
25538
- .values({ email, passwordHash })
25539
- .returning({ id: users.id, email: users.email })
25778
+ const authorize = new URL(authorizeUrl(accountId))
25779
+ authorize.searchParams.set('response_type', 'code')
25780
+ authorize.searchParams.set('client_id', clientId)
25781
+ authorize.searchParams.set('redirect_uri', redirectUri(request))
25782
+ authorize.searchParams.set('scope', SCOPE)
25783
+ authorize.searchParams.set('state', flowId)
25784
+ authorize.searchParams.set('code_challenge', challenge)
25785
+ authorize.searchParams.set('code_challenge_method', 'S256')
25540
25786
 
25541
- const token = await signSession(user.id)
25542
- return Response.json(
25543
- { user },
25544
- { status: 201, headers: { 'Set-Cookie': sessionCookieHeader(token) } },
25545
- )
25787
+ return new Response(null, { status: 302, headers: { Location: authorize.toString() } })
25546
25788
  },
25547
25789
  },
25548
25790
  },
25549
25791
  })
25550
25792
  `;
25551
25793
  }
25552
- function loginRouteTs() {
25794
+ function callbackRouteTs() {
25553
25795
  return `import { createFileRoute } from '@tanstack/react-router'
25554
- import { db } from '#/db'
25555
- import { users } from '#/db/schema'
25556
- import { sessionCookieHeader, signSession, verifyPassword } from '#/lib/auth'
25557
25796
  import { eq } from 'drizzle-orm'
25558
- import { z } from 'zod'
25559
-
25560
- const bodySchema = z.object({ email: z.string().email(), password: z.string() })
25797
+ import { db } from '#/db'
25798
+ import { netsuiteFlows, netsuiteSessions } from '#/db/schema'
25799
+ import { exchangeCode, requireConfig } from '#/lib/netsuite-server'
25561
25800
 
25562
- export const Route = createFileRoute('/api/auth/login')({
25801
+ // GET /auth/netsuite/callback -- exchange the code, mint a session, hand the
25802
+ // bearer back to the SPA via the URL fragment (#nssid=...). The fragment
25803
+ // survives the HelloLeo preview iframe, where third-party cookies are dropped.
25804
+ export const Route = createFileRoute('/auth/netsuite/callback')({
25563
25805
  server: {
25564
25806
  handlers: {
25565
- POST: async ({ request }) => {
25566
- const parsed = bodySchema.safeParse(await request.json().catch(() => null))
25567
- if (!parsed.success) {
25568
- return Response.json({ error: 'Invalid email or password.' }, { status: 400 })
25807
+ GET: async ({ request }) => {
25808
+ const url = new URL(request.url)
25809
+ const err = url.searchParams.get('error')
25810
+ if (err) return new Response(\`NetSuite authorization failed: \${err}\`, { status: 400 })
25811
+
25812
+ const code = url.searchParams.get('code')
25813
+ const state = url.searchParams.get('state')
25814
+ if (!code || !state) return new Response('Missing code or state', { status: 400 })
25815
+
25816
+ const rows = await db
25817
+ .select()
25818
+ .from(netsuiteFlows)
25819
+ .where(eq(netsuiteFlows.flowId, state))
25820
+ .limit(1)
25821
+ const flow = rows[0]
25822
+ if (!flow) return new Response('Unknown or expired flow -- start again', { status: 400 })
25823
+ // Single-use: burn the flow row immediately.
25824
+ await db.delete(netsuiteFlows).where(eq(netsuiteFlows.flowId, state))
25825
+ // Reject stale flows: the PKCE verifier must not stay valid indefinitely
25826
+ // for an abandoned or leaked authorize URL (createdAt is unixepoch seconds).
25827
+ const nowSec = Math.floor(Date.now() / 1000)
25828
+ if (flow.createdAt && nowSec - flow.createdAt > 600) {
25829
+ return new Response('Flow expired -- start again', { status: 400 })
25569
25830
  }
25570
- const email = parsed.data.email.toLowerCase()
25571
25831
 
25572
- const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1)
25573
- if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
25574
- return Response.json({ error: 'Invalid email or password.' }, { status: 401 })
25832
+ const tok = await exchangeCode(request, code, flow.codeVerifier)
25833
+ if (!tok.access_token) {
25834
+ return new Response(
25835
+ \`Token exchange failed: \${tok.error_description || tok.error || 'unknown error'}\`,
25836
+ { status: 400 },
25837
+ )
25575
25838
  }
25576
25839
 
25577
- const token = await signSession(user.id)
25578
- return Response.json(
25579
- { user: { id: user.id, email: user.email } },
25580
- { status: 200, headers: { 'Set-Cookie': sessionCookieHeader(token) } },
25581
- )
25840
+ const sid = crypto.randomUUID()
25841
+ const now = Math.floor(Date.now() / 1000)
25842
+ await db.insert(netsuiteSessions).values({
25843
+ sid,
25844
+ accountId: requireConfig().accountId,
25845
+ accessToken: tok.access_token,
25846
+ refreshToken: tok.refresh_token ?? null,
25847
+ expiresAt: now + (tok.expires_in || 3600),
25848
+ })
25849
+
25850
+ const dest = new URL(flow.returnTo)
25851
+ dest.hash = \`nssid=\${sid}\`
25852
+ return new Response(null, { status: 302, headers: { Location: dest.toString() } })
25582
25853
  },
25583
25854
  },
25584
25855
  },
@@ -25587,13 +25858,15 @@ export const Route = createFileRoute('/api/auth/login')({
25587
25858
  }
25588
25859
  function logoutRouteTs() {
25589
25860
  return `import { createFileRoute } from '@tanstack/react-router'
25590
- import { clearSessionCookieHeader } from '#/lib/auth'
25861
+ import { bearer, deleteSession } from '#/lib/netsuite-server'
25591
25862
 
25592
- export const Route = createFileRoute('/api/auth/logout')({
25863
+ // POST /auth/netsuite/logout -- destroy the current session.
25864
+ export const Route = createFileRoute('/auth/netsuite/logout')({
25593
25865
  server: {
25594
25866
  handlers: {
25595
- POST: async () => {
25596
- return Response.json({ ok: true }, { headers: { 'Set-Cookie': clearSessionCookieHeader() } })
25867
+ POST: async ({ request }) => {
25868
+ await deleteSession(bearer(request))
25869
+ return Response.json({ ok: true })
25597
25870
  },
25598
25871
  },
25599
25872
  },
@@ -25602,279 +25875,462 @@ export const Route = createFileRoute('/api/auth/logout')({
25602
25875
  }
25603
25876
  function meRouteTs() {
25604
25877
  return `import { createFileRoute } from '@tanstack/react-router'
25605
- import { getUserFromRequest } from '#/lib/auth'
25878
+ import { bearer, getSession } from '#/lib/netsuite-server'
25606
25879
 
25607
- export const Route = createFileRoute('/api/auth/me')({
25880
+ // GET /api/me -- resolve the bearer sid to a session. The ONLY authoritative
25881
+ // auth check; the client must never trust sessionStorage on its own.
25882
+ export const Route = createFileRoute('/api/me')({
25608
25883
  server: {
25609
25884
  handlers: {
25610
25885
  GET: async ({ request }) => {
25611
- const user = await getUserFromRequest(request)
25612
- return Response.json({ user })
25886
+ const session = await getSession(bearer(request))
25887
+ if (!session) return Response.json({ authenticated: false })
25888
+ return Response.json({
25889
+ authenticated: true,
25890
+ account_id: session.accountId,
25891
+ })
25613
25892
  },
25614
25893
  },
25615
25894
  },
25616
25895
  })
25617
25896
  `;
25618
25897
  }
25619
- function sessionClientTs() {
25898
+ function configRouteTs() {
25899
+ return `import { createFileRoute } from '@tanstack/react-router'
25900
+ import { env as runtimeEnv } from '@helloleo/runtime'
25901
+ import { appOrigin, redirectUri } from '#/lib/netsuite-server'
25902
+
25903
+ // GET /auth/netsuite/config -- single source of truth for the redirect URI.
25904
+ // Whatever this returns is EXACTLY what /auth/netsuite/start sends NetSuite, so
25905
+ // show the user THIS value to register on their Integration Record. Never
25906
+ // hardcode or guess a callback URL.
25907
+ export const Route = createFileRoute('/auth/netsuite/config')({
25908
+ server: {
25909
+ handlers: {
25910
+ GET: async ({ request }) => {
25911
+ const e = runtimeEnv as unknown as {
25912
+ NETSUITE_ACCOUNT_ID?: string
25913
+ NETSUITE_CLIENT_ID?: string
25914
+ }
25915
+ return Response.json({
25916
+ redirect_uri: redirectUri(request),
25917
+ origin: appOrigin(request),
25918
+ configured: Boolean(e.NETSUITE_ACCOUNT_ID && e.NETSUITE_CLIENT_ID),
25919
+ })
25920
+ },
25921
+ },
25922
+ },
25923
+ })
25924
+ `;
25925
+ }
25926
+ function netsuiteClientTs() {
25620
25927
  return `/*
25621
- * Client-side session helpers. getUser() is the getUser() referenced by the
25622
- * "Authenticated routes" pattern in src/routes/README.md -- wire it into
25623
- * __root.tsx's beforeLoad (see that file's manual step below).
25928
+ * NetSuite auth -- client (same-origin). The OAuth backend is THIS app's own
25929
+ * server routes (src/routes/auth/netsuite/* + src/routes/api/me.ts), so every
25930
+ * call is relative -- no separate Worker, no VITE_WORKER_URL.
25931
+ *
25932
+ * The browser only ever holds an opaque session id (sid), handed back in the
25933
+ * callback's URL fragment (#nssid=...) so it survives the HelloLeo preview
25934
+ * iframe (third-party cookies are partitioned/dropped there). The sid rides as
25935
+ * Authorization: Bearer on every call; NetSuite tokens stay server-side. The
25936
+ * only authoritative auth check is GET /api/me.
25937
+ *
25938
+ * NEVER use a password gate. NEVER trust sessionStorage as the auth state --
25939
+ * it's just a cache of the bearer.
25624
25940
  */
25941
+ const TOKEN_KEY = 'nssid'
25942
+
25943
+ // Capture-and-strip #nssid=... as early as possible so URLs stay clean.
25944
+ ;(function bootstrapTokenFromHash() {
25945
+ if (typeof window === 'undefined') return
25946
+ const hash = window.location.hash.replace(/^#/, '')
25947
+ if (!hash) return
25948
+ const params = new URLSearchParams(hash)
25949
+ const sid = params.get(TOKEN_KEY)
25950
+ if (sid) {
25951
+ try {
25952
+ sessionStorage.setItem(TOKEN_KEY, sid)
25953
+ } catch {}
25954
+ params.delete(TOKEN_KEY)
25955
+ const rest = params.toString()
25956
+ window.history.replaceState(
25957
+ null,
25958
+ '',
25959
+ window.location.pathname + window.location.search + (rest ? '#' + rest : ''),
25960
+ )
25961
+ }
25962
+ })()
25625
25963
 
25626
- export interface SessionUser {
25627
- id: number
25628
- email: string
25964
+ export function getSessionToken(): string | null {
25965
+ try {
25966
+ return sessionStorage.getItem(TOKEN_KEY)
25967
+ } catch {
25968
+ return null
25969
+ }
25629
25970
  }
25630
25971
 
25631
- export async function getUser(): Promise<SessionUser | null> {
25632
- if (typeof window === 'undefined') return null
25633
- const res = await fetch('/api/auth/me')
25634
- if (!res.ok) return null
25635
- const data = (await res.json()) as { user: SessionUser | null }
25636
- return data.user
25972
+ export function signInWithNetSuite(returnTo: string = window.location.href) {
25973
+ const url = new URL('/auth/netsuite/start', window.location.origin)
25974
+ url.searchParams.set('return_to', returnTo)
25975
+ // The app renders inside the HelloLeo preview iframe; /start 302s to
25976
+ // NetSuite, whose OAuth page sends X-Frame-Options: DENY. Navigate the TOP
25977
+ // frame so the flow runs at top level and the callback lands there to read
25978
+ // #nssid=. Assign .href (NOT .assign(): reading .assign off a cross-origin
25979
+ // window.top.location throws, dropping us into the iframe fallback ->
25980
+ // "refused to connect").
25981
+ try {
25982
+ if (window.top && window.top !== window) {
25983
+ window.top.location.href = url.toString()
25984
+ return
25985
+ }
25986
+ } catch {
25987
+ /* top window unreachable -- fall through to same-frame navigation */
25988
+ }
25989
+ window.location.href = url.toString()
25637
25990
  }
25638
25991
 
25639
- export async function login(email: string, password: string): Promise<{ user: SessionUser } | { error: string }> {
25640
- const res = await fetch('/api/auth/login', {
25641
- method: 'POST',
25642
- headers: { 'Content-Type': 'application/json' },
25643
- body: JSON.stringify({ email, password }),
25644
- })
25992
+ export interface NetSuiteSession {
25993
+ authenticated: boolean
25994
+ account_id?: string
25995
+ }
25996
+
25997
+ export async function getNetSuiteSession(): Promise<NetSuiteSession> {
25998
+ const sid = getSessionToken()
25999
+ if (!sid) return { authenticated: false }
26000
+ const res = await fetch('/api/me', { headers: { Authorization: 'Bearer ' + sid } })
26001
+ if (!res.ok) return { authenticated: false }
25645
26002
  const data = await res.json()
25646
- return res.ok ? { user: data.user } : { error: data.error ?? 'Something went wrong.' }
26003
+ return { authenticated: Boolean(data?.authenticated), ...data }
26004
+ }
26005
+
26006
+ export async function signOutNetSuite(): Promise<void> {
26007
+ const sid = getSessionToken()
26008
+ if (sid) {
26009
+ try {
26010
+ await fetch('/auth/netsuite/logout', {
26011
+ method: 'POST',
26012
+ headers: { Authorization: 'Bearer ' + sid },
26013
+ })
26014
+ } catch {}
26015
+ }
26016
+ try {
26017
+ sessionStorage.removeItem(TOKEN_KEY)
26018
+ } catch {}
26019
+ window.location.reload()
25647
26020
  }
25648
26021
 
25649
- export async function signup(email: string, password: string): Promise<{ user: SessionUser } | { error: string }> {
25650
- const res = await fetch('/api/auth/signup', {
25651
- method: 'POST',
25652
- headers: { 'Content-Type': 'application/json' },
25653
- body: JSON.stringify({ email, password }),
25654
- })
25655
- const data = await res.json()
25656
- return res.ok ? { user: data.user } : { error: data.error ?? 'Something went wrong.' }
26022
+ /**
26023
+ * Fetch YOUR data routes (src/routes/api/*) with the bearer attached. Use this
26024
+ * instead of bare fetch for authenticated calls.
26025
+ */
26026
+ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
26027
+ const sid = getSessionToken()
26028
+ const headers = new Headers(init.headers)
26029
+ if (sid) headers.set('Authorization', 'Bearer ' + sid)
26030
+ return fetch(path, { ...init, headers })
25657
26031
  }
25658
26032
 
25659
- export async function logout(): Promise<void> {
25660
- await fetch('/api/auth/logout', { method: 'POST' })
26033
+ /**
26034
+ * The exact redirect URI this app will send NetSuite, read from the server so
26035
+ * it can never drift from what /auth/netsuite/start actually uses. Show it to
26036
+ * whoever registers the Integration Record; never hardcode a callback URL.
26037
+ */
26038
+ export async function getNetSuiteRedirectUri(): Promise<string | null> {
26039
+ try {
26040
+ const res = await fetch('/auth/netsuite/config')
26041
+ if (!res.ok) return null
26042
+ const data = await res.json()
26043
+ return data?.redirect_uri ?? null
26044
+ } catch {
26045
+ return null
26046
+ }
25661
26047
  }
25662
26048
  `;
25663
26049
  }
25664
- function authedLayoutTsx() {
25665
- return `import { createFileRoute, redirect } from '@tanstack/react-router'
26050
+ function authTsx() {
26051
+ return `import * as React from 'react'
26052
+ import {
26053
+ getNetSuiteSession,
26054
+ signInWithNetSuite,
26055
+ signOutNetSuite,
26056
+ type NetSuiteSession,
26057
+ } from './netsuite'
25666
26058
 
25667
- // Pathless guard -- no URL segment of its own. Put protected pages under
25668
- // src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard) and they
25669
- // inherit this beforeLoad. See src/routes/README.md, "Authenticated routes".
25670
- export const Route = createFileRoute('/_authed')({
25671
- beforeLoad: ({ context }) => {
25672
- if (!context.user) {
25673
- throw redirect({ to: '/login' })
25674
- }
25675
- },
25676
- })
25677
- `;
26059
+ /**
26060
+ * NetSuite auth context. Holds the session (resolved client-side from /api/me
26061
+ * via getNetSuiteSession) and exposes sign-in / sign-out. /api/me is the only
26062
+ * authoritative check -- never trust sessionStorage directly.
26063
+ */
26064
+ export interface NetSuiteAuthContextValue {
26065
+ pending: boolean
26066
+ isAuthenticated: boolean
26067
+ session: NetSuiteSession | null
26068
+ signIn: () => void
26069
+ signOut: () => Promise<void>
26070
+ refresh: () => Promise<void>
25678
26071
  }
25679
- function loginPageTsx() {
25680
- return `import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
25681
- import { useState } from 'react'
25682
- import { login } from '#/lib/session-client'
25683
- import { Button } from '#/components/ui/button'
25684
- import { Input } from '#/components/ui/input'
25685
- import { Label } from '#/components/ui/label'
25686
- import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
25687
26072
 
25688
- export const Route = createFileRoute('/login')({
25689
- beforeLoad: ({ context }) => {
25690
- if (context.user) throw redirect({ to: '/' })
25691
- },
25692
- component: LoginPage,
25693
- })
26073
+ const NetSuiteAuthContext = React.createContext<NetSuiteAuthContextValue | null>(null)
25694
26074
 
25695
- function LoginPage() {
25696
- const router = useRouter()
25697
- const [email, setEmail] = useState('')
25698
- const [password, setPassword] = useState('')
25699
- const [error, setError] = useState('')
25700
- const [loading, setLoading] = useState(false)
26075
+ export function NetSuiteAuthProvider({ children }: { children: React.ReactNode }) {
26076
+ const [session, setSession] = React.useState<NetSuiteSession | null>(null)
26077
+ // Start pending on BOTH server and client render so SSR and first paint match
26078
+ // (a spinner), avoiding a hydration mismatch. The effect (client-only)
26079
+ // resolves it.
26080
+ const [pending, setPending] = React.useState(true)
25701
26081
 
25702
- async function handleSubmit(e: React.FormEvent) {
25703
- e.preventDefault()
25704
- setError('')
25705
- setLoading(true)
25706
- const result = await login(email, password)
25707
- setLoading(false)
25708
- if ('error' in result) {
25709
- setError(result.error)
25710
- return
26082
+ const refresh = React.useCallback(async () => {
26083
+ setPending(true)
26084
+ try {
26085
+ setSession(await getNetSuiteSession())
26086
+ } finally {
26087
+ setPending(false)
25711
26088
  }
25712
- await router.invalidate()
25713
- router.navigate({ to: '/' })
26089
+ }, [])
26090
+
26091
+ React.useEffect(() => {
26092
+ refresh()
26093
+ }, [refresh])
26094
+
26095
+ const value: NetSuiteAuthContextValue = {
26096
+ pending,
26097
+ isAuthenticated: Boolean(session?.authenticated),
26098
+ session,
26099
+ signIn: () => signInWithNetSuite(),
26100
+ signOut: signOutNetSuite,
26101
+ refresh,
25714
26102
  }
25715
26103
 
25716
- return (
25717
- <div className="min-h-screen flex items-center justify-center bg-background p-4">
25718
- <Card className="w-full max-w-sm">
25719
- <CardHeader>
25720
- <CardTitle>Log in</CardTitle>
25721
- </CardHeader>
25722
- <CardContent>
25723
- <form onSubmit={handleSubmit} className="space-y-4">
25724
- <div className="space-y-2">
25725
- <Label htmlFor="email">Email</Label>
25726
- <Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
25727
- </div>
25728
- <div className="space-y-2">
25729
- <Label htmlFor="password">Password</Label>
25730
- <Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
25731
- </div>
25732
- {error && <p className="text-sm text-destructive">{error}</p>}
25733
- <Button type="submit" className="w-full" disabled={loading}>
25734
- {loading ? 'Logging in\u2026' : 'Log in'}
25735
- </Button>
25736
- <p className="text-sm text-center text-muted-foreground">
25737
- No account? <Link to="/signup" className="underline">Sign up</Link>
25738
- </p>
25739
- </form>
25740
- </CardContent>
25741
- </Card>
25742
- </div>
25743
- )
26104
+ return <NetSuiteAuthContext.Provider value={value}>{children}</NetSuiteAuthContext.Provider>
26105
+ }
26106
+
26107
+ export function useNetSuiteAuth(): NetSuiteAuthContextValue {
26108
+ const ctx = React.useContext(NetSuiteAuthContext)
26109
+ if (!ctx) {
26110
+ throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>')
26111
+ }
26112
+ return ctx
25744
26113
  }
25745
26114
  `;
25746
26115
  }
25747
- function signupPageTsx() {
25748
- return `import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
25749
- import { useState } from 'react'
25750
- import { signup } from '#/lib/session-client'
25751
- import { Button } from '#/components/ui/button'
25752
- import { Input } from '#/components/ui/input'
25753
- import { Label } from '#/components/ui/label'
25754
- import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
26116
+ function authLayoutTsx() {
26117
+ return `import type { ReactNode } from 'react'
26118
+ import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth'
26119
+ import { LoginPage } from './LoginPage'
25755
26120
 
25756
- export const Route = createFileRoute('/signup')({
25757
- beforeLoad: ({ context }) => {
25758
- if (context.user) throw redirect({ to: '/' })
25759
- },
25760
- component: SignupPage,
25761
- })
26121
+ /**
26122
+ * NetSuite auth layout. The ONE manual wiring step: wrap the root route's
26123
+ * <Outlet /> with it in src/routes/__root.tsx:
26124
+ *
26125
+ * import { AuthLayout } from '../components/auth/AuthLayout'
26126
+ * ...
26127
+ * function RootComponent() {
26128
+ * return (
26129
+ * <RootDocument>
26130
+ * <AuthLayout>
26131
+ * <Outlet />
26132
+ * </AuthLayout>
26133
+ * </RootDocument>
26134
+ * )
26135
+ * }
26136
+ *
26137
+ * It provides auth context and gates rendering: a spinner while the session
26138
+ * resolves, <LoginPage /> for signed-out users, your routes once signed in.
26139
+ * NEVER swap this for a password gate -- real OAuth is the only login.
26140
+ */
26141
+ export function AuthLayout({ children }: { children: ReactNode }) {
26142
+ return (
26143
+ <NetSuiteAuthProvider>
26144
+ <Gate>{children}</Gate>
26145
+ </NetSuiteAuthProvider>
26146
+ )
26147
+ }
25762
26148
 
25763
- function SignupPage() {
25764
- const router = useRouter()
25765
- const [email, setEmail] = useState('')
25766
- const [password, setPassword] = useState('')
25767
- const [error, setError] = useState('')
25768
- const [loading, setLoading] = useState(false)
26149
+ function Gate({ children }: { children: ReactNode }) {
26150
+ const { pending, isAuthenticated } = useNetSuiteAuth()
25769
26151
 
25770
- async function handleSubmit(e: React.FormEvent) {
25771
- e.preventDefault()
25772
- setError('')
25773
- setLoading(true)
25774
- const result = await signup(email, password)
25775
- setLoading(false)
25776
- if ('error' in result) {
25777
- setError(result.error)
25778
- return
25779
- }
25780
- await router.invalidate()
25781
- router.navigate({ to: '/' })
26152
+ if (pending) {
26153
+ return (
26154
+ <div className="min-h-screen flex items-center justify-center bg-background">
26155
+ <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
26156
+ </div>
26157
+ )
25782
26158
  }
25783
26159
 
26160
+ if (!isAuthenticated) return <LoginPage />
26161
+
26162
+ return <>{children}</>
26163
+ }
26164
+ `;
26165
+ }
26166
+ function loginPageTsx2() {
26167
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite'
26168
+
26169
+ /**
26170
+ * Default unauthenticated landing page. Replaces any password gate. Customise
26171
+ * the copy / imagery freely but keep <SignInWithNetSuite /> as the only
26172
+ * call-to-action -- it points to /auth/netsuite/start.
26173
+ */
26174
+ export function LoginPage() {
25784
26175
  return (
25785
- <div className="min-h-screen flex items-center justify-center bg-background p-4">
25786
- <Card className="w-full max-w-sm">
25787
- <CardHeader>
25788
- <CardTitle>Sign up</CardTitle>
25789
- </CardHeader>
25790
- <CardContent>
25791
- <form onSubmit={handleSubmit} className="space-y-4">
25792
- <div className="space-y-2">
25793
- <Label htmlFor="email">Email</Label>
25794
- <Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
25795
- </div>
25796
- <div className="space-y-2">
25797
- <Label htmlFor="password">Password</Label>
25798
- <Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
25799
- </div>
25800
- {error && <p className="text-sm text-destructive">{error}</p>}
25801
- <Button type="submit" className="w-full" disabled={loading}>
25802
- {loading ? 'Signing up\u2026' : 'Sign up'}
25803
- </Button>
25804
- <p className="text-sm text-center text-muted-foreground">
25805
- Already have an account? <Link to="/login" className="underline">Log in</Link>
26176
+ <div className="min-h-screen bg-background text-foreground flex">
26177
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
26178
+ <div className="text-xl font-semibold">NetSuite App</div>
26179
+ <div>
26180
+ <h1 className="text-4xl font-bold leading-tight">Connect your NetSuite account</h1>
26181
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
26182
+ Sign in with your NetSuite credentials to access your data securely. Tokens stay
26183
+ server-side -- they never reach the browser.
26184
+ </p>
26185
+ </div>
26186
+ <div className="text-xs text-primary-foreground/60">Powered by HelloLeo</div>
26187
+ </div>
26188
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
26189
+ <div className="w-full max-w-sm space-y-6">
26190
+ <div>
26191
+ <h2 className="text-2xl font-semibold">Welcome</h2>
26192
+ <p className="mt-2 text-sm text-muted-foreground">
26193
+ Use the button below to sign in. You'll be redirected to NetSuite to authorise
26194
+ access, then back here.
25806
26195
  </p>
25807
- </form>
25808
- </CardContent>
25809
- </Card>
26196
+ </div>
26197
+ <SignInWithNetSuite />
26198
+ </div>
26199
+ </div>
25810
26200
  </div>
25811
26201
  )
25812
26202
  }
25813
26203
  `;
25814
26204
  }
25815
- async function appendUsersTable(dir) {
25816
- const schemaPath = path5.join(dir, "src", "db", "schema.ts");
25817
- const current = await readFile(schemaPath, "utf8").catch(() => null);
25818
- if (current === null) {
25819
- throw new Error(`src/db/schema.ts not found -- run scaffold first.`);
25820
- }
25821
- if (current.includes(SCHEMA_MARKER))
25822
- return false;
25823
- await Bun.write(schemaPath, `${current.trimEnd()}
25824
- ${usersTableBlock()}`);
25825
- return true;
26205
+ function signInButtonTsx() {
26206
+ return `import { signInWithNetSuite } from '../../lib/netsuite'
26207
+
26208
+ export function SignInWithNetSuite() {
26209
+ return (
26210
+ <button
26211
+ onClick={() => signInWithNetSuite()}
26212
+ className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
26213
+ >
26214
+ Sign in with NetSuite
26215
+ </button>
26216
+ )
25826
26217
  }
25827
- var setupPasswordAuth = tool({
25828
- description: "Add email/password auth to an existing project. Call scaffold first, " + "then this. Writes a users table, stateless-JWT session helpers, and " + "signup/login/logout/me API routes.",
26218
+ `;
26219
+ }
26220
+ var setupNetsuite = tool({
26221
+ description: "Add NetSuite runtime OAuth (end-user LOGIN) to a TanStack Start project. " + "Call scaffold first, then this. This login uses NetSuite Integration " + "Record #2 (scope: rest_webservices) -- a SEPARATE record from the MCP one " + "(scope: mcp) that was used only to BUILD the app; do not confuse them. " + "Emits server routes (the OAuth backend lives IN the app -- no separate " + "Worker, no VITE_WORKER_URL), the netsuite_flows + netsuite_sessions " + "drizzle tables, and the same-origin client + auth components. Config " + "(NETSUITE_ACCOUNT_ID/_CLIENT_ID/_CLIENT_SECRET) comes from the project " + ".env in dev and HelloLeo Cloud secrets in prod -- same var names, never " + "in code. Real OAuth from day one -- never replace <AuthLayout> with a " + "password placeholder. Setup guide (link the user to it): " + "https://docs.helloleo.dev/integrations/netsuite",
25829
26222
  args: {},
25830
26223
  async execute(_args, context) {
25831
26224
  const dir = context.directory;
25832
- await Promise.all([
25833
- Bun.write(path5.join(dir, "src", "lib", "auth.ts"), authLibTs()),
25834
- Bun.write(path5.join(dir, "src", "lib", "session-client.ts"), sessionClientTs()),
25835
- Bun.write(path5.join(dir, "src", "routes", "api", "auth", "signup.ts"), signupRouteTs()),
25836
- Bun.write(path5.join(dir, "src", "routes", "api", "auth", "login.ts"), loginRouteTs()),
25837
- Bun.write(path5.join(dir, "src", "routes", "api", "auth", "logout.ts"), logoutRouteTs()),
25838
- Bun.write(path5.join(dir, "src", "routes", "api", "auth", "me.ts"), meRouteTs()),
25839
- Bun.write(path5.join(dir, "src", "routes", "_authed.tsx"), authedLayoutTsx()),
25840
- Bun.write(path5.join(dir, "src", "routes", "login.tsx"), loginPageTsx()),
25841
- Bun.write(path5.join(dir, "src", "routes", "signup.tsx"), signupPageTsx())
25842
- ]);
25843
- const schemaAppended = await appendUsersTable(dir);
26225
+ await mkdir2(path7.join(dir, "src", "lib"), { recursive: true });
26226
+ await mkdir2(path7.join(dir, "src", "components", "auth"), {
26227
+ recursive: true
26228
+ });
26229
+ await mkdir2(path7.join(dir, "src", "routes", "auth", "netsuite"), {
26230
+ recursive: true
26231
+ });
26232
+ await mkdir2(path7.join(dir, "src", "routes", "api"), { recursive: true });
26233
+ const schemaPath = path7.join(dir, "src", "db", "schema.ts");
26234
+ let schemaAppended = false;
26235
+ try {
26236
+ const current = await readFile3(schemaPath, "utf8");
26237
+ if (!current.includes("netsuite_flows")) {
26238
+ await Bun.write(schemaPath, current + schemaAppend());
26239
+ schemaAppended = true;
26240
+ }
26241
+ } catch {
26242
+ await Bun.write(schemaPath, `import { sql } from 'drizzle-orm'
26243
+ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
26244
+ ${schemaAppend()}`);
26245
+ schemaAppended = true;
26246
+ }
26247
+ const files = [
26248
+ [path7.join("src", "lib", "netsuite-server.ts"), netsuiteServerTs()],
26249
+ [
26250
+ path7.join("src", "routes", "auth", "netsuite", "start.ts"),
26251
+ startRouteTs()
26252
+ ],
26253
+ [
26254
+ path7.join("src", "routes", "auth", "netsuite", "callback.ts"),
26255
+ callbackRouteTs()
26256
+ ],
26257
+ [
26258
+ path7.join("src", "routes", "auth", "netsuite", "logout.ts"),
26259
+ logoutRouteTs()
26260
+ ],
26261
+ [
26262
+ path7.join("src", "routes", "auth", "netsuite", "config.ts"),
26263
+ configRouteTs()
26264
+ ],
26265
+ [path7.join("src", "routes", "api", "me.ts"), meRouteTs()],
26266
+ [path7.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
26267
+ [path7.join("src", "lib", "auth.tsx"), authTsx()],
26268
+ [
26269
+ path7.join("src", "components", "auth", "AuthLayout.tsx"),
26270
+ authLayoutTsx()
26271
+ ],
26272
+ [path7.join("src", "components", "auth", "LoginPage.tsx"), loginPageTsx2()],
26273
+ [
26274
+ path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
26275
+ signInButtonTsx()
26276
+ ]
26277
+ ];
26278
+ for (const [filePath, content] of files) {
26279
+ await Bun.write(path7.join(dir, filePath), content);
26280
+ }
25844
26281
  return [
25845
- "Wrote password auth (users table, JWT session, API routes):",
25846
- ` - src/db/schema.ts ${schemaAppended ? "(appended `users` table)" : "(already had a `users` table, left as-is)"}`,
25847
- " - src/lib/auth.ts (hashing, JWT sign/verify, getUserFromRequest -- server-only, never import from a client route)",
25848
- " - src/lib/session-client.ts (getUser, login, signup, logout -- client fetch helpers)",
25849
- " - src/routes/api/auth/{signup,login,logout,me}.ts (server routes -- never createServerFn)",
25850
- " - src/routes/_authed.tsx (pathless guard for protected routes)",
25851
- " - src/routes/login.tsx, src/routes/signup.tsx",
26282
+ `Wrote ${files.length} NetSuite files${schemaAppended ? " + appended src/db/schema.ts" : ""}:`,
26283
+ "DOCS -- the authoritative setup guide is https://docs.helloleo.dev/integrations/netsuite . When you tell the user what to do in NetSuite, follow and link these steps verbatim; do not invent steps.",
26284
+ " - Record #2 (runtime): https://docs.helloleo.dev/integrations/netsuite#step-4-%E2%80%94-create-integration-record-2-runtime",
26285
+ " - Send runtime creds to Leo: https://docs.helloleo.dev/integrations/netsuite#step-5-%E2%80%94-send-the-runtime-credentials-to-leo",
26286
+ "",
26287
+ " server (OAuth backend lives in the app -- no separate Worker):",
26288
+ " - src/lib/netsuite-server.ts (env, PKCE, token exchange, session refresh, netsuiteFetch)",
26289
+ " - src/routes/auth/netsuite/start.ts GET begin PKCE flow",
26290
+ " - src/routes/auth/netsuite/callback.ts GET code -> tokens -> session",
26291
+ " - src/routes/auth/netsuite/logout.ts POST destroy session",
26292
+ " - src/routes/auth/netsuite/config.ts GET the exact redirect URI to register",
26293
+ " - src/routes/api/me.ts GET bearer -> session (auth check)",
26294
+ " - src/db/schema.ts += netsuite_flows + netsuite_sessions",
26295
+ " client:",
26296
+ " - src/lib/netsuite.ts (same-origin: signIn, session, apiFetch)",
26297
+ " - src/lib/auth.tsx (NetSuiteAuthProvider + useNetSuiteAuth)",
26298
+ " - src/components/auth/{AuthLayout,LoginPage,SignInWithNetSuite}.tsx",
25852
26299
  "",
25853
- "Session is a stateless HMAC-signed JWT in an httpOnly cookie (no localStorage, no server-side session store).",
26300
+ "WIRE IT UP (one manual step): in src/routes/__root.tsx, wrap <Outlet /> with <AuthLayout>:",
26301
+ "",
26302
+ " import { AuthLayout } from '../components/auth/AuthLayout'",
25854
26303
  "",
25855
- "Required: set AUTH_JWT_SECRET or every request 500s.",
25856
- " - dev: add `AUTH_JWT_SECRET=<value>` to .env",
25857
- " - prod: `wrangler secret put AUTH_JWT_SECRET`.",
26304
+ " <AuthLayout>",
26305
+ " <Outlet />",
26306
+ " </AuthLayout>",
25858
26307
  "",
25859
- "Next (manual, one edit): in src/routes/__root.tsx, load the user into router context, per src/routes/README.md's Authenticated routes section:",
26308
+ "Then:",
26309
+ "- Build your dashboard inside <AuthLayout>. Read the session with useNetSuiteAuth().",
26310
+ "- Data routes: add src/routes/api/*.ts and call netsuiteFetch(bearer(request), '/services/rest/query/v1/suiteql', {...}) from the handler.",
25860
26311
  "",
25861
- " import { getUser } from '#/lib/session-client'",
25862
- " // in createRootRouteWithContext(...)({ ... }):",
25863
- " beforeLoad: async () => ({ user: await getUser() }),",
26312
+ "TWO INTEGRATION RECORDS -- do not confuse them:",
26313
+ "- Record #1 (scope: mcp): already connected, used ONLY to build the app (schema discovery). This login does NOT use it.",
26314
+ "- Record #2 (scope: rest_webservices): THIS login uses it -- the end user signs into NetSuite through it, and the redirect URI is registered on THIS record. If it doesn't exist yet, tell the user to create it (a second Integration Record, scope rest_webservices only).",
25864
26315
  "",
25865
- "Then put protected pages under src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard); they inherit the guard."
26316
+ "CONFIG -- NETSUITE_ACCOUNT_ID, NETSUITE_CLIENT_ID, NETSUITE_CLIENT_SECRET (secret optional for PKCE public clients). Never hardcode; never put in code. Same var names in every environment, different SOURCE:",
26317
+ "- DEV (Fly preview): read from the project .env. HelloLeo writes them there (the setup wizard, or the env tooling). In dev the app uses .env -- do NOT tell the user to use HelloLeo Cloud here.",
26318
+ "- PROD (published Worker): the SAME names live as HelloLeo Cloud secrets; the publish step promotes them from .env onto the Worker automatically. Same code path (@helloleo/runtime env), just a different source per environment.",
26319
+ "- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
26320
+ "- REDIRECT URI -- never guess or hardcode it. It is derived server-side and auto-corrects behind the preview proxy. Read the exact value from GET /auth/netsuite/config (or getNetSuiteRedirectUri() on the client) and tell the user to register THAT on Integration Record #2 (scope: rest_webservices). Register both the preview and published URLs -- NetSuite allows multiple redirect URIs.",
26321
+ "- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login."
25866
26322
  ].join(`
25867
26323
  `);
25868
26324
  }
25869
26325
  });
25870
- var SetupPasswordAuthPlugin = async () => {
26326
+ var SetupNetsuitePlugin = async () => {
25871
26327
  return {
25872
- tool: { setup_password_auth: setupPasswordAuth }
26328
+ tool: { setup_netsuite: setupNetsuite }
25873
26329
  };
25874
26330
  };
25875
26331
  export {
25876
- SetupPasswordAuthPlugin,
25877
26332
  SetupNetsuitePlugin,
26333
+ SetupAuthenticationPlugin,
25878
26334
  ScaffoldPlugin,
25879
26335
  RemovePlugin,
25880
26336
  InspectHttpPlugin