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

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