@helloleo/plugins 0.2.0-beta.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24964,7 +24964,7 @@ function authLibTs(providers) {
24964
24964
  const oauth = providers.filter((p) => p !== "password");
24965
24965
  const envFields = [
24966
24966
  "BETTER_AUTH_SECRET?: string",
24967
- "BETTER_AUTH_URL?: string",
24967
+ "HELLOLEO_URL?: string",
24968
24968
  ...oauth.flatMap((p) => {
24969
24969
  const prefix = p.toUpperCase();
24970
24970
  return [
@@ -25011,7 +25011,7 @@ function buildAuth() {
25011
25011
 
25012
25012
  return betterAuth({
25013
25013
  secret: e.BETTER_AUTH_SECRET,
25014
- baseURL: e.BETTER_AUTH_URL,
25014
+ baseURL: e.HELLOLEO_URL || 'http://localhost:3000',
25015
25015
  database: drizzleAdapter(db, { provider: 'sqlite', schema }),
25016
25016
  emailAndPassword: {
25017
25017
  enabled: ${hasPassword},
@@ -25438,8 +25438,9 @@ var setupAuthentication = tool({
25438
25438
  const schemaAppended = await appendAuthTables(dir);
25439
25439
  const depsAdded = await addAuthDependencies(dir);
25440
25440
  const envLines = [
25441
- " - dev (.env): BETTER_AUTH_SECRET=<random>, BETTER_AUTH_URL=http://localhost:3000",
25442
- " - prod: `wrangler secret put BETTER_AUTH_SECRET`, and BETTER_AUTH_URL as a var in wrangler.jsonc"
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"
25443
25444
  ];
25444
25445
  for (const p of oauth) {
25445
25446
  const prefix = p.toUpperCase();
@@ -25477,247 +25478,619 @@ var SetupAuthenticationPlugin = async () => {
25477
25478
  };
25478
25479
  };
25479
25480
  // src/tools/setup-netsuite.ts
25480
- import { mkdir as mkdir2 } from "fs/promises";
25481
+ import { mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
25481
25482
  import path7 from "path";
25482
- function netsuiteTs() {
25483
+ function schemaAppend() {
25484
+ return `
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(),
25492
+ createdAt: integer('created_at')
25493
+ .notNull()
25494
+ .default(sql\`(unixepoch())\`),
25495
+ })
25496
+
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
+ })
25511
+ `;
25512
+ }
25513
+ function netsuiteServerTs() {
25483
25514
  return `/*
25484
- * NetSuite OAuth client helper (bearer-token-in-fragment).
25515
+ * NetSuite runtime OAuth -- server side (TanStack Start server routes).
25485
25516
  *
25486
- * Two NetSuite Integration Records back this:
25487
- * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
25488
- * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
25489
- * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
25490
- * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
25491
- * the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
25492
- * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
25493
- * third-party cookies in the HelloLeo preview iframe.
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.
25494
25520
  *
25495
- * Flow:
25496
- * 1. User clicks "Sign in with NetSuite".
25497
- * 2. Browser navigates to the Worker /auth/netsuite/start.
25498
- * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
25499
- * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
25500
- * 5. Worker exchanges the code, mints a session id, then redirects to
25501
- * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
25502
- * 6. This module reads the fragment on load, stashes the sid in
25503
- * sessionStorage, and sends it as Authorization: Bearer on every
25504
- * Worker call.
25505
- *
25506
- * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
25507
- * authoritative auth state -- the only acceptable check is /api/me on the
25508
- * Worker. sessionStorage here is just a cache of the bearer token.
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.
25509
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'
25510
25530
 
25511
- export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
25512
- const TOKEN_KEY = 'nssid';
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
25513
25539
 
25514
- // Capture-and-strip the #nssid=... fragment as early as possible so URLs
25515
- // shown to the user stay clean. Runs once at module load.
25516
- (function bootstrapTokenFromHash() {
25517
- if (typeof window === 'undefined') return;
25518
- const hash = window.location.hash.replace(/^#/, '');
25519
- if (!hash) return;
25520
- const params = new URLSearchParams(hash);
25521
- const sid = params.get(TOKEN_KEY);
25522
- if (sid) {
25523
- try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
25524
- params.delete(TOKEN_KEY);
25525
- const remaining = params.toString();
25526
- const newHash = remaining ? '#' + remaining : '';
25527
- window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
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) {
25548
+ throw new Error(
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.',
25550
+ )
25551
+ }
25552
+ return { accountId, clientId, clientSecret: nsEnv.NETSUITE_CLIENT_SECRET || '' }
25553
+ }
25554
+
25555
+ // Account id -> DNS host form: lowercase, underscores to hyphens.
25556
+ function host(accountId: string) {
25557
+ return accountId.trim().toLowerCase().replace(/_/g, '-')
25558
+ }
25559
+ export function authorizeUrl(accountId: string) {
25560
+ return \`https://\${host(accountId)}.app.netsuite.com/app/login/oauth2/authorize.nl\`
25561
+ }
25562
+ export function tokenUrl(accountId: string) {
25563
+ return \`https://\${host(accountId)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token\`
25564
+ }
25565
+ export function restBase(accountId: string) {
25566
+ return \`https://\${host(accountId)}.suitetalk.api.netsuite.com\`
25567
+ }
25568
+
25569
+ function stripTrailingSlash(s: string) {
25570
+ return s.endsWith('/') ? s.slice(0, -1) : s
25571
+ }
25572
+
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\`
25596
+ }
25597
+
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
25528
25609
  }
25529
- })();
25610
+ }
25611
+
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 }
25623
+ }
25624
+
25625
+ interface TokenResponse {
25626
+ access_token?: string
25627
+ refresh_token?: string
25628
+ expires_in?: number
25629
+ error?: string
25630
+ error_description?: string
25631
+ }
25632
+
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
25639
+ }
25640
+
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
25651
+ try {
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
25660
+ } catch {
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 }
25665
+ }
25666
+ return data
25667
+ }
25668
+
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))
25678
+ }
25679
+
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))
25684
+ }
25685
+
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
25694
+ }
25695
+
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 })
25748
+ }
25749
+ `;
25750
+ }
25751
+ function startRouteTs() {
25752
+ return `import { createFileRoute } from '@tanstack/react-router'
25753
+ import { db } from '#/db'
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'
25763
+
25764
+ // GET /auth/netsuite/start -- begin the NetSuite OAuth (PKCE) flow.
25765
+ export const Route = createFileRoute('/auth/netsuite/start')({
25766
+ server: {
25767
+ handlers: {
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'))
25773
+
25774
+ const { verifier, challenge } = await pkce()
25775
+ const flowId = crypto.randomUUID()
25776
+ await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
25777
+
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')
25786
+
25787
+ return new Response(null, { status: 302, headers: { Location: authorize.toString() } })
25788
+ },
25789
+ },
25790
+ },
25791
+ })
25792
+ `;
25793
+ }
25794
+ function callbackRouteTs() {
25795
+ return `import { createFileRoute } from '@tanstack/react-router'
25796
+ import { eq } from 'drizzle-orm'
25797
+ import { db } from '#/db'
25798
+ import { netsuiteFlows, netsuiteSessions } from '#/db/schema'
25799
+ import { exchangeCode, requireConfig } from '#/lib/netsuite-server'
25800
+
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')({
25805
+ server: {
25806
+ handlers: {
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 })
25830
+ }
25831
+
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
+ )
25838
+ }
25839
+
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() } })
25853
+ },
25854
+ },
25855
+ },
25856
+ })
25857
+ `;
25858
+ }
25859
+ function logoutRouteTs() {
25860
+ return `import { createFileRoute } from '@tanstack/react-router'
25861
+ import { bearer, deleteSession } from '#/lib/netsuite-server'
25862
+
25863
+ // POST /auth/netsuite/logout -- destroy the current session.
25864
+ export const Route = createFileRoute('/auth/netsuite/logout')({
25865
+ server: {
25866
+ handlers: {
25867
+ POST: async ({ request }) => {
25868
+ await deleteSession(bearer(request))
25869
+ return Response.json({ ok: true })
25870
+ },
25871
+ },
25872
+ },
25873
+ })
25874
+ `;
25875
+ }
25876
+ function meRouteTs() {
25877
+ return `import { createFileRoute } from '@tanstack/react-router'
25878
+ import { bearer, getSession } from '#/lib/netsuite-server'
25879
+
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')({
25883
+ server: {
25884
+ handlers: {
25885
+ GET: async ({ request }) => {
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
+ })
25892
+ },
25893
+ },
25894
+ },
25895
+ })
25896
+ `;
25897
+ }
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() {
25927
+ return `/*
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.
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
+ })()
25530
25963
 
25531
25964
  export function getSessionToken(): string | null {
25532
- try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
25965
+ try {
25966
+ return sessionStorage.getItem(TOKEN_KEY)
25967
+ } catch {
25968
+ return null
25969
+ }
25533
25970
  }
25534
25971
 
25535
25972
  export function signInWithNetSuite(returnTo: string = window.location.href) {
25536
- if (!WORKER_URL) {
25537
- throw new Error(
25538
- 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
25539
- );
25540
- }
25541
- const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
25542
- url.searchParams.set('return_to', returnTo);
25543
- // The app usually renders inside the HelloLeo preview iframe. NetSuite's
25544
- // OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
25545
- // Worker (which 302s to NetSuite) ends up loading a page the browser
25546
- // refuses to render -- looks like "nothing happens" to the user. Navigate
25547
- // the top frame instead so the OAuth flow runs at the top level and the
25548
- // callback redirect lands on the same top frame, which then reads the
25549
- // #nssid= fragment. Use \`location.href =\` (NOT \`location.assign()\`):
25550
- // reading the \`.assign\` method off a cross-origin \`window.top.location\`
25551
- // throws a SecurityError, which would drop us into the same-frame fallback
25552
- // and load NetSuite inside the iframe (X-Frame-Options: DENY -> "refused to
25553
- // connect"). Assigning \`.href\` is a navigation, allowed cross-origin.
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").
25554
25981
  try {
25555
25982
  if (window.top && window.top !== window) {
25556
- window.top.location.href = url.toString();
25557
- return;
25983
+ window.top.location.href = url.toString()
25984
+ return
25558
25985
  }
25559
25986
  } catch {
25560
- /* top window not accessible \u2014 fall through to same-frame navigation */
25987
+ /* top window unreachable -- fall through to same-frame navigation */
25561
25988
  }
25562
- window.location.href = url.toString();
25989
+ window.location.href = url.toString()
25563
25990
  }
25564
25991
 
25565
25992
  export interface NetSuiteSession {
25566
- authenticated: boolean;
25567
- account_id?: string;
25568
- user_id?: string;
25569
- email?: string;
25993
+ authenticated: boolean
25994
+ account_id?: string
25570
25995
  }
25571
25996
 
25572
25997
  export async function getNetSuiteSession(): Promise<NetSuiteSession> {
25573
- if (!WORKER_URL) return { authenticated: false };
25574
- const sid = getSessionToken();
25575
- if (!sid) return { authenticated: false };
25576
- const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
25577
- headers: { Authorization: 'Bearer ' + sid },
25578
- });
25579
- if (!res.ok) return { authenticated: false };
25580
- const data = await res.json();
25581
- return { authenticated: Boolean(data?.account_id), ...data };
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 }
26002
+ const data = await res.json()
26003
+ return { authenticated: Boolean(data?.authenticated), ...data }
25582
26004
  }
25583
26005
 
25584
26006
  export async function signOutNetSuite(): Promise<void> {
25585
- if (!WORKER_URL) return;
25586
- const sid = getSessionToken();
26007
+ const sid = getSessionToken()
25587
26008
  if (sid) {
25588
26009
  try {
25589
- await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
26010
+ await fetch('/auth/netsuite/logout', {
25590
26011
  method: 'POST',
25591
26012
  headers: { Authorization: 'Bearer ' + sid },
25592
- });
26013
+ })
25593
26014
  } catch {}
25594
26015
  }
25595
- try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
25596
- window.location.reload();
26016
+ try {
26017
+ sessionStorage.removeItem(TOKEN_KEY)
26018
+ } catch {}
26019
+ window.location.reload()
25597
26020
  }
25598
26021
 
25599
26022
  /**
25600
- * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
25601
- * Always go through this instead of bare fetch -- it injects the bearer
25602
- * token and routes through the Worker so NetSuite tokens stay server-side.
26023
+ * Fetch YOUR data routes (src/routes/api/*) with the bearer attached. Use this
26024
+ * instead of bare fetch for authenticated calls.
25603
26025
  */
25604
- export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
25605
- if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
25606
- const url = pathOrUrl.startsWith('http')
25607
- ? pathOrUrl
25608
- : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
25609
- const sid = getSessionToken();
25610
- const headers = new Headers(init.headers ?? {});
25611
- if (sid) headers.set('Authorization', 'Bearer ' + sid);
25612
- return fetch(url, { ...init, headers });
25613
- }
25614
- `;
25615
- }
25616
- function signInButtonTsx() {
25617
- return `import { signInWithNetSuite } from '../../lib/netsuite';
25618
-
25619
- export function SignInWithNetSuite() {
25620
- return (
25621
- <button
25622
- onClick={() => signInWithNetSuite()}
25623
- 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"
25624
- >
25625
- Sign in with NetSuite
25626
- </button>
25627
- );
25628
- }
25629
- `;
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 })
25630
26031
  }
25631
- function netsuiteLoginPageTsx() {
25632
- return `import { SignInWithNetSuite } from './SignInWithNetSuite';
25633
26032
 
25634
26033
  /**
25635
- * Default unauthenticated landing page. Replaces any password gate.
25636
- * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
25637
- * only call-to-action -- it points to the Worker's /auth/netsuite/start.
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.
25638
26037
  */
25639
- export function LoginPage() {
25640
- return (
25641
- <div className="min-h-screen bg-background text-foreground flex">
25642
- <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
25643
- <div className="text-xl font-semibold">NetSuite App</div>
25644
- <div>
25645
- <h1 className="text-4xl font-bold leading-tight">
25646
- Connect your NetSuite account
25647
- </h1>
25648
- <p className="mt-4 text-primary-foreground/80 max-w-md">
25649
- Sign in with your NetSuite credentials to access your data securely.
25650
- Tokens stay server-side on HelloLeo Cloud -- they never reach the
25651
- browser.
25652
- </p>
25653
- </div>
25654
- <div className="text-xs text-primary-foreground/60">
25655
- Powered by HelloLeo
25656
- </div>
25657
- </div>
25658
- <div className="flex-1 flex items-center justify-center px-6 py-12">
25659
- <div className="w-full max-w-sm space-y-6">
25660
- <div>
25661
- <h2 className="text-2xl font-semibold">Welcome</h2>
25662
- <p className="mt-2 text-sm text-muted-foreground">
25663
- Use the button below to sign in. You'll be redirected to NetSuite
25664
- to authorise access, then back here.
25665
- </p>
25666
- </div>
25667
- <SignInWithNetSuite />
25668
- </div>
25669
- </div>
25670
- </div>
25671
- );
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
+ }
25672
26047
  }
25673
26048
  `;
25674
26049
  }
25675
26050
  function authTsx() {
25676
- return `import * as React from 'react';
26051
+ return `import * as React from 'react'
25677
26052
  import {
25678
26053
  getNetSuiteSession,
25679
26054
  signInWithNetSuite,
25680
26055
  signOutNetSuite,
25681
26056
  type NetSuiteSession,
25682
- } from './netsuite';
26057
+ } from './netsuite'
25683
26058
 
25684
26059
  /**
25685
- * NetSuite auth context. Holds the current session (resolved from the Worker's
25686
- * /api/me via getNetSuiteSession) and exposes sign-in / sign-out. The only
25687
- * authoritative auth check is /api/me -- never trust sessionStorage directly.
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.
25688
26063
  */
25689
26064
  export interface NetSuiteAuthContextValue {
25690
- pending: boolean;
25691
- isAuthenticated: boolean;
25692
- session: NetSuiteSession | null;
25693
- signIn: () => void;
25694
- signOut: () => Promise<void>;
25695
- refresh: () => Promise<void>;
26065
+ pending: boolean
26066
+ isAuthenticated: boolean
26067
+ session: NetSuiteSession | null
26068
+ signIn: () => void
26069
+ signOut: () => Promise<void>
26070
+ refresh: () => Promise<void>
25696
26071
  }
25697
26072
 
25698
- const NetSuiteAuthContext =
25699
- React.createContext<NetSuiteAuthContextValue | null>(null);
26073
+ const NetSuiteAuthContext = React.createContext<NetSuiteAuthContextValue | null>(null)
25700
26074
 
25701
- export function NetSuiteAuthProvider({
25702
- children,
25703
- }: {
25704
- children: React.ReactNode;
25705
- }) {
25706
- const [session, setSession] = React.useState<NetSuiteSession | null>(null);
25707
- const [pending, setPending] = React.useState(true);
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)
25708
26081
 
25709
26082
  const refresh = React.useCallback(async () => {
25710
- setPending(true);
26083
+ setPending(true)
25711
26084
  try {
25712
- setSession(await getNetSuiteSession());
26085
+ setSession(await getNetSuiteSession())
25713
26086
  } finally {
25714
- setPending(false);
26087
+ setPending(false)
25715
26088
  }
25716
- }, []);
26089
+ }, [])
25717
26090
 
25718
26091
  React.useEffect(() => {
25719
- refresh();
25720
- }, [refresh]);
26092
+ refresh()
26093
+ }, [refresh])
25721
26094
 
25722
26095
  const value: NetSuiteAuthContextValue = {
25723
26096
  pending,
@@ -25726,126 +26099,226 @@ export function NetSuiteAuthProvider({
25726
26099
  signIn: () => signInWithNetSuite(),
25727
26100
  signOut: signOutNetSuite,
25728
26101
  refresh,
25729
- };
26102
+ }
25730
26103
 
25731
- return (
25732
- <NetSuiteAuthContext.Provider value={value}>
25733
- {children}
25734
- </NetSuiteAuthContext.Provider>
25735
- );
26104
+ return <NetSuiteAuthContext.Provider value={value}>{children}</NetSuiteAuthContext.Provider>
25736
26105
  }
25737
26106
 
25738
26107
  export function useNetSuiteAuth(): NetSuiteAuthContextValue {
25739
- const ctx = React.useContext(NetSuiteAuthContext);
26108
+ const ctx = React.useContext(NetSuiteAuthContext)
25740
26109
  if (!ctx) {
25741
- throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>');
26110
+ throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>')
25742
26111
  }
25743
- return ctx;
26112
+ return ctx
25744
26113
  }
25745
26114
  `;
25746
26115
  }
25747
26116
  function authLayoutTsx() {
25748
- return `import type { ReactNode } from 'react';
25749
- import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth';
25750
- import { LoginPage } from './LoginPage';
26117
+ return `import type { ReactNode } from 'react'
26118
+ import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth'
26119
+ import { LoginPage } from './LoginPage'
25751
26120
 
25752
26121
  /**
25753
- * NetSuite auth layout. This is the ONLY wiring you need to add by hand --
25754
- * wrap the root route's <Outlet /> with it:
26122
+ * NetSuite auth layout. The ONE manual wiring step: wrap the root route's
26123
+ * <Outlet /> with it in src/routes/__root.tsx:
25755
26124
  *
25756
- * // src/routes/__root.tsx
25757
- * import { AuthLayout } from '../components/auth/AuthLayout';
26125
+ * import { AuthLayout } from '../components/auth/AuthLayout'
25758
26126
  * ...
25759
26127
  * function RootComponent() {
25760
26128
  * return (
25761
- * <>
26129
+ * <RootDocument>
25762
26130
  * <AuthLayout>
25763
26131
  * <Outlet />
25764
26132
  * </AuthLayout>
25765
- * { ...devtools... }
25766
- * </>
25767
- * );
26133
+ * </RootDocument>
26134
+ * )
25768
26135
  * }
25769
26136
  *
25770
- * It provides NetSuite auth context to the whole tree and gates rendering:
25771
- * while the session resolves it shows a spinner, unauthenticated users see
25772
- * <LoginPage />, authenticated users see your routes. Use useNetSuiteAuth()
25773
- * anywhere below it to read the session or sign out. NEVER swap this for a
25774
- * password gate -- real OAuth is the only supported login.
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.
25775
26140
  */
25776
26141
  export function AuthLayout({ children }: { children: ReactNode }) {
25777
26142
  return (
25778
26143
  <NetSuiteAuthProvider>
25779
26144
  <Gate>{children}</Gate>
25780
26145
  </NetSuiteAuthProvider>
25781
- );
26146
+ )
25782
26147
  }
25783
26148
 
25784
26149
  function Gate({ children }: { children: ReactNode }) {
25785
- const { pending, isAuthenticated } = useNetSuiteAuth();
26150
+ const { pending, isAuthenticated } = useNetSuiteAuth()
25786
26151
 
25787
26152
  if (pending) {
25788
26153
  return (
25789
26154
  <div className="min-h-screen flex items-center justify-center bg-background">
25790
26155
  <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
25791
26156
  </div>
25792
- );
26157
+ )
25793
26158
  }
25794
26159
 
25795
- if (!isAuthenticated) return <LoginPage />;
26160
+ if (!isAuthenticated) return <LoginPage />
26161
+
26162
+ return <>{children}</>
26163
+ }
26164
+ `;
26165
+ }
26166
+ function loginPageTsx2() {
26167
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite'
25796
26168
 
25797
- return <>{children}</>;
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() {
26175
+ return (
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.
26195
+ </p>
26196
+ </div>
26197
+ <SignInWithNetSuite />
26198
+ </div>
26199
+ </div>
26200
+ </div>
26201
+ )
26202
+ }
26203
+ `;
26204
+ }
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
+ )
25798
26217
  }
25799
26218
  `;
25800
26219
  }
25801
26220
  var setupNetsuite = tool({
25802
- 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.",
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",
25803
26222
  args: {},
25804
26223
  async execute(_args, context) {
25805
26224
  const dir = context.directory;
26225
+ await mkdir2(path7.join(dir, "src", "lib"), { recursive: true });
25806
26226
  await mkdir2(path7.join(dir, "src", "components", "auth"), {
25807
26227
  recursive: true
25808
26228
  });
25809
- const nsFiles = [
25810
- [path7.join("src", "lib", "netsuite.ts"), netsuiteTs()],
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()],
25811
26267
  [path7.join("src", "lib", "auth.tsx"), authTsx()],
25812
26268
  [
25813
26269
  path7.join("src", "components", "auth", "AuthLayout.tsx"),
25814
26270
  authLayoutTsx()
25815
26271
  ],
25816
- [
25817
- path7.join("src", "components", "auth", "LoginPage.tsx"),
25818
- netsuiteLoginPageTsx()
25819
- ],
26272
+ [path7.join("src", "components", "auth", "LoginPage.tsx"), loginPageTsx2()],
25820
26273
  [
25821
26274
  path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
25822
26275
  signInButtonTsx()
25823
26276
  ]
25824
26277
  ];
25825
- for (const [filePath, content] of nsFiles) {
26278
+ for (const [filePath, content] of files) {
25826
26279
  await Bun.write(path7.join(dir, filePath), content);
25827
26280
  }
25828
26281
  return [
25829
- `Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
25830
- " - src/lib/netsuite.ts (OAuth client: signIn, session, workerFetch)",
25831
- " - src/lib/auth.tsx (NetSuiteAuthProvider and useNetSuiteAuth)",
25832
- " - src/components/auth/AuthLayout.tsx (provider and auth gate)",
25833
- " - src/components/auth/LoginPage.tsx (unauthenticated view)",
25834
- " - src/components/auth/SignInWithNetSuite.tsx (sign-in button)",
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",
25835
26286
  "",
25836
- "Notes:",
25837
- "- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
25838
- "- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
25839
- "- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
25840
- "- 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).",
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",
25841
26299
  "",
25842
- "Next: in src/routes/__root.tsx, import AuthLayout and wrap <Outlet /> with it. Keep any existing providers.",
26300
+ "WIRE IT UP (one manual step): in src/routes/__root.tsx, wrap <Outlet /> with <AuthLayout>:",
25843
26301
  "",
25844
26302
  " import { AuthLayout } from '../components/auth/AuthLayout'",
25845
26303
  "",
25846
26304
  " <AuthLayout>",
25847
26305
  " <Outlet />",
25848
- " </AuthLayout>"
26306
+ " </AuthLayout>",
26307
+ "",
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.",
26311
+ "",
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).",
26315
+ "",
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."
25849
26322
  ].join(`
25850
26323
  `);
25851
26324
  }