@helloleo/plugins 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -25561,6 +25561,17 @@ async function readScaffoldId2(dir) {
25561
25561
  return null;
25562
25562
  }
25563
25563
  }
25564
+ async function isStartScaffold(dir) {
25565
+ const scaffoldId = await readScaffoldId2(dir);
25566
+ if (scaffoldId)
25567
+ return scaffoldId.startsWith("tanstack-start");
25568
+ try {
25569
+ const pkg = await Bun.file(path7.join(dir, "package.json")).json();
25570
+ return Boolean(pkg?.dependencies?.["@tanstack/react-start"] ?? pkg?.devDependencies?.["@tanstack/react-start"]);
25571
+ } catch {
25572
+ return false;
25573
+ }
25574
+ }
25564
25575
  function schemaAppend() {
25565
25576
  return `
25566
25577
  // --- NetSuite OAuth (added by setup_netsuite) ---
@@ -25602,7 +25613,9 @@ function netsuiteServerTs() {
25602
25613
  * Config is read from the Cloudflare env binding via @helloleo/runtime.
25603
25614
  * Set NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET (secret optional for a
25604
25615
  * PKCE public client) and optional PUBLIC_APP_URL as HelloLeo Cloud secrets
25605
- * (prod) or dev vars (preview). They never reach the browser.
25616
+ * (prod) or dev vars (preview). NETSUITE_BROKER_CALLBACK (written by the
25617
+ * HelloLeo wizard) switches the redirect URI to the shared OAuth broker so
25618
+ * one URI serves every app/environment. They never reach the browser.
25606
25619
  */
25607
25620
  import { eq } from 'drizzle-orm'
25608
25621
  import { env as runtimeEnv } from '@helloleo/runtime'
@@ -25613,6 +25626,7 @@ interface NsEnv {
25613
25626
  NETSUITE_ACCOUNT_ID?: string
25614
25627
  NETSUITE_CLIENT_ID?: string
25615
25628
  NETSUITE_CLIENT_SECRET?: string
25629
+ NETSUITE_BROKER_CALLBACK?: string
25616
25630
  PUBLIC_APP_URL?: string
25617
25631
  HELLOLEO_URL?: string
25618
25632
  }
@@ -25673,8 +25687,47 @@ export function appOrigin(request: Request) {
25673
25687
  url.host
25674
25688
  return \`\${proto}://\${hostHeader}\`
25675
25689
  }
25690
+ /**
25691
+ * HelloLeo's shared OAuth broker (integration-proxy Worker). When
25692
+ * NETSUITE_BROKER_CALLBACK is set (the platform writes it to the project env),
25693
+ * customers register that ONE stable URI on their Integration Record instead
25694
+ * of one URI per app/environment: the authorize redirect goes to the broker,
25695
+ * the broker 302s the callback back to THIS app (looked up by the state
25696
+ * nonce), and the token exchange passes the same broker URI. Unset (self-host
25697
+ * / standalone), the app falls back to its own origin's callback.
25698
+ */
25699
+ export function brokerCallback(): string | null {
25700
+ const raw = nsEnv.NETSUITE_BROKER_CALLBACK
25701
+ if (!raw) return null
25702
+ try {
25703
+ const u = new URL(raw)
25704
+ return u.protocol === 'https:' ? u.toString() : null
25705
+ } catch {
25706
+ return null
25707
+ }
25708
+ }
25709
+
25676
25710
  export function redirectUri(request: Request) {
25677
- return \`\${appOrigin(request)}/auth/netsuite/callback\`
25711
+ return brokerCallback() ?? \`\${appOrigin(request)}/auth/netsuite/callback\`
25712
+ }
25713
+
25714
+ /**
25715
+ * Tell the broker where to send this flow's callback: nonce -> this app's
25716
+ * origin. MUST succeed before redirecting to NetSuite, otherwise the callback
25717
+ * dead-ends at the broker. No-op when the broker isn't configured.
25718
+ */
25719
+ export async function registerWithBroker(request: Request, flowId: string): Promise<void> {
25720
+ const callback = brokerCallback()
25721
+ if (!callback) return
25722
+ const registerUrl = new URL('/auth/netsuite/register', callback)
25723
+ const res = await fetch(registerUrl.toString(), {
25724
+ method: 'POST',
25725
+ headers: { 'Content-Type': 'application/json' },
25726
+ body: JSON.stringify({ nonce: flowId, target: appOrigin(request) }),
25727
+ })
25728
+ if (!res.ok) {
25729
+ throw new Error(\`NetSuite broker registration failed (HTTP \${res.status}) -- cannot start sign-in\`)
25730
+ }
25678
25731
  }
25679
25732
 
25680
25733
  // Coerce ?return_to= to our OWN origin. The callback appends the session bearer
@@ -25838,6 +25891,7 @@ import {
25838
25891
  authorizeUrl,
25839
25892
  pkce,
25840
25893
  redirectUri,
25894
+ registerWithBroker,
25841
25895
  requireConfig,
25842
25896
  safeReturnTo,
25843
25897
  SCOPE,
@@ -25855,6 +25909,10 @@ export const Route = createFileRoute('/auth/netsuite/start')({
25855
25909
 
25856
25910
  const { verifier, challenge } = await pkce()
25857
25911
  const flowId = crypto.randomUUID()
25912
+ // With the shared broker, NetSuite redirects to the broker's fixed
25913
+ // callback and the broker forwards here -- it must know this flow's
25914
+ // target before the user leaves for NetSuite.
25915
+ await registerWithBroker(request, flowId)
25858
25916
  await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
25859
25917
 
25860
25918
  const authorize = new URL(authorizeUrl(accountId))
@@ -26717,8 +26775,8 @@ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
26717
26775
  "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:",
26718
26776
  "- 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.",
26719
26777
  "- 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.",
26720
- "- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
26721
- "- 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). ONE redirect URI per record: register exactly the value /auth/netsuite/config returns for the environment the user signs into. If login must work in BOTH the preview and the published app, use a separate Integration Record per environment (same env var names, different NETSUITE_CLIENT_ID/_SECRET values per environment) -- do not stack URLs on one record.",
26778
+ "- PUBLIC_APP_URL / HELLOLEO_URL / NETSUITE_BROKER_CALLBACK are set by the platform per environment; don't hardcode them.",
26779
+ "- REDIRECT URI -- never guess or hardcode it. When NETSUITE_BROKER_CALLBACK is set (the HelloLeo wizard writes it), the redirect URI is HelloLeo's shared OAuth broker: ONE stable URI the user registers ONCE on Integration Record #2 (scope: rest_webservices), valid for both the preview and the published app -- the broker routes each callback back to this app. Without it, the app falls back to its own origin's callback. Either way, read the exact value from GET /auth/netsuite/config (or getNetSuiteRedirectUri() on the client) and tell the user to register THAT.",
26722
26780
  "- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login."
26723
26781
  ].join(`
26724
26782
  `);
@@ -26773,13 +26831,11 @@ async function executeSpa(dir) {
26773
26831
  `);
