@helloleo/plugins 0.2.7 → 0.2.8

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
@@ -25507,620 +25507,247 @@ var SetupAuthenticationPlugin = async () => {
25507
25507
  };
25508
25508
  };
25509
25509
  // src/tools/setup-netsuite.ts
25510
- import { mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
25510
+ import { mkdir as mkdir2 } from "fs/promises";
25511
25511
  import path7 from "path";
25512
- function schemaAppend() {
25513
- return `
25514
- // --- NetSuite OAuth (added by setup_netsuite) ---
25515
- // Short-lived PKCE + return-to state between /auth/netsuite/start and its
25516
- // callback. Single-use: the callback deletes the row after reading it.
25517
- export const netsuiteFlows = sqliteTable('netsuite_flows', {
25518
- flowId: text('flow_id').primaryKey(),
25519
- codeVerifier: text('code_verifier').notNull(),
25520
- returnTo: text('return_to').notNull(),
25521
- createdAt: integer('created_at')
25522
- .notNull()
25523
- .default(sql\`(unixepoch())\`),
25524
- })
25525
-
25526
- // Opaque session id -> NetSuite tokens. Tokens stay server-side; the browser
25527
- // only ever holds the sid (sent as a bearer).
25528
- export const netsuiteSessions = sqliteTable('netsuite_sessions', {
25529
- sid: text('sid').primaryKey(),
25530
- accountId: text('account_id').notNull(),
25531
- accessToken: text('access_token').notNull(),
25532
- refreshToken: text('refresh_token'),
25533
- expiresAt: integer('expires_at'),
25534
- userId: text('user_id'),
25535
- email: text('email'),
25536
- createdAt: integer('created_at')
25537
- .notNull()
25538
- .default(sql\`(unixepoch())\`),
25539
- })
25540
- `;
25541
- }
25542
- function netsuiteServerTs() {
25512
+ function netsuiteTs() {
25543
25513
  return `/*
25544
- * NetSuite runtime OAuth -- server side (TanStack Start server routes).
25514
+ * NetSuite OAuth client helper (bearer-token-in-fragment).
25545
25515
  *
25546
- * Runs wherever the app runs: \`vite dev\` (workerd) in the Fly preview machine
25547
- * in dev, and the published Cloudflare Worker in prod. There is no separate
25548
- * backend Worker -- these helpers back the /auth/netsuite/* + /api/me routes.
25549
- *
25550
- * Config is read from the Cloudflare env binding via @helloleo/runtime.
25551
- * Set NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET (secret optional for a
25552
- * PKCE public client) and optional PUBLIC_APP_URL as HelloLeo Cloud secrets
25553
- * (prod) or dev vars (preview). They never reach the browser.
25554
- */
25555
- import { eq } from 'drizzle-orm'
25556
- import { env as runtimeEnv } from '@helloleo/runtime'
25557
- import { db } from '#/db'
25558
- import { netsuiteSessions } from '#/db/schema'
25559
-
25560
- interface NsEnv {
25561
- NETSUITE_ACCOUNT_ID?: string
25562
- NETSUITE_CLIENT_ID?: string
25563
- NETSUITE_CLIENT_SECRET?: string
25564
- PUBLIC_APP_URL?: string
25565
- HELLOLEO_URL?: string
25566
- }
25567
- const nsEnv = runtimeEnv as unknown as NsEnv
25568
-
25569
- // Runtime record scope. The app signs end-users in for REST/SuiteQL.
25570
- export const SCOPE = 'rest_webservices'
25571
- const REFRESH_LEEWAY_SECONDS = 60
25572
-
25573
- export function requireConfig() {
25574
- const accountId = nsEnv.NETSUITE_ACCOUNT_ID
25575
- const clientId = nsEnv.NETSUITE_CLIENT_ID
25576
- if (!accountId || !clientId) {
25577
- throw new Error(
25578
- '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.',
25579
- )
25580
- }
25581
- return { accountId, clientId, clientSecret: nsEnv.NETSUITE_CLIENT_SECRET || '' }
25582
- }
25583
-
25584
- // Account id -> DNS host form: lowercase, underscores to hyphens.
25585
- function host(accountId: string) {
25586
- return accountId.trim().toLowerCase().replace(/_/g, '-')
25587
- }
25588
- export function authorizeUrl(accountId: string) {
25589
- return \`https://\${host(accountId)}.app.netsuite.com/app/login/oauth2/authorize.nl\`
25590
- }
25591
- export function tokenUrl(accountId: string) {
25592
- return \`https://\${host(accountId)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token\`
25593
- }
25594
- export function restBase(accountId: string) {
25595
- return \`https://\${host(accountId)}.suitetalk.api.netsuite.com\`
25596
- }
25597
-
25598
- function stripTrailingSlash(s: string) {
25599
- return s.endsWith('/') ? s.slice(0, -1) : s
25600
- }
25601
-
25602
- // Public origin of THIS app, used to build the redirect URI. Prefer an explicit
25603
- // PUBLIC_APP_URL; else derive from the request (honouring the preview proxy's
25604
- // forwarded headers). Must match a redirect URI registered on the NetSuite
25605
- // Integration Record exactly.
25606
- export function appOrigin(request: Request) {
25607
- // 1) Explicit override wins (set by the platform per environment).
25608
- if (nsEnv.PUBLIC_APP_URL) return stripTrailingSlash(nsEnv.PUBLIC_APP_URL)
25609
- // 2) HELLOLEO_URL is injected on every deploy (the app's canonical origin,
25610
- // a bare host with no scheme), so it's set even when the NetSuite
25611
- // wizard's PUBLIC_APP_URL pin wasn't.
25612
- if (nsEnv.HELLOLEO_URL) return \`https://\${stripTrailingSlash(nsEnv.HELLOLEO_URL)}\`
25613
- // 3) Preview/prod proxies rewrite Host, so trust the forwarded/original host
25614
- // they set (HelloLeo's nginx sends X-Original-Host) \u2014 that's the public URL
25615
- // the browser loaded, not an internal upstream host.
25616
- const url = new URL(request.url)
25617
- const proto = request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')
25618
- const hostHeader =
25619
- request.headers.get('x-forwarded-host') ||
25620
- request.headers.get('x-original-host') ||
25621
- url.host
25622
- return \`\${proto}://\${hostHeader}\`
25623
- }
25624
- export function redirectUri(request: Request) {
25625
- return \`\${appOrigin(request)}/auth/netsuite/callback\`
25626
- }
25627
-
25628
- // Coerce ?return_to= to our OWN origin. The callback appends the session bearer
25629
- // as a #nssid=... fragment; an off-origin return_to would leak that live
25630
- // credential to an attacker-controlled page (open-redirect -> token theft).
25631
- export function safeReturnTo(request: Request, raw: string | null): string {
25632
- const origin = appOrigin(request)
25633
- if (!raw) return origin
25634
- try {
25635
- const u = new URL(raw, origin)
25636
- return u.origin === origin ? u.toString() : origin
25637
- } catch {
25638
- return origin
25639
- }
25640
- }
25641
-
25642
- // --- PKCE (Web Crypto: works on workerd and Node) ---
25643
- function b64url(bytes: Uint8Array) {
25644
- let s = ''
25645
- for (const b of bytes) s += String.fromCharCode(b)
25646
- return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
25647
- }
25648
- export async function pkce() {
25649
- const verifier = b64url(crypto.getRandomValues(new Uint8Array(48)))
25650
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
25651
- const challenge = b64url(new Uint8Array(digest))
25652
- return { verifier, challenge }
25653
- }
25654
-
25655
- interface TokenResponse {
25656
- access_token?: string
25657
- refresh_token?: string
25658
- expires_in?: number
25659
- error?: string
25660
- error_description?: string
25661
- }
25662
-
25663
- // Confidential client -> HTTP Basic. Public (PKCE-only) -> client_id in body.
25664
- function authHeaders(clientId: string, clientSecret: string, params: Record<string, string>) {
25665
- const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded' }
25666
- if (clientSecret) headers.Authorization = 'Basic ' + btoa(\`\${clientId}:\${clientSecret}\`)
25667
- else params.client_id = clientId
25668
- return headers
25669
- }
25670
-
25671
- // POST the token endpoint and parse tolerantly. NetSuite returns JSON for both
25672
- // success and OAuth errors, but a gateway (502/504) can return HTML -- never let
25673
- // res.json() throw into the route handler (that would surface as an opaque 500
25674
- // instead of a clean "token exchange failed").
25675
- async function postToken(
25676
- accountId: string,
25677
- params: Record<string, string>,
25678
- headers: Record<string, string>,
25679
- ): Promise<TokenResponse> {
25680
- let res: Response
25681
- try {
25682
- res = await fetch(tokenUrl(accountId), { method: 'POST', headers, body: new URLSearchParams(params) })
25683
- } catch (e) {
25684
- return { error: 'network_error', error_description: String(e) }
25685
- }
25686
- const text = await res.text()
25687
- let data: TokenResponse = {}
25688
- try {
25689
- data = JSON.parse(text) as TokenResponse
25690
- } catch {
25691
- // non-JSON body (e.g. gateway HTML) -- leave data empty, fall through
25692
- }
25693
- if (!res.ok && !data.error) {
25694
- data = { error: 'token_request_failed', error_description: 'HTTP ' + res.status }
25695
- }
25696
- return data
25697
- }
25698
-
25699
- export async function exchangeCode(request: Request, code: string, verifier: string) {
25700
- const { accountId, clientId, clientSecret } = requireConfig()
25701
- const params: Record<string, string> = {
25702
- grant_type: 'authorization_code',
25703
- code,
25704
- redirect_uri: redirectUri(request),
25705
- code_verifier: verifier,
25706
- }
25707
- return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
25708
- }
25709
-
25710
- async function refreshToken(refresh: string) {
25711
- const { accountId, clientId, clientSecret } = requireConfig()
25712
- const params: Record<string, string> = { grant_type: 'refresh_token', refresh_token: refresh }
25713
- return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
25714
- }
25715
-
25716
- export interface NsSession {
25717
- sid: string
25718
- accountId: string
25719
- accessToken: string
25720
- refreshToken: string | null
25721
- expiresAt: number | null
25722
- userId: string | null
25723
- email: string | null
25724
- }
25725
-
25726
- // Load a session by sid, transparently refreshing the access token when it's
25727
- // within the leeway of expiry. Returns null if unknown or unrefreshable.
25728
- export async function getSession(sid: string): Promise<NsSession | null> {
25729
- if (!sid) return null
25730
- const rows = await db.select().from(netsuiteSessions).where(eq(netsuiteSessions.sid, sid)).limit(1)
25731
- const s = rows[0]
25732
- if (!s) return null
25733
- const now = Math.floor(Date.now() / 1000)
25734
- // A missing expiry is treated as EXPIRED (force a refresh) rather than trusting
25735
- // the token forever -- NetSuite normally sends expires_in, so this is defensive.
25736
- const expired = s.expiresAt == null ? true : now + REFRESH_LEEWAY_SECONDS >= s.expiresAt
25737
- if (expired) {
25738
- // Token expired (or about to). It MUST refresh; if it can't, the session is
25739
- // dead \u2014 delete it and return null so /api/me reports signed-out and the user
25740
- // re-auths, rather than a "logged in" app whose every NetSuite call 401s.
25741
- if (!s.refreshToken) { await deleteSession(sid); return null }
25742
- const tok = await refreshToken(s.refreshToken)
25743
- if (!tok.access_token) { await deleteSession(sid); return null }
25744
- const expiresAt = now + (tok.expires_in || 3600)
25745
- const refreshTok = tok.refresh_token ?? s.refreshToken
25746
- await db
25747
- .update(netsuiteSessions)
25748
- .set({ accessToken: tok.access_token, refreshToken: refreshTok, expiresAt })
25749
- .where(eq(netsuiteSessions.sid, sid))
25750
- return { ...s, accessToken: tok.access_token, refreshToken: refreshTok, expiresAt }
25751
- }
25752
- return s
25753
- }
25754
-
25755
- export async function deleteSession(sid: string) {
25756
- if (sid) await db.delete(netsuiteSessions).where(eq(netsuiteSessions.sid, sid))
25757
- }
25758
-
25759
- export function bearer(request: Request) {
25760
- const h = request.headers.get('authorization') || ''
25761
- return h.startsWith('Bearer ') ? h.slice(7) : ''
25762
- }
25763
-
25764
- /**
25765
- * Call NetSuite REST / SuiteQL for the signed-in session. Use this from your
25766
- * data routes (src/routes/api/*). \`path\` is relative to the account's
25767
- * suitetalk host, e.g. '/services/rest/query/v1/suiteql'. Returns the raw
25768
- * fetch Response (401 if the sid is unknown). Access token is auto-refreshed.
25769
- */
25770
- export async function netsuiteFetch(sid: string, path: string, init: RequestInit = {}): Promise<Response> {
25771
- const session = await getSession(sid)
25772
- if (!session) return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })
25773
- const url = path.startsWith('http') ? path : \`\${restBase(session.accountId)}\${path}\`
25774
- const headers = new Headers(init.headers)
25775
- headers.set('Authorization', \`Bearer \${session.accessToken}\`)
25776
- if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
25777
- return fetch(url, { ...init, headers })
25778
- }
25779
- `;
25780
- }
25781
- function startRouteTs() {
25782
- return `import { createFileRoute } from '@tanstack/react-router'
25783
- import { db } from '#/db'
25784
- import { netsuiteFlows } from '#/db/schema'
25785
- import {
25786
- authorizeUrl,
25787
- pkce,
25788
- redirectUri,
25789
- requireConfig,
25790
- safeReturnTo,
25791
- SCOPE,
25792
- } from '#/lib/netsuite-server'
25793
-
25794
- // GET /auth/netsuite/start -- begin the NetSuite OAuth (PKCE) flow.
25795
- export const Route = createFileRoute('/auth/netsuite/start')({
25796
- server: {
25797
- handlers: {
25798
- GET: async ({ request }) => {
25799
- const { accountId, clientId } = requireConfig()
25800
- const url = new URL(request.url)
25801
- // Same-origin only: the callback returns the bearer in a URL fragment.
25802
- const returnTo = safeReturnTo(request, url.searchParams.get('return_to'))
25803
-
25804
- const { verifier, challenge } = await pkce()
25805
- const flowId = crypto.randomUUID()
25806
- await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
25807
-
25808
- const authorize = new URL(authorizeUrl(accountId))
25809
- authorize.searchParams.set('response_type', 'code')
25810
- authorize.searchParams.set('client_id', clientId)
25811
- authorize.searchParams.set('redirect_uri', redirectUri(request))
25812
- authorize.searchParams.set('scope', SCOPE)
25813
- authorize.searchParams.set('state', flowId)
25814
- authorize.searchParams.set('code_challenge', challenge)
25815
- authorize.searchParams.set('code_challenge_method', 'S256')
25816
-
25817
- return new Response(null, { status: 302, headers: { Location: authorize.toString() } })
25818
- },
25819
- },
25820
- },
25821
- })
25822
- `;
25823
- }
25824
- function callbackRouteTs() {
25825
- return `import { createFileRoute } from '@tanstack/react-router'
25826
- import { eq } from 'drizzle-orm'
25827
- import { db } from '#/db'
25828
- import { netsuiteFlows, netsuiteSessions } from '#/db/schema'
25829
- import { exchangeCode, requireConfig } from '#/lib/netsuite-server'
25830
-
25831
- // GET /auth/netsuite/callback -- exchange the code, mint a session, hand the
25832
- // bearer back to the SPA via the URL fragment (#nssid=...). The fragment
25833
- // survives the HelloLeo preview iframe, where third-party cookies are dropped.
25834
- export const Route = createFileRoute('/auth/netsuite/callback')({
25835
- server: {
25836
- handlers: {
25837
- GET: async ({ request }) => {
25838
- const url = new URL(request.url)
25839
- const err = url.searchParams.get('error')
25840
- if (err) return new Response(\`NetSuite authorization failed: \${err}\`, { status: 400 })
25841
-
25842
- const code = url.searchParams.get('code')
25843
- const state = url.searchParams.get('state')
25844
- if (!code || !state) return new Response('Missing code or state', { status: 400 })
25845
-
25846
- const rows = await db
25847
- .select()
25848
- .from(netsuiteFlows)
25849
- .where(eq(netsuiteFlows.flowId, state))
25850
- .limit(1)
25851
- const flow = rows[0]
25852
- if (!flow) return new Response('Unknown or expired flow -- start again', { status: 400 })
25853
- // Single-use: burn the flow row immediately.
25854
- await db.delete(netsuiteFlows).where(eq(netsuiteFlows.flowId, state))
25855
- // Reject stale flows: the PKCE verifier must not stay valid indefinitely
25856
- // for an abandoned or leaked authorize URL (createdAt is unixepoch seconds).
25857
- const nowSec = Math.floor(Date.now() / 1000)
25858
- if (flow.createdAt && nowSec - flow.createdAt > 600) {
25859
- return new Response('Flow expired -- start again', { status: 400 })
25860
- }
25861
-
25862
- const tok = await exchangeCode(request, code, flow.codeVerifier)
25863
- if (!tok.access_token) {
25864
- return new Response(
25865
- \`Token exchange failed: \${tok.error_description || tok.error || 'unknown error'}\`,
25866
- { status: 400 },
25867
- )
25868
- }
25869
-
25870
- const sid = crypto.randomUUID()
25871
- const now = Math.floor(Date.now() / 1000)
25872
- await db.insert(netsuiteSessions).values({
25873
- sid,
25874
- accountId: requireConfig().accountId,
25875
- accessToken: tok.access_token,
25876
- refreshToken: tok.refresh_token ?? null,
25877
- expiresAt: now + (tok.expires_in || 3600),
25878
- })
25879
-
25880
- const dest = new URL(flow.returnTo)
25881
- dest.hash = \`nssid=\${sid}\`
25882
- return new Response(null, { status: 302, headers: { Location: dest.toString() } })
25883
- },
25884
- },
25885
- },
25886
- })
25887
- `;
25888
- }
25889
- function logoutRouteTs() {
25890
- return `import { createFileRoute } from '@tanstack/react-router'
25891
- import { bearer, deleteSession } from '#/lib/netsuite-server'
25892
-
25893
- // POST /auth/netsuite/logout -- destroy the current session.
25894
- export const Route = createFileRoute('/auth/netsuite/logout')({
25895
- server: {
25896
- handlers: {
25897
- POST: async ({ request }) => {
25898
- await deleteSession(bearer(request))
25899
- return Response.json({ ok: true })
25900
- },
25901
- },
25902
- },
25903
- })
25904
- `;
25905
- }
25906
- function meRouteTs() {
25907
- return `import { createFileRoute } from '@tanstack/react-router'
25908
- import { bearer, getSession } from '#/lib/netsuite-server'
25909
-
25910
- // GET /api/me -- resolve the bearer sid to a session. The ONLY authoritative
25911
- // auth check; the client must never trust sessionStorage on its own.
25912
- export const Route = createFileRoute('/api/me')({
25913
- server: {
25914
- handlers: {
25915
- GET: async ({ request }) => {
25916
- const session = await getSession(bearer(request))
25917
- if (!session) return Response.json({ authenticated: false })
25918
- return Response.json({
25919
- authenticated: true,
25920
- account_id: session.accountId,
25921
- })
25922
- },
25923
- },
25924
- },
25925
- })
25926
- `;
25927
- }
25928
- function configRouteTs() {
25929
- return `import { createFileRoute } from '@tanstack/react-router'
25930
- import { env as runtimeEnv } from '@helloleo/runtime'
25931
- import { appOrigin, redirectUri } from '#/lib/netsuite-server'
25932
-
25933
- // GET /auth/netsuite/config -- single source of truth for the redirect URI.
25934
- // Whatever this returns is EXACTLY what /auth/netsuite/start sends NetSuite, so
25935
- // show the user THIS value to register on their Integration Record. Never
25936
- // hardcode or guess a callback URL.
25937
- export const Route = createFileRoute('/auth/netsuite/config')({
25938
- server: {
25939
- handlers: {
25940
- GET: async ({ request }) => {
25941
- const e = runtimeEnv as unknown as {
25942
- NETSUITE_ACCOUNT_ID?: string
25943
- NETSUITE_CLIENT_ID?: string
25944
- }
25945
- return Response.json({
25946
- redirect_uri: redirectUri(request),
25947
- origin: appOrigin(request),
25948
- configured: Boolean(e.NETSUITE_ACCOUNT_ID && e.NETSUITE_CLIENT_ID),
25949
- })
25950
- },
25951
- },
25952
- },
25953
- })
25954
- `;
25955
- }
25956
- function netsuiteClientTs() {
25957
- return `/*
25958
- * NetSuite auth -- client (same-origin). The OAuth backend is THIS app's own
25959
- * server routes (src/routes/auth/netsuite/* + src/routes/api/me.ts), so every
25960
- * call is relative -- no separate Worker, no VITE_WORKER_URL.
25516
+ * Two NetSuite Integration Records back this:
25517
+ * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
25518
+ * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
25519
+ * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
25520
+ * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
25521
+ * the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
25522
+ * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
25523
+ * third-party cookies in the HelloLeo preview iframe.
25961
25524
  *
25962
- * The browser only ever holds an opaque session id (sid), handed back in the
25963
- * callback's URL fragment (#nssid=...) so it survives the HelloLeo preview
25964
- * iframe (third-party cookies are partitioned/dropped there). The sid rides as
25965
- * Authorization: Bearer on every call; NetSuite tokens stay server-side. The
25966
- * only authoritative auth check is GET /api/me.
25525
+ * Flow:
25526
+ * 1. User clicks "Sign in with NetSuite".
25527
+ * 2. Browser navigates to the Worker /auth/netsuite/start.
25528
+ * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
25529
+ * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
25530
+ * 5. Worker exchanges the code, mints a session id, then redirects to
25531
+ * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
25532
+ * 6. This module reads the fragment on load, stashes the sid in
25533
+ * sessionStorage, and sends it as Authorization: Bearer on every
25534
+ * Worker call.
25967
25535
  *
25968
- * NEVER use a password gate. NEVER trust sessionStorage as the auth state --
25969
- * it's just a cache of the bearer.
25536
+ * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
25537
+ * authoritative auth state -- the only acceptable check is /api/me on the
25538
+ * Worker. sessionStorage here is just a cache of the bearer token.
25970
25539
  */
25971
- const TOKEN_KEY = 'nssid'
25972
25540
 
25973
- // Capture-and-strip #nssid=... as early as possible so URLs stay clean.
25974
- ;(function bootstrapTokenFromHash() {
25975
- if (typeof window === 'undefined') return
25976
- const hash = window.location.hash.replace(/^#/, '')
25977
- if (!hash) return
25978
- const params = new URLSearchParams(hash)
25979
- const sid = params.get(TOKEN_KEY)
25541
+ export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
25542
+ const TOKEN_KEY = 'nssid';
25543
+
25544
+ // Capture-and-strip the #nssid=... fragment as early as possible so URLs
25545
+ // shown to the user stay clean. Runs once at module load.
25546
+ (function bootstrapTokenFromHash() {
25547
+ if (typeof window === 'undefined') return;
25548
+ const hash = window.location.hash.replace(/^#/, '');
25549
+ if (!hash) return;
25550
+ const params = new URLSearchParams(hash);
25551
+ const sid = params.get(TOKEN_KEY);
25980
25552
  if (sid) {
25981
- try {
25982
- sessionStorage.setItem(TOKEN_KEY, sid)
25983
- } catch {}
25984
- params.delete(TOKEN_KEY)
25985
- const rest = params.toString()
25986
- window.history.replaceState(
25987
- null,
25988
- '',
25989
- window.location.pathname + window.location.search + (rest ? '#' + rest : ''),
25990
- )
25991
- }
25992
- })()
25553
+ try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
25554
+ params.delete(TOKEN_KEY);
25555
+ const remaining = params.toString();
25556
+ const newHash = remaining ? '#' + remaining : '';
25557
+ window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
25558
+ }
25559
+ })();
25993
25560
 
25994
25561
  export function getSessionToken(): string | null {
25995
- try {
25996
- return sessionStorage.getItem(TOKEN_KEY)
25997
- } catch {
25998
- return null
25999
- }
25562
+ try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
26000
25563
  }
26001
25564
 
26002
25565
  export function signInWithNetSuite(returnTo: string = window.location.href) {
26003
- const url = new URL('/auth/netsuite/start', window.location.origin)
26004
- url.searchParams.set('return_to', returnTo)
26005
- // The app renders inside the HelloLeo preview iframe; /start 302s to
26006
- // NetSuite, whose OAuth page sends X-Frame-Options: DENY. Navigate the TOP
26007
- // frame so the flow runs at top level and the callback lands there to read
26008
- // #nssid=. Assign .href (NOT .assign(): reading .assign off a cross-origin
26009
- // window.top.location throws, dropping us into the iframe fallback ->
26010
- // "refused to connect").
25566
+ if (!WORKER_URL) {
25567
+ throw new Error(
25568
+ 'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
25569
+ );
25570
+ }
25571
+ const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
25572
+ url.searchParams.set('return_to', returnTo);
25573
+ // The app usually renders inside the HelloLeo preview iframe. NetSuite's
25574
+ // OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
25575
+ // Worker (which 302s to NetSuite) ends up loading a page the browser
25576
+ // refuses to render -- looks like "nothing happens" to the user. Navigate
25577
+ // the top frame instead so the OAuth flow runs at the top level and the
25578
+ // callback redirect lands on the same top frame, which then reads the
25579
+ // #nssid= fragment. Use \`location.href =\` (NOT \`location.assign()\`):
25580
+ // reading the \`.assign\` method off a cross-origin \`window.top.location\`
25581
+ // throws a SecurityError, which would drop us into the same-frame fallback
25582
+ // and load NetSuite inside the iframe (X-Frame-Options: DENY -> "refused to
25583
+ // connect"). Assigning \`.href\` is a navigation, allowed cross-origin.
26011
25584
  try {
26012
25585
  if (window.top && window.top !== window) {
26013
- window.top.location.href = url.toString()
26014
- return
25586
+ window.top.location.href = url.toString();
25587
+ return;
26015
25588
  }
26016
25589
  } catch {
26017
- /* top window unreachable -- fall through to same-frame navigation */
25590
+ /* top window not accessible \u2014 fall through to same-frame navigation */
26018
25591
  }
26019
- window.location.href = url.toString()
25592
+ window.location.href = url.toString();
26020
25593
  }
26021
25594
 
26022
25595
  export interface NetSuiteSession {
26023
- authenticated: boolean
26024
- account_id?: string
25596
+ authenticated: boolean;
25597
+ account_id?: string;
25598
+ user_id?: string;
25599
+ email?: string;
26025
25600
  }
26026
25601
 
26027
25602
  export async function getNetSuiteSession(): Promise<NetSuiteSession> {
26028
- const sid = getSessionToken()
26029
- if (!sid) return { authenticated: false }
26030
- const res = await fetch('/api/me', { headers: { Authorization: 'Bearer ' + sid } })
26031
- if (!res.ok) return { authenticated: false }
26032
- const data = await res.json()
26033
- return { authenticated: Boolean(data?.authenticated), ...data }
25603
+ if (!WORKER_URL) return { authenticated: false };
25604
+ const sid = getSessionToken();
25605
+ if (!sid) return { authenticated: false };
25606
+ const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
25607
+ headers: { Authorization: 'Bearer ' + sid },
25608
+ });
25609
+ if (!res.ok) return { authenticated: false };
25610
+ const data = await res.json();
25611
+ return { authenticated: Boolean(data?.account_id), ...data };
26034
25612
  }
26035
25613
 
26036
25614
  export async function signOutNetSuite(): Promise<void> {
26037
- const sid = getSessionToken()
25615
+ if (!WORKER_URL) return;
25616
+ const sid = getSessionToken();
26038
25617
  if (sid) {
26039
25618
  try {
26040
- await fetch('/auth/netsuite/logout', {
25619
+ await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
26041
25620
  method: 'POST',
26042
25621
  headers: { Authorization: 'Bearer ' + sid },
26043
- })
25622
+ });
26044
25623
  } catch {}
26045
25624
  }
26046
- try {
26047
- sessionStorage.removeItem(TOKEN_KEY)
26048
- } catch {}
26049
- window.location.reload()
25625
+ try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
25626
+ window.location.reload();
26050
25627
  }
26051
25628
 
26052
25629
  /**
26053
- * Fetch YOUR data routes (src/routes/api/*) with the bearer attached. Use this
26054
- * instead of bare fetch for authenticated calls.
25630
+ * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
25631
+ * Always go through this instead of bare fetch -- it injects the bearer
25632
+ * token and routes through the Worker so NetSuite tokens stay server-side.
26055
25633
  */
26056
- export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
26057
- const sid = getSessionToken()
26058
- const headers = new Headers(init.headers)
26059
- if (sid) headers.set('Authorization', 'Bearer ' + sid)
26060
- return fetch(path, { ...init, headers })
25634
+ export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
25635
+ if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
25636
+ const url = pathOrUrl.startsWith('http')
25637
+ ? pathOrUrl
25638
+ : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
25639
+ const sid = getSessionToken();
25640
+ const headers = new Headers(init.headers ?? {});
25641
+ if (sid) headers.set('Authorization', 'Bearer ' + sid);
25642
+ return fetch(url, { ...init, headers });
25643
+ }
25644
+ `;
25645
+ }
25646
+ function signInButtonTsx() {
25647
+ return `import { signInWithNetSuite } from '../../lib/netsuite';
25648
+
25649
+ export function SignInWithNetSuite() {
25650
+ return (
25651
+ <button
25652
+ onClick={() => signInWithNetSuite()}
25653
+ 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"
25654
+ >
25655
+ Sign in with NetSuite
25656
+ </button>
25657
+ );
25658
+ }
25659
+ `;
26061
25660
  }
25661
+ function netsuiteLoginPageTsx() {
25662
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite';
26062
25663
 
26063
25664
  /**
26064
- * The exact redirect URI this app will send NetSuite, read from the server so
26065
- * it can never drift from what /auth/netsuite/start actually uses. Show it to
26066
- * whoever registers the Integration Record; never hardcode a callback URL.
25665
+ * Default unauthenticated landing page. Replaces any password gate.
25666
+ * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
25667
+ * only call-to-action -- it points to the Worker's /auth/netsuite/start.
26067
25668
  */
26068
- export async function getNetSuiteRedirectUri(): Promise<string | null> {
26069
- try {
26070
- const res = await fetch('/auth/netsuite/config')
26071
- if (!res.ok) return null
26072
- const data = await res.json()
26073
- return data?.redirect_uri ?? null
26074
- } catch {
26075
- return null
26076
- }
25669
+ export function LoginPage() {
25670
+ return (
25671
+ <div className="min-h-screen bg-background text-foreground flex">
25672
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
25673
+ <div className="text-xl font-semibold">NetSuite App</div>
25674
+ <div>
25675
+ <h1 className="text-4xl font-bold leading-tight">
25676
+ Connect your NetSuite account
25677
+ </h1>
25678
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
25679
+ Sign in with your NetSuite credentials to access your data securely.
25680
+ Tokens stay server-side on HelloLeo Cloud -- they never reach the
25681
+ browser.
25682
+ </p>
25683
+ </div>
25684
+ <div className="text-xs text-primary-foreground/60">
25685
+ Powered by HelloLeo
25686
+ </div>
25687
+ </div>
25688
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
25689
+ <div className="w-full max-w-sm space-y-6">
25690
+ <div>
25691
+ <h2 className="text-2xl font-semibold">Welcome</h2>
25692
+ <p className="mt-2 text-sm text-muted-foreground">
25693
+ Use the button below to sign in. You'll be redirected to NetSuite
25694
+ to authorise access, then back here.
25695
+ </p>
25696
+ </div>
25697
+ <SignInWithNetSuite />
25698
+ </div>
25699
+ </div>
25700
+ </div>
25701
+ );
26077
25702
  }
26078
25703
  `;
26079
25704
  }
26080
25705
  function authTsx() {
26081
- return `import * as React from 'react'
25706
+ return `import * as React from 'react';
26082
25707
  import {
26083
25708
  getNetSuiteSession,
26084
25709
  signInWithNetSuite,
26085
25710
  signOutNetSuite,
26086
25711
  type NetSuiteSession,
26087
- } from './netsuite'
25712
+ } from './netsuite';
26088
25713
 
26089
25714
  /**
26090
- * NetSuite auth context. Holds the session (resolved client-side from /api/me
26091
- * via getNetSuiteSession) and exposes sign-in / sign-out. /api/me is the only
26092
- * authoritative check -- never trust sessionStorage directly.
25715
+ * NetSuite auth context. Holds the current session (resolved from the Worker's
25716
+ * /api/me via getNetSuiteSession) and exposes sign-in / sign-out. The only
25717
+ * authoritative auth check is /api/me -- never trust sessionStorage directly.
26093
25718
  */
26094
25719
  export interface NetSuiteAuthContextValue {
26095
- pending: boolean
26096
- isAuthenticated: boolean
26097
- session: NetSuiteSession | null
26098
- signIn: () => void
26099
- signOut: () => Promise<void>
26100
- refresh: () => Promise<void>
25720
+ pending: boolean;
25721
+ isAuthenticated: boolean;
25722
+ session: NetSuiteSession | null;
25723
+ signIn: () => void;
25724
+ signOut: () => Promise<void>;
25725
+ refresh: () => Promise<void>;
26101
25726
  }
26102
25727
 
26103
- const NetSuiteAuthContext = React.createContext<NetSuiteAuthContextValue | null>(null)
25728
+ const NetSuiteAuthContext =
25729
+ React.createContext<NetSuiteAuthContextValue | null>(null);
26104
25730
 
26105
- export function NetSuiteAuthProvider({ children }: { children: React.ReactNode }) {
26106
- const [session, setSession] = React.useState<NetSuiteSession | null>(null)
26107
- // Start pending on BOTH server and client render so SSR and first paint match
26108
- // (a spinner), avoiding a hydration mismatch. The effect (client-only)
26109
- // resolves it.
26110
- const [pending, setPending] = React.useState(true)
25731
+ export function NetSuiteAuthProvider({
25732
+ children,
25733
+ }: {
25734
+ children: React.ReactNode;
25735
+ }) {
25736
+ const [session, setSession] = React.useState<NetSuiteSession | null>(null);
25737
+ const [pending, setPending] = React.useState(true);
26111
25738
 
26112
25739
  const refresh = React.useCallback(async () => {
26113
- setPending(true)
25740
+ setPending(true);
26114
25741
  try {
26115
- setSession(await getNetSuiteSession())
25742
+ setSession(await getNetSuiteSession());
26116
25743
  } finally {
26117
- setPending(false)
25744
+ setPending(false);
26118
25745
  }
26119
- }, [])
25746
+ }, []);
26120
25747
 
26121
25748
  React.useEffect(() => {
26122
- refresh()
26123
- }, [refresh])
25749
+ refresh();
25750
+ }, [refresh]);
26124
25751
 
26125
25752
  const value: NetSuiteAuthContextValue = {
26126
25753
  pending,
@@ -26129,226 +25756,126 @@ export function NetSuiteAuthProvider({ children }: { children: React.ReactNode }
26129
25756
  signIn: () => signInWithNetSuite(),
26130
25757
  signOut: signOutNetSuite,
26131
25758
  refresh,
26132
- }
25759
+ };
26133
25760
 
26134
- return <NetSuiteAuthContext.Provider value={value}>{children}</NetSuiteAuthContext.Provider>
25761
+ return (
25762
+ <NetSuiteAuthContext.Provider value={value}>
25763
+ {children}
25764
+ </NetSuiteAuthContext.Provider>
25765
+ );
26135
25766
  }
26136
25767
 
26137
25768
  export function useNetSuiteAuth(): NetSuiteAuthContextValue {
26138
- const ctx = React.useContext(NetSuiteAuthContext)
25769
+ const ctx = React.useContext(NetSuiteAuthContext);
26139
25770
  if (!ctx) {
26140
- throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>')
25771
+ throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>');
26141
25772
  }
26142
- return ctx
25773
+ return ctx;
26143
25774
  }
26144
25775
  `;
26145
25776
  }
26146
25777
  function authLayoutTsx() {
26147
- return `import type { ReactNode } from 'react'
26148
- import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth'
26149
- import { LoginPage } from './LoginPage'
25778
+ return `import type { ReactNode } from 'react';
25779
+ import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth';
25780
+ import { LoginPage } from './LoginPage';
26150
25781
 
26151
25782
  /**
26152
- * NetSuite auth layout. The ONE manual wiring step: wrap the root route's
26153
- * <Outlet /> with it in src/routes/__root.tsx:
25783
+ * NetSuite auth layout. This is the ONLY wiring you need to add by hand --
25784
+ * wrap the root route's <Outlet /> with it:
26154
25785
  *
26155
- * import { AuthLayout } from '../components/auth/AuthLayout'
25786
+ * // src/routes/__root.tsx
25787
+ * import { AuthLayout } from '../components/auth/AuthLayout';
26156
25788
  * ...
26157
25789
  * function RootComponent() {
26158
25790
  * return (
26159
- * <RootDocument>
25791
+ * <>
26160
25792
  * <AuthLayout>
26161
25793
  * <Outlet />
26162
25794
  * </AuthLayout>
26163
- * </RootDocument>
26164
- * )
25795
+ * { ...devtools... }
25796
+ * </>
25797
+ * );
26165
25798
  * }
26166
25799
  *
26167
- * It provides auth context and gates rendering: a spinner while the session
26168
- * resolves, <LoginPage /> for signed-out users, your routes once signed in.
26169
- * NEVER swap this for a password gate -- real OAuth is the only login.
25800
+ * It provides NetSuite auth context to the whole tree and gates rendering:
25801
+ * while the session resolves it shows a spinner, unauthenticated users see
25802
+ * <LoginPage />, authenticated users see your routes. Use useNetSuiteAuth()
25803
+ * anywhere below it to read the session or sign out. NEVER swap this for a
25804
+ * password gate -- real OAuth is the only supported login.
26170
25805
  */
26171
25806
  export function AuthLayout({ children }: { children: ReactNode }) {
26172
25807
  return (
26173
25808
  <NetSuiteAuthProvider>
26174
25809
  <Gate>{children}</Gate>
26175
25810
  </NetSuiteAuthProvider>
26176
- )
25811
+ );
26177
25812
  }
26178
25813
 
26179
25814
  function Gate({ children }: { children: ReactNode }) {
26180
- const { pending, isAuthenticated } = useNetSuiteAuth()
25815
+ const { pending, isAuthenticated } = useNetSuiteAuth();
26181
25816
 
26182
25817
  if (pending) {
26183
25818
  return (
26184
25819
  <div className="min-h-screen flex items-center justify-center bg-background">
26185
25820
  <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
26186
25821
  </div>
26187
- )
25822
+ );
26188
25823
  }
26189
25824
 
26190
- if (!isAuthenticated) return <LoginPage />
26191
-
26192
- return <>{children}</>
26193
- }
26194
- `;
26195
- }
26196
- function loginPageTsx2() {
26197
- return `import { SignInWithNetSuite } from './SignInWithNetSuite'
25825
+ if (!isAuthenticated) return <LoginPage />;
26198
25826
 
26199
- /**
26200
- * Default unauthenticated landing page. Replaces any password gate. Customise
26201
- * the copy / imagery freely but keep <SignInWithNetSuite /> as the only
26202
- * call-to-action -- it points to /auth/netsuite/start.
26203
- */
26204
- export function LoginPage() {
26205
- return (
26206
- <div className="min-h-screen bg-background text-foreground flex">
26207
- <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
26208
- <div className="text-xl font-semibold">NetSuite App</div>
26209
- <div>
26210
- <h1 className="text-4xl font-bold leading-tight">Connect your NetSuite account</h1>
26211
- <p className="mt-4 text-primary-foreground/80 max-w-md">
26212
- Sign in with your NetSuite credentials to access your data securely. Tokens stay
26213
- server-side -- they never reach the browser.
26214
- </p>
26215
- </div>
26216
- <div className="text-xs text-primary-foreground/60">Powered by HelloLeo</div>
26217
- </div>
26218
- <div className="flex-1 flex items-center justify-center px-6 py-12">
26219
- <div className="w-full max-w-sm space-y-6">
26220
- <div>
26221
- <h2 className="text-2xl font-semibold">Welcome</h2>
26222
- <p className="mt-2 text-sm text-muted-foreground">
26223
- Use the button below to sign in. You'll be redirected to NetSuite to authorise
26224
- access, then back here.
26225
- </p>
26226
- </div>
26227
- <SignInWithNetSuite />
26228
- </div>
26229
- </div>
26230
- </div>
26231
- )
26232
- }
26233
- `;
26234
- }
26235
- function signInButtonTsx() {
26236
- return `import { signInWithNetSuite } from '../../lib/netsuite'
26237
-
26238
- export function SignInWithNetSuite() {
26239
- return (
26240
- <button
26241
- onClick={() => signInWithNetSuite()}
26242
- 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"
26243
- >
26244
- Sign in with NetSuite
26245
- </button>
26246
- )
25827
+ return <>{children}</>;
26247
25828
  }
26248
25829
  `;
26249
25830
  }
26250
25831
  var setupNetsuite = tool({
26251
- 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",
25832
+ 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.",
26252
25833
  args: {},
26253
25834
  async execute(_args, context) {
26254
25835
  const dir = context.directory;
26255
- await mkdir2(path7.join(dir, "src", "lib"), { recursive: true });
26256
25836
  await mkdir2(path7.join(dir, "src", "components", "auth"), {
26257
25837
  recursive: true
26258
25838
  });
26259
- await mkdir2(path7.join(dir, "src", "routes", "auth", "netsuite"), {
26260
- recursive: true
26261
- });
26262
- await mkdir2(path7.join(dir, "src", "routes", "api"), { recursive: true });
26263
- const schemaPath = path7.join(dir, "src", "db", "schema.ts");
26264
- let schemaAppended = false;
26265
- try {
26266
- const current = await readFile3(schemaPath, "utf8");
26267
- if (!current.includes("netsuite_flows")) {
26268
- await Bun.write(schemaPath, current + schemaAppend());
26269
- schemaAppended = true;
26270
- }
26271
- } catch {
26272
- await Bun.write(schemaPath, `import { sql } from 'drizzle-orm'
26273
- import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
26274
- ${schemaAppend()}`);
26275
- schemaAppended = true;
26276
- }
26277
- const files = [
26278
- [path7.join("src", "lib", "netsuite-server.ts"), netsuiteServerTs()],
26279
- [
26280
- path7.join("src", "routes", "auth", "netsuite", "start.ts"),
26281
- startRouteTs()
26282
- ],
26283
- [
26284
- path7.join("src", "routes", "auth", "netsuite", "callback.ts"),
26285
- callbackRouteTs()
26286
- ],
26287
- [
26288
- path7.join("src", "routes", "auth", "netsuite", "logout.ts"),
26289
- logoutRouteTs()
26290
- ],
26291
- [
26292
- path7.join("src", "routes", "auth", "netsuite", "config.ts"),
26293
- configRouteTs()
26294
- ],
26295
- [path7.join("src", "routes", "api", "me.ts"), meRouteTs()],
26296
- [path7.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
25839
+ const nsFiles = [
25840
+ [path7.join("src", "lib", "netsuite.ts"), netsuiteTs()],
26297
25841
  [path7.join("src", "lib", "auth.tsx"), authTsx()],
26298
25842
  [
26299
25843
  path7.join("src", "components", "auth", "AuthLayout.tsx"),
26300
25844
  authLayoutTsx()
26301
25845
  ],
26302
- [path7.join("src", "components", "auth", "LoginPage.tsx"), loginPageTsx2()],
25846
+ [
25847
+ path7.join("src", "components", "auth", "LoginPage.tsx"),
25848
+ netsuiteLoginPageTsx()
25849
+ ],
26303
25850
  [
26304
25851
  path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
26305
25852
  signInButtonTsx()
26306
25853
  ]
26307
25854
  ];
26308
- for (const [filePath, content] of files) {
25855
+ for (const [filePath, content] of nsFiles) {
26309
25856
  await Bun.write(path7.join(dir, filePath), content);
26310
25857
  }
26311
25858
  return [
26312
- `Wrote ${files.length} NetSuite files${schemaAppended ? " + appended src/db/schema.ts" : ""}:`,
26313
- "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.",
26314
- " - Record #2 (runtime): https://docs.helloleo.dev/integrations/netsuite#step-4-%E2%80%94-create-integration-record-2-runtime",
26315
- " - Send runtime creds to Leo: https://docs.helloleo.dev/integrations/netsuite#step-5-%E2%80%94-send-the-runtime-credentials-to-leo",
25859
+ `Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
25860
+ " - src/lib/netsuite.ts (OAuth client: signIn, session, workerFetch)",
25861
+ " - src/lib/auth.tsx (NetSuiteAuthProvider and useNetSuiteAuth)",
25862
+ " - src/components/auth/AuthLayout.tsx (provider and auth gate)",
25863
+ " - src/components/auth/LoginPage.tsx (unauthenticated view)",
25864
+ " - src/components/auth/SignInWithNetSuite.tsx (sign-in button)",
26316
25865
  "",
26317
- " server (OAuth backend lives in the app -- no separate Worker):",
26318
- " - src/lib/netsuite-server.ts (env, PKCE, token exchange, session refresh, netsuiteFetch)",
26319
- " - src/routes/auth/netsuite/start.ts GET begin PKCE flow",
26320
- " - src/routes/auth/netsuite/callback.ts GET code -> tokens -> session",
26321
- " - src/routes/auth/netsuite/logout.ts POST destroy session",
26322
- " - src/routes/auth/netsuite/config.ts GET the exact redirect URI to register",
26323
- " - src/routes/api/me.ts GET bearer -> session (auth check)",
26324
- " - src/db/schema.ts += netsuite_flows + netsuite_sessions",
26325
- " client:",
26326
- " - src/lib/netsuite.ts (same-origin: signIn, session, apiFetch)",
26327
- " - src/lib/auth.tsx (NetSuiteAuthProvider + useNetSuiteAuth)",
26328
- " - src/components/auth/{AuthLayout,LoginPage,SignInWithNetSuite}.tsx",
25866
+ "Notes:",
25867
+ "- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
25868
+ "- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
25869
+ "- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
25870
+ "- 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).",
26329
25871
  "",
26330
- "WIRE IT UP (one manual step): in src/routes/__root.tsx, wrap <Outlet /> with <AuthLayout>:",
25872
+ "Next: in src/routes/__root.tsx, import AuthLayout and wrap <Outlet /> with it. Keep any existing providers.",
26331
25873
  "",
26332
25874
  " import { AuthLayout } from '../components/auth/AuthLayout'",
26333
25875
  "",
26334
25876
  " <AuthLayout>",
26335
25877
  " <Outlet />",
26336
- " </AuthLayout>",
26337
- "",
26338
- "Then:",
26339
- "- Build your dashboard inside <AuthLayout>. Read the session with useNetSuiteAuth().",
26340
- "- Data routes: add src/routes/api/*.ts and call netsuiteFetch(bearer(request), '/services/rest/query/v1/suiteql', {...}) from the handler.",
26341
- "",
26342
- "TWO INTEGRATION RECORDS -- do not confuse them:",
26343
- "- Record #1 (scope: mcp): already connected, used ONLY to build the app (schema discovery). This login does NOT use it.",
26344
- "- 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).",
26345
- "",
26346
- "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:",
26347
- "- 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.",
26348
- "- 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.",
26349
- "- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
26350
- "- 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.",
26351
- "- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login."
25878
+ " </AuthLayout>"
26352
25879
  ].join(`
26353
25880
  `);
26354
25881
  }