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