@helloleo/plugins 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +254 -703
- package/package.json +1 -1
- package/src/tools/setup-netsuite.ts +268 -776
|
@@ -1,676 +1,285 @@
|
|
|
1
|
-
import { mkdir
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
4
|
import { tool } from "@opencode-ai/plugin";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* NetSuite
|
|
7
|
+
* Adds NetSuite OAuth wiring on top of an existing scaffold: the netsuite
|
|
8
|
+
* OAuth client, a NetSuite auth context (auth.tsx), and the AuthLayout /
|
|
9
|
+
* LoginPage / SignInWithNetSuite components. It never overwrites a
|
|
10
|
+
* scaffold file -- the one manual step is wrapping the root route's
|
|
11
|
+
* <Outlet /> with <AuthLayout> (the tool's output shows the snippet).
|
|
12
|
+
* All NetSuite-specific templates live in this file -- one place to read
|
|
13
|
+
* or change anything NetSuite-related.
|
|
8
14
|
*
|
|
9
|
-
*
|
|
10
|
-
* `vite dev` (workerd) inside the Fly preview machine in dev, the published
|
|
11
|
-
* Worker in prod. So the OAuth backend is a set of **server routes inside the
|
|
12
|
-
* app**, NOT a separate Worker. There is no VITE_WORKER_URL and nothing is
|
|
13
|
-
* cross-site: the SPA calls its own /auth/netsuite/* and /api/me.
|
|
14
|
-
*
|
|
15
|
-
* What this writes (all additive; only src/db/schema.ts is appended to):
|
|
16
|
-
* - src/db/schema.ts += netsuite_flows + netsuite_sessions tables
|
|
17
|
-
* - src/lib/netsuite-server.ts server OAuth helpers (env, PKCE, token
|
|
18
|
-
* exchange, session refresh, netsuiteFetch)
|
|
19
|
-
* - src/routes/auth/netsuite/start.ts GET begin PKCE flow -> NetSuite
|
|
20
|
-
* - src/routes/auth/netsuite/callback.ts GET code -> tokens -> session
|
|
21
|
-
* - src/routes/auth/netsuite/logout.ts POST destroy session
|
|
22
|
-
* - src/routes/api/me.ts GET bearer sid -> session (auth check)
|
|
23
|
-
* - src/lib/netsuite.ts client (same-origin, bearer-in-fragment)
|
|
24
|
-
* - src/lib/auth.tsx NetSuiteAuthProvider / useNetSuiteAuth
|
|
25
|
-
* - src/components/auth/{AuthLayout,LoginPage,SignInWithNetSuite}.tsx
|
|
26
|
-
*
|
|
27
|
-
* This login uses NetSuite Integration Record #2 (scope: rest_webservices) —
|
|
28
|
-
* a SEPARATE record from the MCP one (scope: mcp) used only to BUILD the app.
|
|
29
|
-
*
|
|
30
|
-
* Config (read via @helloleo/runtime env): NETSUITE_ACCOUNT_ID / _CLIENT_ID /
|
|
31
|
-
* _CLIENT_SECRET (secret optional for a PKCE public client) and optional
|
|
32
|
-
* PUBLIC_APP_URL. Same var names everywhere, different source per environment:
|
|
33
|
-
* in DEV (Fly preview) the app reads them from the project .env; in PROD they
|
|
34
|
-
* are HelloLeo Cloud secrets on the published Worker (the publish step promotes
|
|
35
|
-
* them from .env). Never in code; they never reach the browser.
|
|
36
|
-
*
|
|
37
|
-
* The redirect URI is derived at runtime from the request origin
|
|
38
|
-
* (`${origin}/auth/netsuite/callback`), so the SAME code works at the Fly
|
|
39
|
-
* preview URL and the published prod URL — register BOTH on Integration
|
|
40
|
-
* Record #2 (NetSuite allows multiple redirect URIs).
|
|
41
|
-
*
|
|
42
|
-
* Call AFTER `scaffold`, never instead of it.
|
|
15
|
+
* Called AFTER `scaffold`, never instead of it.
|
|
43
16
|
*/
|
|
44
17
|
|
|
45
|
-
|
|
46
|
-
function schemaAppend(): string {
|
|
47
|
-
return `
|
|
48
|
-
// --- NetSuite OAuth (added by setup_netsuite) ---
|
|
49
|
-
// Short-lived PKCE + return-to state between /auth/netsuite/start and its
|
|
50
|
-
// callback. Single-use: the callback deletes the row after reading it.
|
|
51
|
-
export const netsuiteFlows = sqliteTable('netsuite_flows', {
|
|
52
|
-
flowId: text('flow_id').primaryKey(),
|
|
53
|
-
codeVerifier: text('code_verifier').notNull(),
|
|
54
|
-
returnTo: text('return_to').notNull(),
|
|
55
|
-
createdAt: integer('created_at')
|
|
56
|
-
.notNull()
|
|
57
|
-
.default(sql\`(unixepoch())\`),
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
// Opaque session id -> NetSuite tokens. Tokens stay server-side; the browser
|
|
61
|
-
// only ever holds the sid (sent as a bearer).
|
|
62
|
-
export const netsuiteSessions = sqliteTable('netsuite_sessions', {
|
|
63
|
-
sid: text('sid').primaryKey(),
|
|
64
|
-
accountId: text('account_id').notNull(),
|
|
65
|
-
accessToken: text('access_token').notNull(),
|
|
66
|
-
refreshToken: text('refresh_token'),
|
|
67
|
-
expiresAt: integer('expires_at'),
|
|
68
|
-
userId: text('user_id'),
|
|
69
|
-
email: text('email'),
|
|
70
|
-
createdAt: integer('created_at')
|
|
71
|
-
.notNull()
|
|
72
|
-
.default(sql\`(unixepoch())\`),
|
|
73
|
-
})
|
|
74
|
-
`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// --- emitted: src/lib/netsuite-server.ts ---------------------------------
|
|
78
|
-
function netsuiteServerTs(): string {
|
|
18
|
+
function netsuiteTs(): string {
|
|
79
19
|
return `/*
|
|
80
|
-
* NetSuite
|
|
81
|
-
*
|
|
82
|
-
* Runs wherever the app runs: \`vite dev\` (workerd) in the Fly preview machine
|
|
83
|
-
* in dev, and the published Cloudflare Worker in prod. There is no separate
|
|
84
|
-
* backend Worker -- these helpers back the /auth/netsuite/* + /api/me routes.
|
|
20
|
+
* NetSuite OAuth client helper (bearer-token-in-fragment).
|
|
85
21
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
import { netsuiteSessions } from '#/db/schema'
|
|
95
|
-
|
|
96
|
-
interface NsEnv {
|
|
97
|
-
NETSUITE_ACCOUNT_ID?: string
|
|
98
|
-
NETSUITE_CLIENT_ID?: string
|
|
99
|
-
NETSUITE_CLIENT_SECRET?: string
|
|
100
|
-
PUBLIC_APP_URL?: string
|
|
101
|
-
HELLOLEO_URL?: string
|
|
102
|
-
}
|
|
103
|
-
const nsEnv = runtimeEnv as unknown as NsEnv
|
|
104
|
-
|
|
105
|
-
// Runtime record scope. The app signs end-users in for REST/SuiteQL.
|
|
106
|
-
export const SCOPE = 'rest_webservices'
|
|
107
|
-
const REFRESH_LEEWAY_SECONDS = 60
|
|
108
|
-
|
|
109
|
-
export function requireConfig() {
|
|
110
|
-
const accountId = nsEnv.NETSUITE_ACCOUNT_ID
|
|
111
|
-
const clientId = nsEnv.NETSUITE_CLIENT_ID
|
|
112
|
-
if (!accountId || !clientId) {
|
|
113
|
-
throw new Error(
|
|
114
|
-
'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.',
|
|
115
|
-
)
|
|
116
|
-
}
|
|
117
|
-
return { accountId, clientId, clientSecret: nsEnv.NETSUITE_CLIENT_SECRET || '' }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Account id -> DNS host form: lowercase, underscores to hyphens.
|
|
121
|
-
function host(accountId: string) {
|
|
122
|
-
return accountId.trim().toLowerCase().replace(/_/g, '-')
|
|
123
|
-
}
|
|
124
|
-
export function authorizeUrl(accountId: string) {
|
|
125
|
-
return \`https://\${host(accountId)}.app.netsuite.com/app/login/oauth2/authorize.nl\`
|
|
126
|
-
}
|
|
127
|
-
export function tokenUrl(accountId: string) {
|
|
128
|
-
return \`https://\${host(accountId)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token\`
|
|
129
|
-
}
|
|
130
|
-
export function restBase(accountId: string) {
|
|
131
|
-
return \`https://\${host(accountId)}.suitetalk.api.netsuite.com\`
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function stripTrailingSlash(s: string) {
|
|
135
|
-
return s.endsWith('/') ? s.slice(0, -1) : s
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Public origin of THIS app, used to build the redirect URI. Prefer an explicit
|
|
139
|
-
// PUBLIC_APP_URL; else derive from the request (honouring the preview proxy's
|
|
140
|
-
// forwarded headers). Must match a redirect URI registered on the NetSuite
|
|
141
|
-
// Integration Record exactly.
|
|
142
|
-
export function appOrigin(request: Request) {
|
|
143
|
-
// 1) Explicit override wins (set by the platform per environment).
|
|
144
|
-
if (nsEnv.PUBLIC_APP_URL) return stripTrailingSlash(nsEnv.PUBLIC_APP_URL)
|
|
145
|
-
// 2) HELLOLEO_URL is injected on every deploy (the app's canonical origin,
|
|
146
|
-
// a bare host with no scheme), so it's set even when the NetSuite
|
|
147
|
-
// wizard's PUBLIC_APP_URL pin wasn't.
|
|
148
|
-
if (nsEnv.HELLOLEO_URL) return \`https://\${stripTrailingSlash(nsEnv.HELLOLEO_URL)}\`
|
|
149
|
-
// 3) Preview/prod proxies rewrite Host, so trust the forwarded/original host
|
|
150
|
-
// they set (HelloLeo's nginx sends X-Original-Host) — that's the public URL
|
|
151
|
-
// the browser loaded, not an internal upstream host.
|
|
152
|
-
const url = new URL(request.url)
|
|
153
|
-
const proto = request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')
|
|
154
|
-
const hostHeader =
|
|
155
|
-
request.headers.get('x-forwarded-host') ||
|
|
156
|
-
request.headers.get('x-original-host') ||
|
|
157
|
-
url.host
|
|
158
|
-
return \`\${proto}://\${hostHeader}\`
|
|
159
|
-
}
|
|
160
|
-
export function redirectUri(request: Request) {
|
|
161
|
-
return \`\${appOrigin(request)}/auth/netsuite/callback\`
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Coerce ?return_to= to our OWN origin. The callback appends the session bearer
|
|
165
|
-
// as a #nssid=... fragment; an off-origin return_to would leak that live
|
|
166
|
-
// credential to an attacker-controlled page (open-redirect -> token theft).
|
|
167
|
-
export function safeReturnTo(request: Request, raw: string | null): string {
|
|
168
|
-
const origin = appOrigin(request)
|
|
169
|
-
if (!raw) return origin
|
|
170
|
-
try {
|
|
171
|
-
const u = new URL(raw, origin)
|
|
172
|
-
return u.origin === origin ? u.toString() : origin
|
|
173
|
-
} catch {
|
|
174
|
-
return origin
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// --- PKCE (Web Crypto: works on workerd and Node) ---
|
|
179
|
-
function b64url(bytes: Uint8Array) {
|
|
180
|
-
let s = ''
|
|
181
|
-
for (const b of bytes) s += String.fromCharCode(b)
|
|
182
|
-
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
183
|
-
}
|
|
184
|
-
export async function pkce() {
|
|
185
|
-
const verifier = b64url(crypto.getRandomValues(new Uint8Array(48)))
|
|
186
|
-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
187
|
-
const challenge = b64url(new Uint8Array(digest))
|
|
188
|
-
return { verifier, challenge }
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
interface TokenResponse {
|
|
192
|
-
access_token?: string
|
|
193
|
-
refresh_token?: string
|
|
194
|
-
expires_in?: number
|
|
195
|
-
error?: string
|
|
196
|
-
error_description?: string
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Confidential client -> HTTP Basic. Public (PKCE-only) -> client_id in body.
|
|
200
|
-
function authHeaders(clientId: string, clientSecret: string, params: Record<string, string>) {
|
|
201
|
-
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
202
|
-
if (clientSecret) headers.Authorization = 'Basic ' + btoa(\`\${clientId}:\${clientSecret}\`)
|
|
203
|
-
else params.client_id = clientId
|
|
204
|
-
return headers
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// POST the token endpoint and parse tolerantly. NetSuite returns JSON for both
|
|
208
|
-
// success and OAuth errors, but a gateway (502/504) can return HTML -- never let
|
|
209
|
-
// res.json() throw into the route handler (that would surface as an opaque 500
|
|
210
|
-
// instead of a clean "token exchange failed").
|
|
211
|
-
async function postToken(
|
|
212
|
-
accountId: string,
|
|
213
|
-
params: Record<string, string>,
|
|
214
|
-
headers: Record<string, string>,
|
|
215
|
-
): Promise<TokenResponse> {
|
|
216
|
-
let res: Response
|
|
217
|
-
try {
|
|
218
|
-
res = await fetch(tokenUrl(accountId), { method: 'POST', headers, body: new URLSearchParams(params) })
|
|
219
|
-
} catch (e) {
|
|
220
|
-
return { error: 'network_error', error_description: String(e) }
|
|
221
|
-
}
|
|
222
|
-
const text = await res.text()
|
|
223
|
-
let data: TokenResponse = {}
|
|
224
|
-
try {
|
|
225
|
-
data = JSON.parse(text) as TokenResponse
|
|
226
|
-
} catch {
|
|
227
|
-
// non-JSON body (e.g. gateway HTML) -- leave data empty, fall through
|
|
228
|
-
}
|
|
229
|
-
if (!res.ok && !data.error) {
|
|
230
|
-
data = { error: 'token_request_failed', error_description: 'HTTP ' + res.status }
|
|
231
|
-
}
|
|
232
|
-
return data
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export async function exchangeCode(request: Request, code: string, verifier: string) {
|
|
236
|
-
const { accountId, clientId, clientSecret } = requireConfig()
|
|
237
|
-
const params: Record<string, string> = {
|
|
238
|
-
grant_type: 'authorization_code',
|
|
239
|
-
code,
|
|
240
|
-
redirect_uri: redirectUri(request),
|
|
241
|
-
code_verifier: verifier,
|
|
242
|
-
}
|
|
243
|
-
return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function refreshToken(refresh: string) {
|
|
247
|
-
const { accountId, clientId, clientSecret } = requireConfig()
|
|
248
|
-
const params: Record<string, string> = { grant_type: 'refresh_token', refresh_token: refresh }
|
|
249
|
-
return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export interface NsSession {
|
|
253
|
-
sid: string
|
|
254
|
-
accountId: string
|
|
255
|
-
accessToken: string
|
|
256
|
-
refreshToken: string | null
|
|
257
|
-
expiresAt: number | null
|
|
258
|
-
userId: string | null
|
|
259
|
-
email: string | null
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Load a session by sid, transparently refreshing the access token when it's
|
|
263
|
-
// within the leeway of expiry. Returns null if unknown or unrefreshable.
|
|
264
|
-
export async function getSession(sid: string): Promise<NsSession | null> {
|
|
265
|
-
if (!sid) return null
|
|
266
|
-
const rows = await db.select().from(netsuiteSessions).where(eq(netsuiteSessions.sid, sid)).limit(1)
|
|
267
|
-
const s = rows[0]
|
|
268
|
-
if (!s) return null
|
|
269
|
-
const now = Math.floor(Date.now() / 1000)
|
|
270
|
-
// A missing expiry is treated as EXPIRED (force a refresh) rather than trusting
|
|
271
|
-
// the token forever -- NetSuite normally sends expires_in, so this is defensive.
|
|
272
|
-
const expired = s.expiresAt == null ? true : now + REFRESH_LEEWAY_SECONDS >= s.expiresAt
|
|
273
|
-
if (expired) {
|
|
274
|
-
// Token expired (or about to). It MUST refresh; if it can't, the session is
|
|
275
|
-
// dead — delete it and return null so /api/me reports signed-out and the user
|
|
276
|
-
// re-auths, rather than a "logged in" app whose every NetSuite call 401s.
|
|
277
|
-
if (!s.refreshToken) { await deleteSession(sid); return null }
|
|
278
|
-
const tok = await refreshToken(s.refreshToken)
|
|
279
|
-
if (!tok.access_token) { await deleteSession(sid); return null }
|
|
280
|
-
const expiresAt = now + (tok.expires_in || 3600)
|
|
281
|
-
const refreshTok = tok.refresh_token ?? s.refreshToken
|
|
282
|
-
await db
|
|
283
|
-
.update(netsuiteSessions)
|
|
284
|
-
.set({ accessToken: tok.access_token, refreshToken: refreshTok, expiresAt })
|
|
285
|
-
.where(eq(netsuiteSessions.sid, sid))
|
|
286
|
-
return { ...s, accessToken: tok.access_token, refreshToken: refreshTok, expiresAt }
|
|
287
|
-
}
|
|
288
|
-
return s
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export async function deleteSession(sid: string) {
|
|
292
|
-
if (sid) await db.delete(netsuiteSessions).where(eq(netsuiteSessions.sid, sid))
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export function bearer(request: Request) {
|
|
296
|
-
const h = request.headers.get('authorization') || ''
|
|
297
|
-
return h.startsWith('Bearer ') ? h.slice(7) : ''
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Call NetSuite REST / SuiteQL for the signed-in session. Use this from your
|
|
302
|
-
* data routes (src/routes/api/*). \`path\` is relative to the account's
|
|
303
|
-
* suitetalk host, e.g. '/services/rest/query/v1/suiteql'. Returns the raw
|
|
304
|
-
* fetch Response (401 if the sid is unknown). Access token is auto-refreshed.
|
|
305
|
-
*/
|
|
306
|
-
export async function netsuiteFetch(sid: string, path: string, init: RequestInit = {}): Promise<Response> {
|
|
307
|
-
const session = await getSession(sid)
|
|
308
|
-
if (!session) return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })
|
|
309
|
-
const url = path.startsWith('http') ? path : \`\${restBase(session.accountId)}\${path}\`
|
|
310
|
-
const headers = new Headers(init.headers)
|
|
311
|
-
headers.set('Authorization', \`Bearer \${session.accessToken}\`)
|
|
312
|
-
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
|
313
|
-
return fetch(url, { ...init, headers })
|
|
314
|
-
}
|
|
315
|
-
`;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// --- emitted: src/routes/auth/netsuite/start.ts --------------------------
|
|
319
|
-
function startRouteTs(): string {
|
|
320
|
-
return `import { createFileRoute } from '@tanstack/react-router'
|
|
321
|
-
import { db } from '#/db'
|
|
322
|
-
import { netsuiteFlows } from '#/db/schema'
|
|
323
|
-
import {
|
|
324
|
-
authorizeUrl,
|
|
325
|
-
pkce,
|
|
326
|
-
redirectUri,
|
|
327
|
-
requireConfig,
|
|
328
|
-
safeReturnTo,
|
|
329
|
-
SCOPE,
|
|
330
|
-
} from '#/lib/netsuite-server'
|
|
331
|
-
|
|
332
|
-
// GET /auth/netsuite/start -- begin the NetSuite OAuth (PKCE) flow.
|
|
333
|
-
export const Route = createFileRoute('/auth/netsuite/start')({
|
|
334
|
-
server: {
|
|
335
|
-
handlers: {
|
|
336
|
-
GET: async ({ request }) => {
|
|
337
|
-
const { accountId, clientId } = requireConfig()
|
|
338
|
-
const url = new URL(request.url)
|
|
339
|
-
// Same-origin only: the callback returns the bearer in a URL fragment.
|
|
340
|
-
const returnTo = safeReturnTo(request, url.searchParams.get('return_to'))
|
|
341
|
-
|
|
342
|
-
const { verifier, challenge } = await pkce()
|
|
343
|
-
const flowId = crypto.randomUUID()
|
|
344
|
-
await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
|
|
345
|
-
|
|
346
|
-
const authorize = new URL(authorizeUrl(accountId))
|
|
347
|
-
authorize.searchParams.set('response_type', 'code')
|
|
348
|
-
authorize.searchParams.set('client_id', clientId)
|
|
349
|
-
authorize.searchParams.set('redirect_uri', redirectUri(request))
|
|
350
|
-
authorize.searchParams.set('scope', SCOPE)
|
|
351
|
-
authorize.searchParams.set('state', flowId)
|
|
352
|
-
authorize.searchParams.set('code_challenge', challenge)
|
|
353
|
-
authorize.searchParams.set('code_challenge_method', 'S256')
|
|
354
|
-
|
|
355
|
-
return new Response(null, { status: 302, headers: { Location: authorize.toString() } })
|
|
356
|
-
},
|
|
357
|
-
},
|
|
358
|
-
},
|
|
359
|
-
})
|
|
360
|
-
`;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// --- emitted: src/routes/auth/netsuite/callback.ts -----------------------
|
|
364
|
-
function callbackRouteTs(): string {
|
|
365
|
-
return `import { createFileRoute } from '@tanstack/react-router'
|
|
366
|
-
import { eq } from 'drizzle-orm'
|
|
367
|
-
import { db } from '#/db'
|
|
368
|
-
import { netsuiteFlows, netsuiteSessions } from '#/db/schema'
|
|
369
|
-
import { exchangeCode, requireConfig } from '#/lib/netsuite-server'
|
|
370
|
-
|
|
371
|
-
// GET /auth/netsuite/callback -- exchange the code, mint a session, hand the
|
|
372
|
-
// bearer back to the SPA via the URL fragment (#nssid=...). The fragment
|
|
373
|
-
// survives the HelloLeo preview iframe, where third-party cookies are dropped.
|
|
374
|
-
export const Route = createFileRoute('/auth/netsuite/callback')({
|
|
375
|
-
server: {
|
|
376
|
-
handlers: {
|
|
377
|
-
GET: async ({ request }) => {
|
|
378
|
-
const url = new URL(request.url)
|
|
379
|
-
const err = url.searchParams.get('error')
|
|
380
|
-
if (err) return new Response(\`NetSuite authorization failed: \${err}\`, { status: 400 })
|
|
381
|
-
|
|
382
|
-
const code = url.searchParams.get('code')
|
|
383
|
-
const state = url.searchParams.get('state')
|
|
384
|
-
if (!code || !state) return new Response('Missing code or state', { status: 400 })
|
|
385
|
-
|
|
386
|
-
const rows = await db
|
|
387
|
-
.select()
|
|
388
|
-
.from(netsuiteFlows)
|
|
389
|
-
.where(eq(netsuiteFlows.flowId, state))
|
|
390
|
-
.limit(1)
|
|
391
|
-
const flow = rows[0]
|
|
392
|
-
if (!flow) return new Response('Unknown or expired flow -- start again', { status: 400 })
|
|
393
|
-
// Single-use: burn the flow row immediately.
|
|
394
|
-
await db.delete(netsuiteFlows).where(eq(netsuiteFlows.flowId, state))
|
|
395
|
-
// Reject stale flows: the PKCE verifier must not stay valid indefinitely
|
|
396
|
-
// for an abandoned or leaked authorize URL (createdAt is unixepoch seconds).
|
|
397
|
-
const nowSec = Math.floor(Date.now() / 1000)
|
|
398
|
-
if (flow.createdAt && nowSec - flow.createdAt > 600) {
|
|
399
|
-
return new Response('Flow expired -- start again', { status: 400 })
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const tok = await exchangeCode(request, code, flow.codeVerifier)
|
|
403
|
-
if (!tok.access_token) {
|
|
404
|
-
return new Response(
|
|
405
|
-
\`Token exchange failed: \${tok.error_description || tok.error || 'unknown error'}\`,
|
|
406
|
-
{ status: 400 },
|
|
407
|
-
)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const sid = crypto.randomUUID()
|
|
411
|
-
const now = Math.floor(Date.now() / 1000)
|
|
412
|
-
await db.insert(netsuiteSessions).values({
|
|
413
|
-
sid,
|
|
414
|
-
accountId: requireConfig().accountId,
|
|
415
|
-
accessToken: tok.access_token,
|
|
416
|
-
refreshToken: tok.refresh_token ?? null,
|
|
417
|
-
expiresAt: now + (tok.expires_in || 3600),
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
const dest = new URL(flow.returnTo)
|
|
421
|
-
dest.hash = \`nssid=\${sid}\`
|
|
422
|
-
return new Response(null, { status: 302, headers: { Location: dest.toString() } })
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
},
|
|
426
|
-
})
|
|
427
|
-
`;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// --- emitted: src/routes/auth/netsuite/logout.ts -------------------------
|
|
431
|
-
function logoutRouteTs(): string {
|
|
432
|
-
return `import { createFileRoute } from '@tanstack/react-router'
|
|
433
|
-
import { bearer, deleteSession } from '#/lib/netsuite-server'
|
|
434
|
-
|
|
435
|
-
// POST /auth/netsuite/logout -- destroy the current session.
|
|
436
|
-
export const Route = createFileRoute('/auth/netsuite/logout')({
|
|
437
|
-
server: {
|
|
438
|
-
handlers: {
|
|
439
|
-
POST: async ({ request }) => {
|
|
440
|
-
await deleteSession(bearer(request))
|
|
441
|
-
return Response.json({ ok: true })
|
|
442
|
-
},
|
|
443
|
-
},
|
|
444
|
-
},
|
|
445
|
-
})
|
|
446
|
-
`;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// --- emitted: src/routes/api/me.ts ---------------------------------------
|
|
450
|
-
function meRouteTs(): string {
|
|
451
|
-
return `import { createFileRoute } from '@tanstack/react-router'
|
|
452
|
-
import { bearer, getSession } from '#/lib/netsuite-server'
|
|
453
|
-
|
|
454
|
-
// GET /api/me -- resolve the bearer sid to a session. The ONLY authoritative
|
|
455
|
-
// auth check; the client must never trust sessionStorage on its own.
|
|
456
|
-
export const Route = createFileRoute('/api/me')({
|
|
457
|
-
server: {
|
|
458
|
-
handlers: {
|
|
459
|
-
GET: async ({ request }) => {
|
|
460
|
-
const session = await getSession(bearer(request))
|
|
461
|
-
if (!session) return Response.json({ authenticated: false })
|
|
462
|
-
return Response.json({
|
|
463
|
-
authenticated: true,
|
|
464
|
-
account_id: session.accountId,
|
|
465
|
-
})
|
|
466
|
-
},
|
|
467
|
-
},
|
|
468
|
-
},
|
|
469
|
-
})
|
|
470
|
-
`;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// --- emitted: src/routes/auth/netsuite/config.ts -------------------------
|
|
474
|
-
function configRouteTs(): string {
|
|
475
|
-
return `import { createFileRoute } from '@tanstack/react-router'
|
|
476
|
-
import { env as runtimeEnv } from '@helloleo/runtime'
|
|
477
|
-
import { appOrigin, redirectUri } from '#/lib/netsuite-server'
|
|
478
|
-
|
|
479
|
-
// GET /auth/netsuite/config -- single source of truth for the redirect URI.
|
|
480
|
-
// Whatever this returns is EXACTLY what /auth/netsuite/start sends NetSuite, so
|
|
481
|
-
// show the user THIS value to register on their Integration Record. Never
|
|
482
|
-
// hardcode or guess a callback URL.
|
|
483
|
-
export const Route = createFileRoute('/auth/netsuite/config')({
|
|
484
|
-
server: {
|
|
485
|
-
handlers: {
|
|
486
|
-
GET: async ({ request }) => {
|
|
487
|
-
const e = runtimeEnv as unknown as {
|
|
488
|
-
NETSUITE_ACCOUNT_ID?: string
|
|
489
|
-
NETSUITE_CLIENT_ID?: string
|
|
490
|
-
}
|
|
491
|
-
return Response.json({
|
|
492
|
-
redirect_uri: redirectUri(request),
|
|
493
|
-
origin: appOrigin(request),
|
|
494
|
-
configured: Boolean(e.NETSUITE_ACCOUNT_ID && e.NETSUITE_CLIENT_ID),
|
|
495
|
-
})
|
|
496
|
-
},
|
|
497
|
-
},
|
|
498
|
-
},
|
|
499
|
-
})
|
|
500
|
-
`;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// --- emitted: src/lib/netsuite.ts (client) -------------------------------
|
|
504
|
-
function netsuiteClientTs(): string {
|
|
505
|
-
return `/*
|
|
506
|
-
* NetSuite auth -- client (same-origin). The OAuth backend is THIS app's own
|
|
507
|
-
* server routes (src/routes/auth/netsuite/* + src/routes/api/me.ts), so every
|
|
508
|
-
* call is relative -- no separate Worker, no VITE_WORKER_URL.
|
|
22
|
+
* Two NetSuite Integration Records back this:
|
|
23
|
+
* 1. MCP (scope: mcp) -- used by HelloLeo at build time.
|
|
24
|
+
* 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
|
|
25
|
+
* Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
|
|
26
|
+
* (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
|
|
27
|
+
* the browser. Cookies are NOT used -- frontend ↔ Worker are cross-site
|
|
28
|
+
* (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
|
|
29
|
+
* third-party cookies in the HelloLeo preview iframe.
|
|
509
30
|
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
31
|
+
* Flow:
|
|
32
|
+
* 1. User clicks "Sign in with NetSuite".
|
|
33
|
+
* 2. Browser navigates to the Worker /auth/netsuite/start.
|
|
34
|
+
* 3. Worker redirects to NetSuite's OAuth UI with PKCE.
|
|
35
|
+
* 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
|
|
36
|
+
* 5. Worker exchanges the code, mints a session id, then redirects to
|
|
37
|
+
* <return_to>#nssid=<sid>. URL fragments stay in the browser only.
|
|
38
|
+
* 6. This module reads the fragment on load, stashes the sid in
|
|
39
|
+
* sessionStorage, and sends it as Authorization: Bearer on every
|
|
40
|
+
* Worker call.
|
|
515
41
|
*
|
|
516
|
-
* NEVER use a password gate. NEVER
|
|
517
|
-
*
|
|
42
|
+
* NEVER use a hardcoded password gate. NEVER use sessionStorage as the
|
|
43
|
+
* authoritative auth state -- the only acceptable check is /api/me on the
|
|
44
|
+
* Worker. sessionStorage here is just a cache of the bearer token.
|
|
518
45
|
*/
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
46
|
+
|
|
47
|
+
export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
|
|
48
|
+
const TOKEN_KEY = 'nssid';
|
|
49
|
+
|
|
50
|
+
// Capture-and-strip the #nssid=... fragment as early as possible so URLs
|
|
51
|
+
// shown to the user stay clean. Runs once at module load.
|
|
52
|
+
(function bootstrapTokenFromHash() {
|
|
53
|
+
if (typeof window === 'undefined') return;
|
|
54
|
+
const hash = window.location.hash.replace(/^#/, '');
|
|
55
|
+
if (!hash) return;
|
|
56
|
+
const params = new URLSearchParams(hash);
|
|
57
|
+
const sid = params.get(TOKEN_KEY);
|
|
528
58
|
if (sid) {
|
|
529
|
-
try {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
window.history.replaceState(
|
|
535
|
-
null,
|
|
536
|
-
'',
|
|
537
|
-
window.location.pathname + window.location.search + (rest ? '#' + rest : ''),
|
|
538
|
-
)
|
|
59
|
+
try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
|
|
60
|
+
params.delete(TOKEN_KEY);
|
|
61
|
+
const remaining = params.toString();
|
|
62
|
+
const newHash = remaining ? '#' + remaining : '';
|
|
63
|
+
window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
|
|
539
64
|
}
|
|
540
|
-
})()
|
|
65
|
+
})();
|
|
541
66
|
|
|
542
67
|
export function getSessionToken(): string | null {
|
|
543
|
-
try {
|
|
544
|
-
return sessionStorage.getItem(TOKEN_KEY)
|
|
545
|
-
} catch {
|
|
546
|
-
return null
|
|
547
|
-
}
|
|
68
|
+
try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
548
69
|
}
|
|
549
70
|
|
|
550
71
|
export function signInWithNetSuite(returnTo: string = window.location.href) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
72
|
+
if (!WORKER_URL) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const workerOrigin = new URL(WORKER_URL).origin;
|
|
78
|
+
const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
|
|
79
|
+
url.searchParams.set('return_to', returnTo);
|
|
80
|
+
// popup=1 tells the Worker callback to postMessage the session id back to
|
|
81
|
+
// THIS window (then close) instead of a #nssid= redirect.
|
|
82
|
+
url.searchParams.set('popup', '1');
|
|
83
|
+
|
|
84
|
+
// Open OAuth in a POPUP -- not the iframe, not the top frame. NetSuite's OAuth
|
|
85
|
+
// page is X-Frame-Options: DENY (can't load in an iframe), and navigating the
|
|
86
|
+
// TOP frame replaces the HelloLeo preview and lands the session in a DIFFERENT
|
|
87
|
+
// browsing context -- so back in the preview iframe (its own partitioned
|
|
88
|
+
// sessionStorage) you still see the login even after signing in. A popup keeps
|
|
89
|
+
// the app + preview intact and hands the token back to this exact context.
|
|
90
|
+
const popup = window.open(
|
|
91
|
+
url.toString(),
|
|
92
|
+
'netsuite-oauth',
|
|
93
|
+
'width=600,height=760,menubar=no,toolbar=no',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!popup) {
|
|
97
|
+
// Popup blocked -> fall back to top-frame navigation (works first-party / in
|
|
98
|
+
// a standalone tab; the callback then uses the #nssid= redirect).
|
|
99
|
+
try {
|
|
100
|
+
if (window.top && window.top !== window) {
|
|
101
|
+
window.top.location.href = url.toString();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* top window not accessible */
|
|
563
106
|
}
|
|
564
|
-
|
|
565
|
-
|
|
107
|
+
window.location.href = url.toString();
|
|
108
|
+
return;
|
|
566
109
|
}
|
|
567
|
-
|
|
110
|
+
|
|
111
|
+
const onMessage = (event: MessageEvent) => {
|
|
112
|
+
if (event.origin !== workerOrigin) return;
|
|
113
|
+
const data = event.data as { type?: string; nssid?: string } | null;
|
|
114
|
+
if (!data || data.type !== 'netsuite-auth' || !data.nssid) return;
|
|
115
|
+
try { sessionStorage.setItem(TOKEN_KEY, data.nssid); } catch {}
|
|
116
|
+
window.removeEventListener('message', onMessage);
|
|
117
|
+
try { popup.close(); } catch {}
|
|
118
|
+
// Re-render as signed-in -- the auth provider re-checks /api/me on load.
|
|
119
|
+
window.location.reload();
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('message', onMessage);
|
|
568
122
|
}
|
|
569
123
|
|
|
570
124
|
export interface NetSuiteSession {
|
|
571
|
-
authenticated: boolean
|
|
572
|
-
account_id?: string
|
|
125
|
+
authenticated: boolean;
|
|
126
|
+
account_id?: string;
|
|
127
|
+
user_id?: string;
|
|
128
|
+
email?: string;
|
|
573
129
|
}
|
|
574
130
|
|
|
575
131
|
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
132
|
+
if (!WORKER_URL) return { authenticated: false };
|
|
133
|
+
const sid = getSessionToken();
|
|
134
|
+
if (!sid) return { authenticated: false };
|
|
135
|
+
const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
|
|
136
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) return { authenticated: false };
|
|
139
|
+
const data = await res.json();
|
|
140
|
+
return { authenticated: Boolean(data?.account_id), ...data };
|
|
582
141
|
}
|
|
583
142
|
|
|
584
143
|
export async function signOutNetSuite(): Promise<void> {
|
|
585
|
-
|
|
144
|
+
if (!WORKER_URL) return;
|
|
145
|
+
const sid = getSessionToken();
|
|
586
146
|
if (sid) {
|
|
587
147
|
try {
|
|
588
|
-
await fetch('/auth/netsuite/logout
|
|
148
|
+
await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
|
|
589
149
|
method: 'POST',
|
|
590
150
|
headers: { Authorization: 'Bearer ' + sid },
|
|
591
|
-
})
|
|
151
|
+
});
|
|
592
152
|
} catch {}
|
|
593
153
|
}
|
|
594
|
-
try {
|
|
595
|
-
|
|
596
|
-
} catch {}
|
|
597
|
-
window.location.reload()
|
|
154
|
+
try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
|
|
155
|
+
window.location.reload();
|
|
598
156
|
}
|
|
599
157
|
|
|
600
158
|
/**
|
|
601
|
-
*
|
|
602
|
-
* instead of bare fetch
|
|
159
|
+
* Helper for ALL other Worker calls (your dashboard endpoints, etc.).
|
|
160
|
+
* Always go through this instead of bare fetch -- it injects the bearer
|
|
161
|
+
* token and routes through the Worker so NetSuite tokens stay server-side.
|
|
603
162
|
*/
|
|
604
|
-
export async function
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
163
|
+
export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
|
|
164
|
+
if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
|
|
165
|
+
const url = pathOrUrl.startsWith('http')
|
|
166
|
+
? pathOrUrl
|
|
167
|
+
: \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
|
|
168
|
+
const sid = getSessionToken();
|
|
169
|
+
const headers = new Headers(init.headers ?? {});
|
|
170
|
+
if (sid) headers.set('Authorization', 'Bearer ' + sid);
|
|
171
|
+
return fetch(url, { ...init, headers });
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
609
174
|
}
|
|
610
175
|
|
|
176
|
+
function signInButtonTsx(): string {
|
|
177
|
+
return `import { signInWithNetSuite } from '../../lib/netsuite';
|
|
178
|
+
|
|
179
|
+
export function SignInWithNetSuite() {
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => signInWithNetSuite()}
|
|
183
|
+
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"
|
|
184
|
+
>
|
|
185
|
+
Sign in with NetSuite
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function netsuiteLoginPageTsx(): string {
|
|
193
|
+
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
194
|
+
|
|
611
195
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
196
|
+
* Default unauthenticated landing page. Replaces any password gate.
|
|
197
|
+
* Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
|
|
198
|
+
* only call-to-action -- it points to the Worker's /auth/netsuite/start.
|
|
615
199
|
*/
|
|
616
|
-
export
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
200
|
+
export function LoginPage() {
|
|
201
|
+
return (
|
|
202
|
+
<div className="min-h-screen bg-background text-foreground flex">
|
|
203
|
+
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
204
|
+
<div className="text-xl font-semibold">NetSuite App</div>
|
|
205
|
+
<div>
|
|
206
|
+
<h1 className="text-4xl font-bold leading-tight">
|
|
207
|
+
Connect your NetSuite account
|
|
208
|
+
</h1>
|
|
209
|
+
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
210
|
+
Sign in with your NetSuite credentials to access your data securely.
|
|
211
|
+
Tokens stay server-side on HelloLeo Cloud -- they never reach the
|
|
212
|
+
browser.
|
|
213
|
+
</p>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="text-xs text-primary-foreground/60">
|
|
216
|
+
Powered by HelloLeo
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
220
|
+
<div className="w-full max-w-sm space-y-6">
|
|
221
|
+
<div>
|
|
222
|
+
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
223
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
224
|
+
Use the button below to sign in. You'll be redirected to NetSuite
|
|
225
|
+
to authorise access, then back here.
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
<SignInWithNetSuite />
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
625
233
|
}
|
|
626
234
|
`;
|
|
627
235
|
}
|
|
628
236
|
|
|
629
|
-
// --- emitted: src/lib/auth.tsx (client) ----------------------------------
|
|
630
237
|
function authTsx(): string {
|
|
631
|
-
return `import * as React from 'react'
|
|
238
|
+
return `import * as React from 'react';
|
|
632
239
|
import {
|
|
633
240
|
getNetSuiteSession,
|
|
634
241
|
signInWithNetSuite,
|
|
635
242
|
signOutNetSuite,
|
|
636
243
|
type NetSuiteSession,
|
|
637
|
-
} from './netsuite'
|
|
244
|
+
} from './netsuite';
|
|
638
245
|
|
|
639
246
|
/**
|
|
640
|
-
* NetSuite auth context. Holds the session (resolved
|
|
641
|
-
* via getNetSuiteSession) and exposes sign-in / sign-out.
|
|
642
|
-
* authoritative check -- never trust sessionStorage directly.
|
|
247
|
+
* NetSuite auth context. Holds the current session (resolved from the Worker's
|
|
248
|
+
* /api/me via getNetSuiteSession) and exposes sign-in / sign-out. The only
|
|
249
|
+
* authoritative auth check is /api/me -- never trust sessionStorage directly.
|
|
643
250
|
*/
|
|
644
251
|
export interface NetSuiteAuthContextValue {
|
|
645
|
-
pending: boolean
|
|
646
|
-
isAuthenticated: boolean
|
|
647
|
-
session: NetSuiteSession | null
|
|
648
|
-
signIn: () => void
|
|
649
|
-
signOut: () => Promise<void
|
|
650
|
-
refresh: () => Promise<void
|
|
252
|
+
pending: boolean;
|
|
253
|
+
isAuthenticated: boolean;
|
|
254
|
+
session: NetSuiteSession | null;
|
|
255
|
+
signIn: () => void;
|
|
256
|
+
signOut: () => Promise<void>;
|
|
257
|
+
refresh: () => Promise<void>;
|
|
651
258
|
}
|
|
652
259
|
|
|
653
|
-
const NetSuiteAuthContext =
|
|
260
|
+
const NetSuiteAuthContext =
|
|
261
|
+
React.createContext<NetSuiteAuthContextValue | null>(null);
|
|
654
262
|
|
|
655
|
-
export function NetSuiteAuthProvider({
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const [
|
|
263
|
+
export function NetSuiteAuthProvider({
|
|
264
|
+
children,
|
|
265
|
+
}: {
|
|
266
|
+
children: React.ReactNode;
|
|
267
|
+
}) {
|
|
268
|
+
const [session, setSession] = React.useState<NetSuiteSession | null>(null);
|
|
269
|
+
const [pending, setPending] = React.useState(true);
|
|
661
270
|
|
|
662
271
|
const refresh = React.useCallback(async () => {
|
|
663
|
-
setPending(true)
|
|
272
|
+
setPending(true);
|
|
664
273
|
try {
|
|
665
|
-
setSession(await getNetSuiteSession())
|
|
274
|
+
setSession(await getNetSuiteSession());
|
|
666
275
|
} finally {
|
|
667
|
-
setPending(false)
|
|
276
|
+
setPending(false);
|
|
668
277
|
}
|
|
669
|
-
}, [])
|
|
278
|
+
}, []);
|
|
670
279
|
|
|
671
280
|
React.useEffect(() => {
|
|
672
|
-
refresh()
|
|
673
|
-
}, [refresh])
|
|
281
|
+
refresh();
|
|
282
|
+
}, [refresh]);
|
|
674
283
|
|
|
675
284
|
const value: NetSuiteAuthContextValue = {
|
|
676
285
|
pending,
|
|
@@ -679,255 +288,138 @@ export function NetSuiteAuthProvider({ children }: { children: React.ReactNode }
|
|
|
679
288
|
signIn: () => signInWithNetSuite(),
|
|
680
289
|
signOut: signOutNetSuite,
|
|
681
290
|
refresh,
|
|
682
|
-
}
|
|
291
|
+
};
|
|
683
292
|
|
|
684
|
-
return
|
|
293
|
+
return (
|
|
294
|
+
<NetSuiteAuthContext.Provider value={value}>
|
|
295
|
+
{children}
|
|
296
|
+
</NetSuiteAuthContext.Provider>
|
|
297
|
+
);
|
|
685
298
|
}
|
|
686
299
|
|
|
687
300
|
export function useNetSuiteAuth(): NetSuiteAuthContextValue {
|
|
688
|
-
const ctx = React.useContext(NetSuiteAuthContext)
|
|
301
|
+
const ctx = React.useContext(NetSuiteAuthContext);
|
|
689
302
|
if (!ctx) {
|
|
690
|
-
throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>')
|
|
303
|
+
throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>');
|
|
691
304
|
}
|
|
692
|
-
return ctx
|
|
305
|
+
return ctx;
|
|
693
306
|
}
|
|
694
307
|
`;
|
|
695
308
|
}
|
|
696
309
|
|
|
697
|
-
// --- emitted: src/components/auth/AuthLayout.tsx --------------------------
|
|
698
310
|
function authLayoutTsx(): string {
|
|
699
|
-
return `import type { ReactNode } from 'react'
|
|
700
|
-
import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth'
|
|
701
|
-
import { LoginPage } from './LoginPage'
|
|
311
|
+
return `import type { ReactNode } from 'react';
|
|
312
|
+
import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth';
|
|
313
|
+
import { LoginPage } from './LoginPage';
|
|
702
314
|
|
|
703
315
|
/**
|
|
704
|
-
* NetSuite auth layout.
|
|
705
|
-
* <Outlet /> with it
|
|
316
|
+
* NetSuite auth layout. This is the ONLY wiring you need to add by hand --
|
|
317
|
+
* wrap the root route's <Outlet /> with it:
|
|
706
318
|
*
|
|
707
|
-
*
|
|
319
|
+
* // src/routes/__root.tsx
|
|
320
|
+
* import { AuthLayout } from '../components/auth/AuthLayout';
|
|
708
321
|
* ...
|
|
709
322
|
* function RootComponent() {
|
|
710
323
|
* return (
|
|
711
|
-
*
|
|
324
|
+
* <>
|
|
712
325
|
* <AuthLayout>
|
|
713
326
|
* <Outlet />
|
|
714
327
|
* </AuthLayout>
|
|
715
|
-
*
|
|
716
|
-
*
|
|
328
|
+
* { ...devtools... }
|
|
329
|
+
* </>
|
|
330
|
+
* );
|
|
717
331
|
* }
|
|
718
332
|
*
|
|
719
|
-
* It provides auth context and gates rendering:
|
|
720
|
-
*
|
|
721
|
-
*
|
|
333
|
+
* It provides NetSuite auth context to the whole tree and gates rendering:
|
|
334
|
+
* while the session resolves it shows a spinner, unauthenticated users see
|
|
335
|
+
* <LoginPage />, authenticated users see your routes. Use useNetSuiteAuth()
|
|
336
|
+
* anywhere below it to read the session or sign out. NEVER swap this for a
|
|
337
|
+
* password gate -- real OAuth is the only supported login.
|
|
722
338
|
*/
|
|
723
339
|
export function AuthLayout({ children }: { children: ReactNode }) {
|
|
724
340
|
return (
|
|
725
341
|
<NetSuiteAuthProvider>
|
|
726
342
|
<Gate>{children}</Gate>
|
|
727
343
|
</NetSuiteAuthProvider>
|
|
728
|
-
)
|
|
344
|
+
);
|
|
729
345
|
}
|
|
730
346
|
|
|
731
347
|
function Gate({ children }: { children: ReactNode }) {
|
|
732
|
-
const { pending, isAuthenticated } = useNetSuiteAuth()
|
|
348
|
+
const { pending, isAuthenticated } = useNetSuiteAuth();
|
|
733
349
|
|
|
734
350
|
if (pending) {
|
|
735
351
|
return (
|
|
736
352
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
737
353
|
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
738
354
|
</div>
|
|
739
|
-
)
|
|
355
|
+
);
|
|
740
356
|
}
|
|
741
357
|
|
|
742
|
-
if (!isAuthenticated) return <LoginPage
|
|
358
|
+
if (!isAuthenticated) return <LoginPage />;
|
|
743
359
|
|
|
744
|
-
return <>{children}
|
|
745
|
-
}
|
|
746
|
-
`;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// --- emitted: src/components/auth/LoginPage.tsx ---------------------------
|
|
750
|
-
function loginPageTsx(): string {
|
|
751
|
-
return `import { SignInWithNetSuite } from './SignInWithNetSuite'
|
|
752
|
-
|
|
753
|
-
/**
|
|
754
|
-
* Default unauthenticated landing page. Replaces any password gate. Customise
|
|
755
|
-
* the copy / imagery freely but keep <SignInWithNetSuite /> as the only
|
|
756
|
-
* call-to-action -- it points to /auth/netsuite/start.
|
|
757
|
-
*/
|
|
758
|
-
export function LoginPage() {
|
|
759
|
-
return (
|
|
760
|
-
<div className="min-h-screen bg-background text-foreground flex">
|
|
761
|
-
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
762
|
-
<div className="text-xl font-semibold">NetSuite App</div>
|
|
763
|
-
<div>
|
|
764
|
-
<h1 className="text-4xl font-bold leading-tight">Connect your NetSuite account</h1>
|
|
765
|
-
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
766
|
-
Sign in with your NetSuite credentials to access your data securely. Tokens stay
|
|
767
|
-
server-side -- they never reach the browser.
|
|
768
|
-
</p>
|
|
769
|
-
</div>
|
|
770
|
-
<div className="text-xs text-primary-foreground/60">Powered by HelloLeo</div>
|
|
771
|
-
</div>
|
|
772
|
-
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
773
|
-
<div className="w-full max-w-sm space-y-6">
|
|
774
|
-
<div>
|
|
775
|
-
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
776
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
777
|
-
Use the button below to sign in. You'll be redirected to NetSuite to authorise
|
|
778
|
-
access, then back here.
|
|
779
|
-
</p>
|
|
780
|
-
</div>
|
|
781
|
-
<SignInWithNetSuite />
|
|
782
|
-
</div>
|
|
783
|
-
</div>
|
|
784
|
-
</div>
|
|
785
|
-
)
|
|
786
|
-
}
|
|
787
|
-
`;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// --- emitted: src/components/auth/SignInWithNetSuite.tsx ------------------
|
|
791
|
-
function signInButtonTsx(): string {
|
|
792
|
-
return `import { signInWithNetSuite } from '../../lib/netsuite'
|
|
793
|
-
|
|
794
|
-
export function SignInWithNetSuite() {
|
|
795
|
-
return (
|
|
796
|
-
<button
|
|
797
|
-
onClick={() => signInWithNetSuite()}
|
|
798
|
-
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"
|
|
799
|
-
>
|
|
800
|
-
Sign in with NetSuite
|
|
801
|
-
</button>
|
|
802
|
-
)
|
|
360
|
+
return <>{children}</>;
|
|
803
361
|
}
|
|
804
362
|
`;
|
|
805
363
|
}
|
|
806
364
|
|
|
807
365
|
export const setupNetsuite = tool({
|
|
808
366
|
description:
|
|
809
|
-
"Add NetSuite
|
|
810
|
-
"
|
|
811
|
-
"
|
|
812
|
-
"
|
|
813
|
-
"Emits server routes (the OAuth backend lives IN the app -- no separate " +
|
|
814
|
-
"Worker, no VITE_WORKER_URL), the netsuite_flows + netsuite_sessions " +
|
|
815
|
-
"drizzle tables, and the same-origin client + auth components. Config " +
|
|
816
|
-
"(NETSUITE_ACCOUNT_ID/_CLIENT_ID/_CLIENT_SECRET) comes from the project " +
|
|
817
|
-
".env in dev and HelloLeo Cloud secrets in prod -- same var names, never " +
|
|
818
|
-
"in code. Real OAuth from day one -- never replace <AuthLayout> with a " +
|
|
819
|
-
"password placeholder. Setup guide (link the user to it): " +
|
|
820
|
-
"https://docs.helloleo.dev/integrations/netsuite",
|
|
367
|
+
"Add NetSuite OAuth wiring to an existing project. Call scaffold first, " +
|
|
368
|
+
"then this. Writes the OAuth client, auth provider and login components. " +
|
|
369
|
+
"All files are additive, nothing is overwritten. Real OAuth is wired " +
|
|
370
|
+
"from day one, never replace <AuthLayout> with a password placeholder.",
|
|
821
371
|
args: {},
|
|
822
372
|
async execute(_args, context) {
|
|
823
373
|
const dir = context.directory;
|
|
824
374
|
|
|
825
|
-
await mkdir(path.join(dir, "src", "lib"), { recursive: true });
|
|
826
375
|
await mkdir(path.join(dir, "src", "components", "auth"), {
|
|
827
376
|
recursive: true,
|
|
828
377
|
});
|
|
829
|
-
await mkdir(path.join(dir, "src", "routes", "auth", "netsuite"), {
|
|
830
|
-
recursive: true,
|
|
831
|
-
});
|
|
832
|
-
await mkdir(path.join(dir, "src", "routes", "api"), { recursive: true });
|
|
833
|
-
|
|
834
|
-
// Append the NetSuite tables to the drizzle schema (single source of
|
|
835
|
-
// truth: createDb pushes them in dev, migrations carry them to prod).
|
|
836
|
-
// Idempotent -- skip if already present.
|
|
837
|
-
const schemaPath = path.join(dir, "src", "db", "schema.ts");
|
|
838
|
-
let schemaAppended = false;
|
|
839
|
-
try {
|
|
840
|
-
const current = await readFile(schemaPath, "utf8");
|
|
841
|
-
if (!current.includes("netsuite_flows")) {
|
|
842
|
-
await Bun.write(schemaPath, current + schemaAppend());
|
|
843
|
-
schemaAppended = true;
|
|
844
|
-
}
|
|
845
|
-
} catch {
|
|
846
|
-
// No base schema.ts -- scaffold wasn't run. Write a minimal one.
|
|
847
|
-
await Bun.write(
|
|
848
|
-
schemaPath,
|
|
849
|
-
`import { sql } from 'drizzle-orm'\nimport { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'\n${schemaAppend()}`,
|
|
850
|
-
);
|
|
851
|
-
schemaAppended = true;
|
|
852
|
-
}
|
|
853
378
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
[
|
|
857
|
-
path.join("src", "routes", "auth", "netsuite", "start.ts"),
|
|
858
|
-
startRouteTs(),
|
|
859
|
-
],
|
|
860
|
-
[
|
|
861
|
-
path.join("src", "routes", "auth", "netsuite", "callback.ts"),
|
|
862
|
-
callbackRouteTs(),
|
|
863
|
-
],
|
|
864
|
-
[
|
|
865
|
-
path.join("src", "routes", "auth", "netsuite", "logout.ts"),
|
|
866
|
-
logoutRouteTs(),
|
|
867
|
-
],
|
|
868
|
-
[
|
|
869
|
-
path.join("src", "routes", "auth", "netsuite", "config.ts"),
|
|
870
|
-
configRouteTs(),
|
|
871
|
-
],
|
|
872
|
-
[path.join("src", "routes", "api", "me.ts"), meRouteTs()],
|
|
873
|
-
[path.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
|
|
379
|
+
// All additive -- nothing the base scaffold wrote is touched.
|
|
380
|
+
const nsFiles: [string, string][] = [
|
|
381
|
+
[path.join("src", "lib", "netsuite.ts"), netsuiteTs()],
|
|
874
382
|
[path.join("src", "lib", "auth.tsx"), authTsx()],
|
|
875
383
|
[
|
|
876
384
|
path.join("src", "components", "auth", "AuthLayout.tsx"),
|
|
877
385
|
authLayoutTsx(),
|
|
878
386
|
],
|
|
879
|
-
[
|
|
387
|
+
[
|
|
388
|
+
path.join("src", "components", "auth", "LoginPage.tsx"),
|
|
389
|
+
netsuiteLoginPageTsx(),
|
|
390
|
+
],
|
|
880
391
|
[
|
|
881
392
|
path.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
|
|
882
393
|
signInButtonTsx(),
|
|
883
394
|
],
|
|
884
395
|
];
|
|
885
396
|
|
|
886
|
-
for (const [filePath, content] of
|
|
397
|
+
for (const [filePath, content] of nsFiles) {
|
|
887
398
|
await Bun.write(path.join(dir, filePath), content);
|
|
888
399
|
}
|
|
889
400
|
|
|
890
401
|
return [
|
|
891
|
-
`Wrote ${
|
|
892
|
-
"
|
|
893
|
-
" -
|
|
894
|
-
" -
|
|
402
|
+
`Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
|
|
403
|
+
" - src/lib/netsuite.ts (OAuth client: signIn, session, workerFetch)",
|
|
404
|
+
" - src/lib/auth.tsx (NetSuiteAuthProvider and useNetSuiteAuth)",
|
|
405
|
+
" - src/components/auth/AuthLayout.tsx (provider and auth gate)",
|
|
406
|
+
" - src/components/auth/LoginPage.tsx (unauthenticated view)",
|
|
407
|
+
" - src/components/auth/SignInWithNetSuite.tsx (sign-in button)",
|
|
895
408
|
"",
|
|
896
|
-
"
|
|
897
|
-
"
|
|
898
|
-
"
|
|
899
|
-
"
|
|
900
|
-
"
|
|
901
|
-
"
|
|
902
|
-
" - src/routes/api/me.ts GET bearer -> session (auth check)",
|
|
903
|
-
" - src/db/schema.ts += netsuite_flows + netsuite_sessions",
|
|
904
|
-
" client:",
|
|
905
|
-
" - src/lib/netsuite.ts (same-origin: signIn, session, apiFetch)",
|
|
906
|
-
" - src/lib/auth.tsx (NetSuiteAuthProvider + useNetSuiteAuth)",
|
|
907
|
-
" - src/components/auth/{AuthLayout,LoginPage,SignInWithNetSuite}.tsx",
|
|
409
|
+
"Notes:",
|
|
410
|
+
"- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
|
|
411
|
+
"- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
|
|
412
|
+
"- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
|
|
413
|
+
"- 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).",
|
|
414
|
+
"- POPUP FLOW (required so login works inside the HelloLeo preview iframe): the client opens /auth/netsuite/start in a popup with ?popup=1. The worker MUST carry that flag through OAuth (persist it on the flow row in /start, read it back in /callback via state). In /auth/netsuite/callback, when the flow was popup, DO NOT redirect to return_to#nssid — instead return an HTML page whose script does: window.opener.postMessage({ type: 'netsuite-auth', nssid: SID }, '*'); window.close(); (fall back to the return_to#nssid redirect only when there is no opener / not a popup). Without this, the preview iframe (partitioned sessionStorage) keeps showing the login after sign-in.",
|
|
908
415
|
"",
|
|
909
|
-
"
|
|
416
|
+
"Next: in src/routes/__root.tsx, import AuthLayout and wrap <Outlet /> with it. Keep any existing providers.",
|
|
910
417
|
"",
|
|
911
418
|
" import { AuthLayout } from '../components/auth/AuthLayout'",
|
|
912
419
|
"",
|
|
913
420
|
" <AuthLayout>",
|
|
914
421
|
" <Outlet />",
|
|
915
422
|
" </AuthLayout>",
|
|
916
|
-
"",
|
|
917
|
-
"Then:",
|
|
918
|
-
"- Build your dashboard inside <AuthLayout>. Read the session with useNetSuiteAuth().",
|
|
919
|
-
"- Data routes: add src/routes/api/*.ts and call netsuiteFetch(bearer(request), '/services/rest/query/v1/suiteql', {...}) from the handler.",
|
|
920
|
-
"",
|
|
921
|
-
"TWO INTEGRATION RECORDS -- do not confuse them:",
|
|
922
|
-
"- Record #1 (scope: mcp): already connected, used ONLY to build the app (schema discovery). This login does NOT use it.",
|
|
923
|
-
"- 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).",
|
|
924
|
-
"",
|
|
925
|
-
"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:",
|
|
926
|
-
"- 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.",
|
|
927
|
-
"- 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.",
|
|
928
|
-
"- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
|
|
929
|
-
"- 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.",
|
|
930
|
-
"- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login.",
|
|
931
423
|
].join("\n");
|
|
932
424
|
},
|
|
933
425
|
});
|