Skip to content
extraorbital
Documentation ExtraOrbital
Browse documentation

Reference

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

Output
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_nameShown to the user, marked unverified, beside the host of redirect_uri.
resourcesComma-separated service or service/name, at most 8. Names default to default. Two resources that would set the same env var are refused.
redirect_urihttps://…, or http://localhost:<any port>/… for a local tool. No fragment.
code_challengeRequired with redirect_uri. S256 only.
stateReturned untouched. Check it.
projectOptional: the id a new project gets. Defaults to your app name.
refOptional: 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

http
POST https://extraorbital.dev/api/v1/connect/token
Content-Type: application/json

{ "code": "eoc_…", "code_verifier": "…", "redirect_uri": "https://acme.dev/api/extraorbital/callback" }
json
{
  "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

ts
// 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);
}
ts
// 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:

md
[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.