26774
26832
  }
26775
26833
  var setupNetsuite = tool({
26776
- description: "Add NetSuite runtime OAuth (end-user LOGIN) to an existing project. " + "Call scaffold first, then this. Works on both scaffold types: it " + "writes the client + auth components, and the OAuth backend is the " + "project's HelloLeo Cloud Worker (VITE_WORKER_URL) -- the platform " + "wizard registers the Worker's fixed /auth/netsuite/callback on the " + "Integration Record, so the app never hosts the OAuth routes itself. " + "This login uses NetSuite Integration Record #2 " + "(scope: rest_webservices) -- a SEPARATE record from the MCP one " + "(scope: mcp) that was used only to BUILD the app; do not confuse " + "them. Real OAuth from day one -- never replace <AuthLayout> with a " + "password placeholder. Setup guide (link the user to it): " + "https://docs.helloleo.dev/integrations/netsuite",
26834
+ description: "Add NetSuite runtime OAuth (end-user LOGIN) to an existing project. " + "Call scaffold first, then this. Works on both scaffold types and " + "picks the variant automatically: on tanstack-start the app hosts " + "its own OAuth routes (/auth/netsuite/* + /api/me, same-origin " + "client); on tanstack-router (SPA) it writes the client + auth " + "components and the OAuth backend is the project's HelloLeo Cloud " + "Worker (VITE_WORKER_URL). This login uses NetSuite Integration " + "Record #2 (scope: rest_webservices) -- a SEPARATE record from the " + "MCP one (scope: mcp) that was used only to BUILD the app; do not " + "confuse them. Real OAuth from day one -- never replace <AuthLayout> " + "with a password placeholder. Setup guide (link the user to it): " + "https://docs.helloleo.dev/integrations/netsuite",
26777
26835
  args: {},
26778
26836
  async execute(_args, context) {
26779
26837
  const dir = context.directory;
26780
- const scaffoldId = await readScaffoldId2(dir);
26781
- const inAppOptIn = process.env.PLUGINS_NETSUITE_IN_APP === "1";
26782
- if (inAppOptIn && scaffoldId?.startsWith("tanstack-start")) {
26838
+ if (await isStartScaffold(dir)) {
26783
26839
  return executeStart(dir);
26784
26840
  }
26785
26841
  return executeSpa(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -4,19 +4,20 @@ import type { Plugin } from "@opencode-ai/plugin";
4
4
  import { tool } from "@opencode-ai/plugin";
5
5
 
6
6
  /**
7
- * NetSuite runtime OAuth (end-user LOGIN). Two variants:
7
+ * NetSuite runtime OAuth (end-user LOGIN). Two variants, picked automatically
8
+ * from the project's scaffold -- never from env:
8
9
  *
9
- * - DEFAULT (every scaffold type): client-only files; the OAuth backend is
10
- * the per-project HelloLeo Cloud Worker (VITE_WORKER_URL), sign-in runs in
10
+ * - tanstack-start (SSR): the app IS the backend. Emits server routes
11
+ * (/auth/netsuite/* + /api/me), netsuite_flows + netsuite_sessions drizzle
12
+ * tables, and a same-origin client. The redirect URI registered on
13
+ * Integration Record #2 must be the APP's own /auth/netsuite/callback
14
+ * (read it from GET /auth/netsuite/config).
15
+ * - tanstack-router (SPA): client-only files; the OAuth backend is the
16
+ * per-project HelloLeo Cloud Worker (VITE_WORKER_URL), sign-in runs in
11
17
  * a popup + postMessage so it works inside the HelloLeo preview iframe.
12
- * This matches prod: the platform wizard registers the Worker's fixed
13
- * /auth/netsuite/callback as the ONE redirect URI on Integration Record #2
14
- * (provision-first, see webapp netsuiteRuntimeSetup).
15
- * - IN-APP (opt-in: PLUGINS_NETSUITE_IN_APP=1 + tanstack-start scaffold):
16
- * the app IS the backend. Emits server routes (/auth/netsuite/* + /api/me),
17
- * netsuite_flows + netsuite_sessions drizzle tables, and a same-origin
18
- * client. Only valid when the deployment registers the APP's own callback
19
- * on the Integration Record instead of the Worker's.
18
+ * The wizard registers the Worker's fixed /auth/netsuite/callback as the
19
+ * ONE redirect URI on Integration Record #2 (provision-first, see webapp
20
+ * netsuiteRuntimeSetup).
20
21
  *
21
22
  * Both variants share the same component names (AuthLayout / LoginPage /
22
23
  * SignInWithNetSuite) and the same wiring step: wrap the root route's
@@ -42,6 +43,26 @@ async function readScaffoldId(dir: string): Promise<string | null> {
42
43
  }
43
44
  }
44
45
 
46
+ /**
47
+ * Is this project a tanstack-start (SSR) app? Primary signal: the scaffold id
48
+ * in .leo/project.json. Fallback for pre-project.json workspaces: only the
49
+ * start scaffold depends on @tanstack/react-start -- the router SPA ships
50
+ * @tanstack/react-router alone.
51
+ */
52
+ async function isStartScaffold(dir: string): Promise<boolean> {
53
+ const scaffoldId = await readScaffoldId(dir);
54
+ if (scaffoldId) return scaffoldId.startsWith("tanstack-start");
55
+ try {
56
+ const pkg = await Bun.file(path.join(dir, "package.json")).json();
57
+ return Boolean(
58
+ pkg?.dependencies?.["@tanstack/react-start"] ??
59
+ pkg?.devDependencies?.["@tanstack/react-start"],
60
+ );
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
45
66
  // --- emitted: src/db/schema.ts (appended) --------------------------------
46
67
  function schemaAppend(): string {
47
68
  return `
@@ -86,7 +107,9 @@ function netsuiteServerTs(): string {
86
107
  * Config is read from the Cloudflare env binding via @helloleo/runtime.
87
108
  * Set NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET (secret optional for a
88
109
  * PKCE public client) and optional PUBLIC_APP_URL as HelloLeo Cloud secrets
89
- * (prod) or dev vars (preview). They never reach the browser.
110
+ * (prod) or dev vars (preview). NETSUITE_BROKER_CALLBACK (written by the
111
+ * HelloLeo wizard) switches the redirect URI to the shared OAuth broker so
112
+ * one URI serves every app/environment. They never reach the browser.
90
113
  */
91
114
  import { eq } from 'drizzle-orm'
92
115
  import { env as runtimeEnv } from '@helloleo/runtime'
@@ -97,6 +120,7 @@ interface NsEnv {
97
120
  NETSUITE_ACCOUNT_ID?: string
98
121
  NETSUITE_CLIENT_ID?: string
99
122
  NETSUITE_CLIENT_SECRET?: string
123
+ NETSUITE_BROKER_CALLBACK?: string
100
124
  PUBLIC_APP_URL?: string
101
125
  HELLOLEO_URL?: string
102
126
  }
@@ -157,8 +181,47 @@ export function appOrigin(request: Request) {
157
181
  url.host
158
182
  return \`\${proto}://\${hostHeader}\`
159
183
  }
184
+ /**
185
+ * HelloLeo's shared OAuth broker (integration-proxy Worker). When
186
+ * NETSUITE_BROKER_CALLBACK is set (the platform writes it to the project env),
187
+ * customers register that ONE stable URI on their Integration Record instead
188
+ * of one URI per app/environment: the authorize redirect goes to the broker,
189
+ * the broker 302s the callback back to THIS app (looked up by the state
190
+ * nonce), and the token exchange passes the same broker URI. Unset (self-host
191
+ * / standalone), the app falls back to its own origin's callback.
192
+ */
193
+ export function brokerCallback(): string | null {
194
+ const raw = nsEnv.NETSUITE_BROKER_CALLBACK
195
+ if (!raw) return null
196
+ try {
197
+ const u = new URL(raw)
198
+ return u.protocol === 'https:' ? u.toString() : null
199
+ } catch {
200
+ return null
201
+ }
202
+ }
203
+
160
204
  export function redirectUri(request: Request) {
161
- return \`\${appOrigin(request)}/auth/netsuite/callback\`
205
+ return brokerCallback() ?? \`\${appOrigin(request)}/auth/netsuite/callback\`
206
+ }
207
+
208
+ /**
209
+ * Tell the broker where to send this flow's callback: nonce -> this app's
210
+ * origin. MUST succeed before redirecting to NetSuite, otherwise the callback
211
+ * dead-ends at the broker. No-op when the broker isn't configured.
212
+ */
213
+ export async function registerWithBroker(request: Request, flowId: string): Promise<void> {
214
+ const callback = brokerCallback()
215
+ if (!callback) return
216
+ const registerUrl = new URL('/auth/netsuite/register', callback)
217
+ const res = await fetch(registerUrl.toString(), {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ nonce: flowId, target: appOrigin(request) }),
221
+ })
222
+ if (!res.ok) {
223
+ throw new Error(\`NetSuite broker registration failed (HTTP \${res.status}) -- cannot start sign-in\`)
224
+ }
162
225
  }
163
226
 
164
227
  // Coerce ?return_to= to our OWN origin. The callback appends the session bearer
@@ -324,6 +387,7 @@ import {
324
387
  authorizeUrl,
325
388
  pkce,
326
389
  redirectUri,
390
+ registerWithBroker,
327
391
  requireConfig,
328
392
  safeReturnTo,
329
393
  SCOPE,
@@ -341,6 +405,10 @@ export const Route = createFileRoute('/auth/netsuite/start')({
341
405
 
342
406
  const { verifier, challenge } = await pkce()
343
407
  const flowId = crypto.randomUUID()
408
+ // With the shared broker, NetSuite redirects to the broker's fixed
409
+ // callback and the broker forwards here -- it must know this flow's
410
+ // target before the user leaves for NetSuite.
411
+ await registerWithBroker(request, flowId)
344
412
  await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
345
413
 
346
414
  const authorize = new URL(authorizeUrl(accountId))
@@ -1239,8 +1307,8 @@ async function executeStart(dir: string): Promise<string> {
1239
1307
  "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:",
1240
1308
  "- 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.",
1241
1309
  "- 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.",
1242
- "- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
1243
- "- 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). ONE redirect URI per record: register exactly the value /auth/netsuite/config returns for the environment the user signs into. If login must work in BOTH the preview and the published app, use a separate Integration Record per environment (same env var names, different NETSUITE_CLIENT_ID/_SECRET values per environment) -- do not stack URLs on one record.",
1310
+ "- PUBLIC_APP_URL / HELLOLEO_URL / NETSUITE_BROKER_CALLBACK are set by the platform per environment; don't hardcode them.",
1311
+ "- REDIRECT URI -- never guess or hardcode it. When NETSUITE_BROKER_CALLBACK is set (the HelloLeo wizard writes it), the redirect URI is HelloLeo's shared OAuth broker: ONE stable URI the user registers ONCE on Integration Record #2 (scope: rest_webservices), valid for both the preview and the published app -- the broker routes each callback back to this app. Without it, the app falls back to its own origin's callback. Either way, read the exact value from GET /auth/netsuite/config (or getNetSuiteRedirectUri() on the client) and tell the user to register THAT.",
1244
1312
  "- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login.",
1245
1313
  ].join("\n");
1246
1314
  }
@@ -1302,31 +1370,27 @@ async function executeSpa(dir: string): Promise<string> {
1302
1370
  export const setupNetsuite = tool({
1303
1371
  description:
1304
1372
  "Add NetSuite runtime OAuth (end-user LOGIN) to an existing project. " +
1305
- "Call scaffold first, then this. Works on both scaffold types: it " +
1306
- "writes the client + auth components, and the OAuth backend is the " +
1307
- "project's HelloLeo Cloud Worker (VITE_WORKER_URL) -- the platform " +
1308
- "wizard registers the Worker's fixed /auth/netsuite/callback on the " +
1309
- "Integration Record, so the app never hosts the OAuth routes itself. " +
1310
- "This login uses NetSuite Integration Record #2 " +
1311
- "(scope: rest_webservices) -- a SEPARATE record from the MCP one " +
1312
- "(scope: mcp) that was used only to BUILD the app; do not confuse " +
1313
- "them. Real OAuth from day one -- never replace <AuthLayout> with a " +
1314
- "password placeholder. Setup guide (link the user to it): " +
1373
+ "Call scaffold first, then this. Works on both scaffold types and " +
1374
+ "picks the variant automatically: on tanstack-start the app hosts " +
1375
+ "its own OAuth routes (/auth/netsuite/* + /api/me, same-origin " +
1376
+ "client); on tanstack-router (SPA) it writes the client + auth " +
1377
+ "components and the OAuth backend is the project's HelloLeo Cloud " +
1378
+ "Worker (VITE_WORKER_URL). This login uses NetSuite Integration " +
1379
+ "Record #2 (scope: rest_webservices) -- a SEPARATE record from the " +
1380
+ "MCP one (scope: mcp) that was used only to BUILD the app; do not " +
1381
+ "confuse them. Real OAuth from day one -- never replace <AuthLayout> " +
1382
+ "with a password placeholder. Setup guide (link the user to it): " +
1315
1383
  "https://docs.helloleo.dev/integrations/netsuite",
1316
1384
  args: {},
1317
1385
  async execute(_args, context) {
1318
1386
  const dir = context.directory;
1319
- // Prod model: Integration Record #2 carries ONE fixed redirect URI --
1320
- // the per-project HelloLeo Cloud Worker's /auth/netsuite/callback
1321
- // (provision-first wizard, see webapp netsuiteRuntimeSetup). So the
1322
- // Worker variant is the default for EVERY scaffold type. The in-app
1323
- // server-route variant (app-origin redirect URI) only makes sense when
1324
- // a deployment's wizard registers the app's own callback instead --
1325
- // opt in per deployment with PLUGINS_NETSUITE_IN_APP=1 on a
1326
- // tanstack-start scaffold.
1327
- const scaffoldId = await readScaffoldId(dir);
1328
- const inAppOptIn = process.env.PLUGINS_NETSUITE_IN_APP === "1";
1329
- if (inAppOptIn && scaffoldId?.startsWith("tanstack-start")) {
1387
+ // The variant follows the scaffold, never an env flag: a start app has
1388
+ // a server (TanStack Start routes + D1) so it hosts the OAuth itself;
1389
+ // the router SPA has no server, so the per-project HelloLeo Cloud
1390
+ // Worker is the backend. The redirect URI registered on Integration
1391
+ // Record #2 must match: the app's own /auth/netsuite/callback for
1392
+ // start, the Worker's fixed callback for the SPA.
1393
+ if (await isStartScaffold(dir)) {
1330
1394
  return executeStart(dir);
1331
1395
  }
1332
1396
  return executeSpa(dir);