Connect
Let your app or tool set up ExtraOrbital resources for its users and receive their env vars, with the user's approval.
Connect is for apps and developer tools that need resources on their user's account: a component kit whose components read MONGODB_URI, a starter that needs a bucket, a hosted builder. It follows the shape of OpenRouter's PKCE flow: your app never holds an ExtraOrbital token, only the values it asked for.
The request
https://extraorbital.dev/connect
?app_name=Acme
&resources=mongo,s3/uploads
&redirect_uri=https://acme.dev/api/extraorbital/callback
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256
&state=<random>| Parameter | |
|---|---|
app_name | Shown to the user, marked unverified, beside the host of redirect_uri. |
resources | Comma-separated service or service/name, at most 8. Names default to default. Two resources that would set the same env var are refused. |
redirect_uri | https://…, or http://localhost:<any port>/… for a local tool. No fragment. |
code_challenge | Required with redirect_uri. S256 only. |
state | Returned untouched. Check it. |
project | Optional: the id a new project gets. Defaults to your app name. |
ref | Optional: an affiliate slug. |
The user lands back on redirect_uri?code=eoc_…&state=…, or ?error=access_denied&state=… if they deny. The code works once, for 10 minutes.
The exchange
POST https://extraorbital.dev/api/v1/connect/token
Content-Type: application/json
{ "code": "eoc_…", "code_verifier": "…", "redirect_uri": "https://acme.dev/api/extraorbital/callback" }{
"grant_id": "cg_…",
"team_id": "team_…",
"project_id": "acme",
"env": { "MONGODB_URI": "mongodb+srv://…", "S3_BUCKET": "…" },
"resources": [{ "resource": "mongo/default", "env": ["MONGODB_URI"], "shared": false }]
}Call it from your server: the response has no CORS headers, so a browser page cannot read it. Errors are OAuth-shaped, { "error": "invalid_grant", "error_description": "…" }. Store the values the way you store any secret, or write them to your user's hosting provider with its API.
Each resource gets a credential of its own for your app, which ExtraOrbital does not keep a copy of. shared: true means the provider could only hand out the resource's shared credential.
Next.js example
// app/api/extraorbital/start/route.ts
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
export async function GET() {
const verifier = randomBytes(32).toString("base64url");
const state = randomBytes(16).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const jar = await cookies();
const options = { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600, path: "/api/extraorbital" } as const;
jar.set("eo_verifier", verifier, options);
jar.set("eo_state", state, options);
const url = new URL("https://extraorbital.dev/connect");
url.searchParams.set("app_name", "Acme");
url.searchParams.set("resources", "mongo,s3/uploads");
url.searchParams.set("redirect_uri", `${process.env.APP_URL}/api/extraorbital/callback`);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
return Response.redirect(url, 302);
}// app/api/extraorbital/callback/route.ts
import { cookies } from "next/headers";
export async function GET(request: Request) {
const url = new URL(request.url);
const jar = await cookies();
const verifier = jar.get("eo_verifier")?.value;
if (!verifier || url.searchParams.get("state") !== jar.get("eo_state")?.value) {
return new Response("State mismatch", { status: 400 });
}
jar.delete("eo_verifier");
jar.delete("eo_state");
if (url.searchParams.get("error")) return Response.redirect(new URL("/?connect=denied", url), 302);
const response = await fetch("https://extraorbital.dev/api/v1/connect/token", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
code: url.searchParams.get("code"),
code_verifier: verifier,
redirect_uri: `${process.env.APP_URL}/api/extraorbital/callback`,
}),
});
if (!response.ok) return new Response("Could not connect ExtraOrbital", { status: 502 });
const { env, project_id } = (await response.json()) as { env: Record<string, string>; project_id: string };
await saveSecretsForCurrentUser(project_id, env); // your storage
return Response.redirect(new URL("/?connect=done", url), 302);
}An "Add to ExtraOrbital" button
Without redirect_uri, nothing is sent to anyone: the user approves, the resources are created, and the last screen shows the npx extraorbital add … commands that write them to .env in their project folder. That suits a README:
[Add to ExtraOrbital](https://extraorbital.dev/connect?app_name=my-starter&resources=mongo,s3&ref=my-starter)What the user controls
- The consent screen names your app (unverified) and where the values go, the team and project, every resource with its env var names and price. Resources go to a new project named after your app unless the user picks an existing one.
- Resources are billed to the user's team like any other.
- Team settings → Connected apps lists every app with its project and env var names, and revokes it: your credentials stop working, and the resources stay with the user.
- Every approval, exchange and revocation is in the team's activity log, without values.