diff --git a/README.md b/README.md
index d4d7c2ce..1423fd87 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,7 @@ When you add a new sample, make sure to add any config vals to the `deploy-chang
| [Ollama](./samples/ollama) | Ollama is a tool that lets you easily run large language models. | AI, LLM, ML, Llama, Mistral, Next.js, AI SDK, | Typescript |
| [Phoenix & PostgreSQL](./samples/phoenix-postgres) | A sample Phoenix application that uses a PostgreSQL database. | Phoenix, PostgreSQL, Database, Elixir | Elixir |
| [Platformatic](./samples/platformatic) | A sample project showcasing a simple Platformatic service with Docker deployment. | Platformatic, Defang, Docker, Node.js, Service, JavaScript | nodejs |
+| [Programmatic Customer Handoff](./samples/customer-handoff) | A server-backed demo for creating customer cloud-setup handoffs with Defang Deploy. | Defang, Customer Onboarding, Cloud, GitHub, sample | nodejs, html, css, javascript |
| [Pulumi](./samples/pulumi) | A basic Pulumi example. | Pulumi, Node.js, HTTP, Server, TypeScript | nodejs |
| [Pulumi & Remix & PostgreSQL](./samples/pulumi-remix-postgres) | A full-stack example using Remix, Prisma, and Aiven. | Full-stack, Remix, Prisma, Aiven, PostgreSQL, Pulumi, Node.js, TypeScript, SQL | nodejs |
| [Python & Form](./samples/python-form) | A short Python example for form submission in Flask. | Python, Flask, Form | python |
diff --git a/samples/customer-handoff/README.md b/samples/customer-handoff/README.md
new file mode 100644
index 00000000..0d16d7ba
--- /dev/null
+++ b/samples/customer-handoff/README.md
@@ -0,0 +1,75 @@
+# Programmatic Customer Handoff
+
+[](https://portal.defang.io/sample/customer-handoff)
+
+This demo shows how a software provider can create a hosted cloud-setup handoff for a customer. The provider chooses one of its Defang projects and defines the GitHub trust boundary. Defang creates the pending customer installation and returns a link where the customer signs in and connects their cloud account.
+
+The application calls Portal from its Node.js backend. For short-lived manual testing, the demo accepts a token in the browser, forwards it to its own backend, and never saves or logs it. A production integration should obtain the developer credential through its existing authenticated server flow instead.
+
+> [!IMPORTANT]
+> The current Portal API accepts an existing developer bearer token. Durable machine credentials are outside the scope of the initial API, so this sample is a demonstration rather than an unattended production integration.
+
+## Prerequisites
+
+1. Open the repository in VS Code with Dev Containers.
+2. Have a Defang developer account with an existing project.
+3. Obtain a current Portal access token for that developer account.
+4. For the customer-completion step, use an email inbox and cloud account you control.
+
+## Development
+
+Run the application locally:
+
+```bash
+docker compose up --build
+```
+
+Then open `http://localhost:8080`.
+
+## Configuration
+
+The demo targets the production Portal by default. To test Portal PR #1069 in the dev environment, set its GraphQL endpoint before starting the application:
+
+```bash
+PORTAL_GRAPHQL_URL=https://graphql.dev.gnafed.click/v1/graphql docker compose up --build
+```
+
+Do not commit access tokens. They expire and grant access to the developer workspace.
+
+## Test the handoff
+
+1. Load the developer workspaces and select a project.
+2. Enter an email address you can access and a unique installation name.
+3. Define the GitHub organization, repository pattern, and allowed reference.
+4. Create the handoff.
+5. Open the exact link returned by Portal and sign in with the same customer email.
+6. Confirm the installation details, then connect a test cloud account.
+
+Creating the handoff does not deploy a workload. Cloud setup creates the deployable stack. To test the broader deployment flow, deploy a small project through that stack and use a one-hour TTL so test resources are removed automatically.
+
+Run the backend tests with:
+
+```bash
+cd app
+npm test
+```
+
+## Deployment
+
+Deploy the demo with:
+
+```bash
+defang compose up
+```
+
+The demo does not persist developer tokens and cannot make Portal requests without a token supplied for that request.
+
+---
+
+Title: Programmatic Customer Handoff
+
+Short Description: A server-backed demo for creating customer cloud-setup handoffs with Defang Deploy.
+
+Tags: Defang, Customer Onboarding, Cloud, GitHub, sample
+
+Languages: nodejs, html, css, javascript
diff --git a/samples/customer-handoff/app/Dockerfile b/samples/customer-handoff/app/Dockerfile
new file mode 100644
index 00000000..d9fb89ca
--- /dev/null
+++ b/samples/customer-handoff/app/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:22-alpine
+
+WORKDIR /app
+
+COPY --chown=node:node package.json server.js portal-client.js ./
+COPY --chown=node:node public ./public
+
+ENV NODE_ENV=production
+USER node
+
+EXPOSE 8080
+
+CMD ["node", "server.js"]
diff --git a/samples/customer-handoff/app/package.json b/samples/customer-handoff/app/package.json
new file mode 100644
index 00000000..d51c48b0
--- /dev/null
+++ b/samples/customer-handoff/app/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "customer-handoff",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node server.js",
+ "test": "node --test"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/samples/customer-handoff/app/portal-client.js b/samples/customer-handoff/app/portal-client.js
new file mode 100644
index 00000000..9c724a38
--- /dev/null
+++ b/samples/customer-handoff/app/portal-client.js
@@ -0,0 +1,239 @@
+const CONTEXT_QUERY = `
+ query ProgrammaticHandoffDemoContext {
+ tenants: allAuthorizedTenants {
+ id
+ name
+ ownerId
+ }
+ projects(orderBy: { label: ASC }) {
+ id
+ tenantId
+ name
+ label
+ }
+ }
+`;
+
+const CREATE_HANDOFF_MUTATION = `
+ mutation CreateProgrammaticHandoff($input: CreateInstallationHandoffInput!) {
+ createInstallationHandoff(input: $input) {
+ installationId
+ status
+ handoffUrl
+ }
+ }
+`;
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+export class PortalRequestError extends Error {
+ constructor(message, status = 502) {
+ super(message);
+ this.name = "PortalRequestError";
+ this.status = status;
+ }
+}
+
+function requireText(value, field, maxLength = 256) {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new PortalRequestError(`${field} is required.`, 400);
+ }
+ const normalized = value.trim();
+ if (normalized.length > maxLength) {
+ throw new PortalRequestError(`${field} is too long.`, 400);
+ }
+ return normalized;
+}
+
+function requireUuid(value, field) {
+ const normalized = requireText(value, field, 36);
+ if (!UUID_PATTERN.test(normalized)) {
+ throw new PortalRequestError(`${field} must be a valid ID.`, 400);
+ }
+ return normalized;
+}
+
+function validateEmail(value) {
+ const email = requireText(value, "Customer email", 320).toLowerCase();
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ throw new PortalRequestError("Enter a valid customer email.", 400);
+ }
+ return email;
+}
+
+export function validateHandoffInput(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new PortalRequestError("Handoff details are required.", 400);
+ }
+
+ const cloudProvider = requireText(value.cloudProvider, "Cloud provider", 16);
+ if (!["aws", "gcp", "azure"].includes(cloudProvider)) {
+ throw new PortalRequestError("Choose AWS, GCP, or Azure.", 400);
+ }
+
+ const refType = requireText(value.refType, "Git reference type", 16);
+ if (!["all", "branch", "environment"].includes(refType)) {
+ throw new PortalRequestError(
+ "Choose all refs, a branch, or an environment.",
+ 400,
+ );
+ }
+
+ const refPattern =
+ refType === "all"
+ ? null
+ : requireText(value.refPattern, "Git reference pattern", 256);
+
+ return {
+ tenantId: requireUuid(value.tenantId, "Workspace"),
+ customerEmail: validateEmail(value.customerEmail),
+ projectId: requireUuid(value.projectId, "Project"),
+ installationName: requireText(
+ value.installationName,
+ "Installation name",
+ 128,
+ ),
+ recipe: requireText(value.recipe, "Recipe", 128),
+ stackName: requireText(value.stackName, "Stack name", 128),
+ cloudProvider,
+ githubOrg: requireText(value.githubOrg, "GitHub organization", 128),
+ repoPattern: requireText(value.repoPattern, "Repository pattern", 256),
+ refType,
+ refPattern,
+ };
+}
+
+function safePortalMessage(errors) {
+ if (!Array.isArray(errors) || errors.length === 0) {
+ return "Portal could not process the request. Try again.";
+ }
+
+ const message = errors.find((error) => typeof error?.message === "string")
+ ?.message;
+
+ if (!message) {
+ return "Portal could not process the request. Check the details and try again.";
+ }
+ if (message.startsWith("This installation name already exists")) {
+ return message.slice(0, 300);
+ }
+ if (message.startsWith("Project not found in this tenant")) {
+ return "That project is no longer available in the selected workspace. Reload the workspaces and choose another project.";
+ }
+ if (message.startsWith("Invalid input")) {
+ return "Check every handoff field and try again.";
+ }
+ if (message.startsWith("Forbidden")) {
+ return "The developer account cannot create handoffs for that workspace.";
+ }
+ if (message.startsWith("Unauthorized")) {
+ return "Portal rejected the developer token. Sign in again and retry.";
+ }
+ return "Portal could not process the request. Check the details and try again.";
+}
+
+export async function portalGraphql({
+ graphqlUrl,
+ token,
+ query,
+ variables,
+ fetchImpl = fetch,
+}) {
+ const endpoint = new URL(graphqlUrl);
+ if (endpoint.protocol !== "https:" && endpoint.hostname !== "localhost") {
+ throw new PortalRequestError(
+ "Portal must use HTTPS unless it is running on localhost.",
+ 500,
+ );
+ }
+
+ let response;
+ try {
+ response = await fetchImpl(endpoint, {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ query, variables }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ } catch {
+ throw new PortalRequestError(
+ "Portal did not respond. Check the endpoint and try again.",
+ );
+ }
+
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ throw new PortalRequestError(
+ "Portal rejected the developer token. Sign in again and retry.",
+ response.status,
+ );
+ }
+ throw new PortalRequestError(
+ "Portal could not process the request. Try again.",
+ response.status,
+ );
+ }
+ if (!payload || typeof payload !== "object") {
+ throw new PortalRequestError("Portal returned an invalid response.");
+ }
+ if (payload.errors?.length) {
+ throw new PortalRequestError(safePortalMessage(payload.errors), 422);
+ }
+ return payload.data;
+}
+
+export async function getDemoContext(options) {
+ const data = await portalGraphql({
+ ...options,
+ query: CONTEXT_QUERY,
+ variables: {},
+ });
+
+ return {
+ tenants: Array.isArray(data?.tenants) ? data.tenants : [],
+ projects: Array.isArray(data?.projects) ? data.projects : [],
+ };
+}
+
+export async function createInstallationHandoff(options) {
+ const input = validateHandoffInput(options.input);
+ const data = await portalGraphql({
+ ...options,
+ query: CREATE_HANDOFF_MUTATION,
+ variables: { input },
+ });
+ const handoff = data?.createInstallationHandoff;
+
+ if (
+ !handoff ||
+ !UUID_PATTERN.test(handoff.installationId ?? "") ||
+ typeof handoff.status !== "string" ||
+ typeof handoff.handoffUrl !== "string"
+ ) {
+ throw new PortalRequestError("Portal returned an invalid handoff.");
+ }
+
+ let handoffUrl;
+ try {
+ handoffUrl = new URL(handoff.handoffUrl);
+ } catch {
+ throw new PortalRequestError("Portal returned an invalid handoff URL.");
+ }
+ if (handoffUrl.protocol !== "https:" && handoffUrl.hostname !== "localhost") {
+ throw new PortalRequestError("Portal returned an unsafe handoff URL.");
+ }
+
+ return {
+ installationId: handoff.installationId,
+ status: handoff.status,
+ // Preserve the literal returned by Portal. This is the URL the customer
+ // will receive, so the demo must not silently normalize it.
+ handoffUrl: handoff.handoffUrl,
+ };
+}
diff --git a/samples/customer-handoff/app/portal-client.test.js b/samples/customer-handoff/app/portal-client.test.js
new file mode 100644
index 00000000..4f17bcac
--- /dev/null
+++ b/samples/customer-handoff/app/portal-client.test.js
@@ -0,0 +1,126 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ createInstallationHandoff,
+ getDemoContext,
+ PortalRequestError,
+ validateHandoffInput,
+} from "./portal-client.js";
+
+const TENANT_ID = "00000000-0000-4000-8000-000000000001";
+const PROJECT_ID = "00000000-0000-4000-8000-000000000002";
+const INSTALLATION_ID = "00000000-0000-4000-8000-000000000003";
+
+function input(overrides = {}) {
+ return {
+ tenantId: TENANT_ID,
+ customerEmail: "Cloud.Owner@Example.com",
+ projectId: PROJECT_ID,
+ installationName: "customer-production",
+ recipe: "default",
+ stackName: "production",
+ cloudProvider: "aws",
+ githubOrg: "example-org",
+ repoPattern: "customer-*",
+ refType: "environment",
+ refPattern: "production",
+ ...overrides,
+ };
+}
+
+function jsonResponse(payload, init = {}) {
+ return new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ ...init,
+ });
+}
+
+describe("validateHandoffInput", () => {
+ it("normalizes the customer email and clears the all-refs pattern", () => {
+ const result = validateHandoffInput(
+ input({ refType: "all", refPattern: "ignored" }),
+ );
+ assert.equal(result.customerEmail, "cloud.owner@example.com");
+ assert.equal(result.refPattern, null);
+ });
+
+ it("requires a reference pattern for a branch", () => {
+ assert.throws(
+ () => validateHandoffInput(input({ refType: "branch", refPattern: "" })),
+ (error) =>
+ error instanceof PortalRequestError &&
+ error.message === "Git reference pattern is required.",
+ );
+ });
+});
+describe("Portal client", () => {
+ it("loads authorized workspaces and projects with the developer token", async () => {
+ let request;
+ const context = await getDemoContext({
+ graphqlUrl: "https://graphql.example.com/v1/graphql",
+ token: "developer-token",
+ fetchImpl: async (url, init) => {
+ request = { url: url.toString(), init };
+ return jsonResponse({
+ data: {
+ tenants: [{ id: TENANT_ID, name: "Example", ownerId: TENANT_ID }],
+ projects: [
+ {
+ id: PROJECT_ID,
+ tenantId: TENANT_ID,
+ name: "demo",
+ label: "Demo",
+ },
+ ],
+ },
+ });
+ },
+ });
+
+ assert.equal(request.url, "https://graphql.example.com/v1/graphql");
+ assert.equal(request.init.headers.authorization, "Bearer developer-token");
+ assert.equal(context.tenants[0].name, "Example");
+ assert.equal(context.projects[0].label, "Demo");
+ });
+
+ it("returns the exact handoff URL supplied by Portal", async () => {
+ const literalUrl = `https://portal.dev.gnafed.click/clients/login?redirect=%2Finstallations%2F${INSTALLATION_ID}%2Fsetup`;
+ let variables;
+ const handoff = await createInstallationHandoff({
+ graphqlUrl: "https://graphql.dev.gnafed.click/v1/graphql",
+ token: "developer-token",
+ input: input(),
+ fetchImpl: async (_url, init) => {
+ variables = JSON.parse(init.body).variables;
+ return jsonResponse({
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "pending",
+ handoffUrl: literalUrl,
+ },
+ },
+ });
+ },
+ });
+
+ assert.equal(variables.input.customerEmail, "cloud.owner@example.com");
+ assert.equal(handoff.handoffUrl, literalUrl);
+ });
+
+ it("does not expose an invalid upstream response", async () => {
+ await assert.rejects(
+ createInstallationHandoff({
+ graphqlUrl: "https://graphql.example.com/v1/graphql",
+ token: "developer-token",
+ input: input(),
+ fetchImpl: async () => jsonResponse({ errors: [{ message: "unexpected" }] }),
+ }),
+ (error) =>
+ error instanceof PortalRequestError &&
+ error.message ===
+ "Portal could not process the request. Check the details and try again.",
+ );
+ });
+});
diff --git a/samples/customer-handoff/app/public/app.js b/samples/customer-handoff/app/public/app.js
new file mode 100644
index 00000000..41796ed5
--- /dev/null
+++ b/samples/customer-handoff/app/public/app.js
@@ -0,0 +1,173 @@
+const form = document.querySelector("#handoff-form");
+const accessTokenInput = document.querySelector("#access-token");
+const loadContextButton = document.querySelector("#load-context");
+const detailsSection = document.querySelector("#details-section");
+const tenantSelect = document.querySelector("#tenant-id");
+const projectSelect = document.querySelector("#project-id");
+const refTypeSelect = document.querySelector("#ref-type");
+const refPatternInput = document.querySelector("#ref-pattern");
+const refPatternField = document.querySelector("#ref-pattern-field");
+const errorMessage = document.querySelector("#error-message");
+const result = document.querySelector("#result");
+const submitButton = document.querySelector("#create-handoff");
+
+let projects = [];
+
+function tokenPayload() {
+ return { token: accessTokenInput.value.trim() };
+}
+
+function setBusy(button, busy, label) {
+ if (!button.dataset.label) button.dataset.label = button.textContent.trim();
+ button.disabled = busy;
+ button.textContent = busy ? label : button.dataset.label;
+}
+
+function showError(message) {
+ errorMessage.textContent = message;
+ errorMessage.hidden = false;
+}
+
+function clearMessages() {
+ errorMessage.hidden = true;
+ errorMessage.textContent = "";
+ result.hidden = true;
+}
+
+async function request(path, body) {
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(payload.message || "The request could not be completed.");
+ }
+ return payload;
+}
+
+function option(value, label) {
+ const element = document.createElement("option");
+ element.value = value;
+ element.textContent = label;
+ return element;
+}
+
+function updateProjects() {
+ const tenantId = tenantSelect.value;
+ projectSelect.replaceChildren(option("", "Select a project"));
+ const availableProjects = projects.filter((item) => item.tenantId === tenantId);
+ for (const project of availableProjects) {
+ projectSelect.append(option(project.id, project.label || project.name));
+ }
+ if (tenantId && availableProjects.length === 0) {
+ projectSelect.replaceChildren(option("", "No projects in this workspace"));
+ }
+ projectSelect.disabled = !tenantId || availableProjects.length === 0;
+}
+
+async function loadContext() {
+ clearMessages();
+ setBusy(loadContextButton, true, "Loading…");
+ try {
+ const context = await request("/api/context", tokenPayload());
+ if (context.tenants.length === 0) {
+ throw new Error(
+ "This developer account has no available workspaces. Create a project in Portal first.",
+ );
+ }
+ projects = context.projects;
+ tenantSelect.replaceChildren(option("", "Select a workspace"));
+ for (const tenant of context.tenants) {
+ tenantSelect.append(option(tenant.id, tenant.name));
+ }
+ detailsSection.disabled = false;
+ if (context.tenants.length === 1) {
+ tenantSelect.value = context.tenants[0].id;
+ updateProjects();
+ }
+ tenantSelect.focus();
+ } catch (error) {
+ showError(error.message);
+ } finally {
+ setBusy(loadContextButton, false, "Loading…");
+ }
+}
+
+function updateRefPattern() {
+ const usesAllRefs = refTypeSelect.value === "all";
+ refPatternField.hidden = usesAllRefs;
+ refPatternInput.disabled = usesAllRefs;
+ refPatternInput.required = !usesAllRefs;
+}
+
+function formInput() {
+ const data = new FormData(form);
+ return {
+ tenantId: data.get("tenantId"),
+ projectId: data.get("projectId"),
+ customerEmail: data.get("customerEmail"),
+ installationName: data.get("installationName"),
+ recipe: data.get("recipe"),
+ stackName: data.get("stackName"),
+ cloudProvider: data.get("cloudProvider"),
+ githubOrg: data.get("githubOrg"),
+ repoPattern: data.get("repoPattern"),
+ refType: data.get("refType"),
+ refPattern: data.get("refPattern") || null,
+ };
+}
+
+function showResult(handoff) {
+ document.querySelector("#result-installation").textContent =
+ handoff.installationId;
+ document.querySelector("#result-status").textContent = handoff.status;
+ document.querySelector("#result-url").textContent = handoff.handoffUrl;
+ document.querySelector("#open-handoff").href = handoff.handoffUrl;
+ result.hidden = false;
+ result.scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
+async function createHandoff(event) {
+ event.preventDefault();
+ clearMessages();
+ setBusy(submitButton, true, "Creating handoff…");
+ try {
+ const handoff = await request("/api/handoffs", {
+ ...tokenPayload(),
+ input: formInput(),
+ });
+ showResult(handoff);
+ } catch (error) {
+ showError(error.message);
+ } finally {
+ setBusy(submitButton, false, "Creating handoff…");
+ }
+}
+
+async function initialize() {
+ try {
+ const response = await fetch("/api/config", { cache: "no-store" });
+ const config = await response.json();
+ document.querySelector("#portal-host").textContent = config.portalHost;
+ } catch {
+ showError("The demo configuration could not be loaded. Refresh and try again.");
+ }
+}
+
+loadContextButton.addEventListener("click", loadContext);
+tenantSelect.addEventListener("change", updateProjects);
+refTypeSelect.addEventListener("change", updateRefPattern);
+form.addEventListener("submit", createHandoff);
+document.querySelector("#copy-url").addEventListener("click", async (event) => {
+ const url = document.querySelector("#result-url").textContent;
+ await navigator.clipboard.writeText(url);
+ event.currentTarget.textContent = "Copied";
+ window.setTimeout(() => {
+ event.currentTarget.textContent = "Copy";
+ }, 1600);
+});
+
+updateRefPattern();
+initialize();
diff --git a/samples/customer-handoff/app/public/index.html b/samples/customer-handoff/app/public/index.html
new file mode 100644
index 00000000..b17aa31a
--- /dev/null
+++ b/samples/customer-handoff/app/public/index.html
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+ Customer cloud handoff
+
+
+
+
+
+
+
+ D
+ Defang Deploy
+
+ Provider demo
+ Hand cloud setup to your customer
+
+ Choose one of your projects, define its GitHub trust boundary, and
+ send the customer to a hosted setup flow in their own workspace.
+
+
+ 1 Configure
+
+ 2 Share link
+
+ 3 Customer connects cloud
+
+
+
+
+
+
+
New handoff
+
Customer and deployment details
+
+
+
+
+
+
+
+
+
+ ✓
+
+
Handoff ready
+
Share the hosted setup link
+
+ The installation is pending until the customer signs in and
+ connects their cloud account.
+
+
+
+
- Installation
+
+
+
+
- Status
+
+
+
+
+
+
+
+
+ Open customer setup
+ ↗
+
+
+
+
+
+
+
diff --git a/samples/customer-handoff/app/public/styles.css b/samples/customer-handoff/app/public/styles.css
new file mode 100644
index 00000000..726f1cb2
--- /dev/null
+++ b/samples/customer-handoff/app/public/styles.css
@@ -0,0 +1,462 @@
+:root {
+ color-scheme: dark;
+ --background: #09090b;
+ --panel: rgba(24, 24, 27, 0.94);
+ --panel-soft: #202024;
+ --border: #343438;
+ --border-strong: #4b4b52;
+ --text: #fafafa;
+ --muted: #a1a1aa;
+ --accent: #d6ff57;
+ --accent-ink: #171b08;
+ --danger: #ffb4ab;
+ --danger-bg: rgba(127, 29, 29, 0.24);
+ --success: #b9f6ca;
+}
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 12% 5%, rgba(214, 255, 87, 0.09), transparent 28rem),
+ radial-gradient(circle at 90% 24%, rgba(91, 111, 255, 0.08), transparent 26rem),
+ var(--background);
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.shell {
+ width: min(1100px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 36px 0 72px;
+}
+
+.hero {
+ padding: 0 4px 30px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 650;
+ text-decoration: none;
+}
+
+.brand-mark {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border-radius: 7px;
+ color: var(--accent-ink);
+ background: var(--accent);
+ font-weight: 900;
+}
+
+.eyebrow,
+.section-label {
+ margin: 0 0 8px;
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 750;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.eyebrow {
+ margin-top: 58px;
+}
+
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ max-width: 760px;
+ margin-bottom: 16px;
+ font-size: clamp(36px, 7vw, 66px);
+ line-height: 0.99;
+ letter-spacing: -0.055em;
+}
+
+.lede {
+ max-width: 680px;
+ color: #c9c9d0;
+ font-size: 18px;
+ line-height: 1.65;
+}
+
+.flow {
+ display: flex;
+ align-items: center;
+ max-width: 760px;
+ margin-top: 34px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.flow-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+}
+
+.flow-step b {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ border: 1px solid var(--border-strong);
+ border-radius: 999px;
+ font-size: 11px;
+}
+
+.flow-step.current {
+ color: var(--text);
+}
+
+.flow-step.current b {
+ border-color: var(--accent);
+ color: var(--accent-ink);
+ background: var(--accent);
+}
+
+.flow-line {
+ width: 56px;
+ height: 1px;
+ margin: 0 12px;
+ background: var(--border);
+}
+
+.panel {
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--panel);
+ box-shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
+ backdrop-filter: blur(18px);
+}
+
+.panel-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 28px 30px 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.panel-heading h2,
+.result h2 {
+ margin-bottom: 0;
+ font-size: 23px;
+ letter-spacing: -0.025em;
+}
+
+.environment {
+ margin: 1px 0 0;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--muted);
+ background: #151517;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+}
+
+.form-section {
+ margin: 0;
+ padding: 28px 30px 30px;
+ border: 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.form-section:last-of-type {
+ border-bottom: 0;
+}
+
+.form-section:disabled {
+ opacity: 0.44;
+}
+
+legend {
+ padding: 0;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.field-help,
+.request-note,
+.field small,
+.result p {
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.field-help {
+ margin: 6px 0 18px;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+ margin-top: 20px;
+}
+
+.field {
+ display: grid;
+ gap: 8px;
+}
+
+.field > span {
+ color: #dedee3;
+ font-size: 13px;
+ font-weight: 620;
+}
+
+.full-width {
+ grid-column: 1 / -1;
+}
+
+input,
+select {
+ width: 100%;
+ min-height: 44px;
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ outline: none;
+ color: var(--text);
+ background: #111113;
+ padding: 10px 12px;
+ transition: border-color 140ms ease, box-shadow 140ms ease;
+}
+
+input:focus,
+select:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(214, 255, 87, 0.12);
+}
+
+input::placeholder {
+ color: #66666f;
+}
+
+.button {
+ display: inline-flex;
+ min-height: 42px;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ padding: 10px 15px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 720;
+ text-decoration: none;
+}
+
+.button:disabled {
+ cursor: wait;
+ opacity: 0.64;
+}
+
+.button.primary {
+ color: var(--accent-ink);
+ background: var(--accent);
+}
+
+.button.secondary {
+ border-color: var(--border-strong);
+ color: var(--text);
+ background: var(--panel-soft);
+}
+
+.form-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ margin-top: 28px;
+ padding-top: 24px;
+ border-top: 1px solid var(--border);
+}
+
+.request-note {
+ max-width: 560px;
+ margin: 0;
+}
+
+.message {
+ margin: 0 30px 30px;
+ padding: 14px 16px;
+ border-radius: 10px;
+ font-size: 14px;
+}
+
+.message.error {
+ border: 1px solid rgba(255, 180, 171, 0.36);
+ color: var(--danger);
+ background: var(--danger-bg);
+}
+
+.result {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 18px;
+ padding: 30px;
+ border-top: 1px solid rgba(185, 246, 202, 0.22);
+ background: rgba(20, 83, 45, 0.16);
+}
+
+.success-icon {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border-radius: 999px;
+ color: #052e16;
+ background: var(--success);
+ font-weight: 900;
+}
+
+.section-label.success {
+ color: var(--success);
+}
+
+.result h2 {
+ margin-bottom: 8px;
+}
+
+.result-meta {
+ display: flex;
+ gap: 36px;
+ margin: 22px 0;
+}
+
+.result-meta div {
+ display: grid;
+ gap: 4px;
+}
+
+.result-meta dt {
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.result-meta dd {
+ margin: 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px;
+}
+
+.link-box {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 18px;
+ padding: 11px 12px;
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ background: #111113;
+}
+
+.link-box code {
+ min-width: 0;
+ flex: 1;
+ overflow-wrap: anywhere;
+ color: #d4d4d8;
+ font-size: 12px;
+}
+
+.copy-button {
+ border: 0;
+ cursor: pointer;
+ color: var(--accent);
+ background: transparent;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.result-link {
+ width: fit-content;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+@media (max-width: 720px) {
+ .shell {
+ width: min(100% - 20px, 1100px);
+ padding-top: 22px;
+ }
+
+ .eyebrow {
+ margin-top: 42px;
+ }
+
+ .flow {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 9px;
+ }
+
+ .flow-line {
+ width: 1px;
+ height: 18px;
+ margin: -3px 0 -3px 11px;
+ }
+
+ .panel-heading,
+ .form-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .panel-heading,
+ .form-section,
+ .result {
+ padding: 22px 18px;
+ }
+
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .full-width {
+ grid-column: auto;
+ }
+
+ .result {
+ grid-template-columns: 1fr;
+ }
+
+ .result-meta {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 12px;
+ }
+}
diff --git a/samples/customer-handoff/app/server.js b/samples/customer-handoff/app/server.js
new file mode 100644
index 00000000..ba27418d
--- /dev/null
+++ b/samples/customer-handoff/app/server.js
@@ -0,0 +1,138 @@
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { createServer } from "node:http";
+import { extname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ createInstallationHandoff,
+ getDemoContext,
+ PortalRequestError,
+} from "./portal-client.js";
+
+const publicDirectory = fileURLToPath(new URL("./public", import.meta.url));
+const port = Number.parseInt(process.env.PORT ?? "8080", 10);
+const graphqlUrl =
+ process.env.PORTAL_GRAPHQL_URL ??
+ "https://graphql.defang.io/v1/graphql";
+
+const contentTypes = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+};
+
+function sendJson(response, status, payload) {
+ response.writeHead(status, {
+ "cache-control": "no-store",
+ "content-type": "application/json; charset=utf-8",
+ "x-content-type-options": "nosniff",
+ });
+ response.end(JSON.stringify(payload));
+}
+
+async function readJson(request) {
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > 64 * 1024) {
+ throw new PortalRequestError("Request is too large.", 413);
+ }
+ chunks.push(chunk);
+ }
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch {
+ throw new PortalRequestError("Request must be valid JSON.", 400);
+ }
+}
+
+function resolveToken(body) {
+ const suppliedToken = typeof body?.token === "string" ? body.token.trim() : "";
+ if (!suppliedToken || suppliedToken.length > 16_384) {
+ throw new PortalRequestError(
+ "Add a current developer access token to continue.",
+ 401,
+ );
+ }
+ return suppliedToken;
+}
+
+async function serveStatic(pathname, response) {
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(relativePath)) return false;
+
+ const filePath = join(publicDirectory, relativePath);
+ if (!filePath.startsWith(`${publicDirectory}/`)) return false;
+ const fileStat = await stat(filePath).catch(() => null);
+ if (!fileStat?.isFile()) return false;
+
+ response.writeHead(200, {
+ "cache-control": relativePath === "index.html" ? "no-cache" : "public, max-age=3600",
+ "content-security-policy":
+ "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
+ "content-type": contentTypes[extname(filePath)] ?? "application/octet-stream",
+ "referrer-policy": "no-referrer",
+ "x-content-type-options": "nosniff",
+ "x-frame-options": "DENY",
+ });
+ createReadStream(filePath).pipe(response);
+ return true;
+}
+
+export function createAppServer() {
+ return createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://localhost");
+
+ try {
+ if (request.method === "GET" && url.pathname === "/health") {
+ return sendJson(response, 200, { status: "ok" });
+ }
+
+ if (request.method === "GET" && url.pathname === "/api/config") {
+ return sendJson(response, 200, {
+ portalHost: new URL(graphqlUrl).host,
+ });
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/context") {
+ const body = await readJson(request);
+ const context = await getDemoContext({
+ graphqlUrl,
+ token: resolveToken(body),
+ });
+ return sendJson(response, 200, context);
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/handoffs") {
+ const body = await readJson(request);
+ const handoff = await createInstallationHandoff({
+ graphqlUrl,
+ token: resolveToken(body),
+ input: body?.input,
+ });
+ return sendJson(response, 201, handoff);
+ }
+
+ if (request.method === "GET" && (await serveStatic(url.pathname, response))) {
+ return;
+ }
+
+ sendJson(response, 404, { message: "Page not found." });
+ } catch (error) {
+ const status = error instanceof PortalRequestError ? error.status : 500;
+ const message =
+ error instanceof PortalRequestError
+ ? error.message
+ : "The demo could not complete the request. Try again.";
+ sendJson(response, status, { message });
+ }
+ });
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ createAppServer().listen(port, "0.0.0.0", () => {
+ console.log(`Programmatic handoff demo listening on port ${port}`);
+ });
+}
diff --git a/samples/customer-handoff/app/server.test.js b/samples/customer-handoff/app/server.test.js
new file mode 100644
index 00000000..5d32def7
--- /dev/null
+++ b/samples/customer-handoff/app/server.test.js
@@ -0,0 +1,108 @@
+import assert from "node:assert/strict";
+import { after, before, describe, it } from "node:test";
+import { createAppServer } from "./server.js";
+
+const TENANT_ID = "00000000-0000-4000-8000-000000000001";
+const PROJECT_ID = "00000000-0000-4000-8000-000000000002";
+const INSTALLATION_ID = "00000000-0000-4000-8000-000000000003";
+const HANDOFF_URL = `https://portal.dev.gnafed.click/clients/login?redirect=%2Finstallations%2F${INSTALLATION_ID}%2Fsetup`;
+
+const originalFetch = globalThis.fetch;
+let upstreamRequest;
+let upstreamResponse;
+let server;
+let baseUrl;
+
+function jsonResponse(payload) {
+ return new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+async function post(path, body) {
+ return originalFetch(`${baseUrl}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+describe("demo server", () => {
+ before(async () => {
+ globalThis.fetch = async (url, init) => {
+ upstreamRequest = { url: url.toString(), init };
+ return jsonResponse(upstreamResponse);
+ };
+ server = createAppServer();
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ baseUrl = `http://127.0.0.1:${address.port}`;
+ });
+
+ after(async () => {
+ globalThis.fetch = originalFetch;
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ );
+ });
+
+ it("serves its health check without Portal access", async () => {
+ const response = await originalFetch(`${baseUrl}/health`);
+ assert.equal(response.status, 200);
+ assert.deepEqual(await response.json(), { status: "ok" });
+ });
+
+ it("loads the authenticated developer context", async () => {
+ upstreamResponse = {
+ data: {
+ tenants: [{ id: TENANT_ID, name: "Example", ownerId: TENANT_ID }],
+ projects: [
+ { id: PROJECT_ID, tenantId: TENANT_ID, name: "demo", label: "Demo" },
+ ],
+ },
+ };
+
+ const response = await post("/api/context", { token: "developer-token" });
+ const body = await response.json();
+
+ assert.equal(response.status, 200);
+ assert.equal(body.projects[0].id, PROJECT_ID);
+ assert.equal(upstreamRequest.init.headers.authorization, "Bearer developer-token");
+ });
+
+ it("returns Portal's literal customer handoff URL", async () => {
+ upstreamResponse = {
+ data: {
+ createInstallationHandoff: {
+ installationId: INSTALLATION_ID,
+ status: "pending",
+ handoffUrl: HANDOFF_URL,
+ },
+ },
+ };
+
+ const response = await post("/api/handoffs", {
+ token: "developer-token",
+ input: {
+ tenantId: TENANT_ID,
+ customerEmail: "cloud-owner@example.com",
+ projectId: PROJECT_ID,
+ installationName: "customer-production",
+ recipe: "default",
+ stackName: "production",
+ cloudProvider: "aws",
+ githubOrg: "example-org",
+ repoPattern: "customer-*",
+ refType: "environment",
+ refPattern: "production",
+ },
+ });
+ const body = await response.json();
+
+ assert.equal(response.status, 201);
+ assert.equal(body.handoffUrl, HANDOFF_URL);
+ const upstreamBody = JSON.parse(upstreamRequest.init.body);
+ assert.equal(upstreamBody.variables.input.projectId, PROJECT_ID);
+ });
+});
diff --git a/samples/customer-handoff/compose.yaml b/samples/customer-handoff/compose.yaml
new file mode 100644
index 00000000..01de9ac5
--- /dev/null
+++ b/samples/customer-handoff/compose.yaml
@@ -0,0 +1,24 @@
+name: customer-handoff
+
+services:
+ app:
+ restart: unless-stopped
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ environment:
+ PORT: 8080
+ PORTAL_GRAPHQL_URL: ${PORTAL_GRAPHQL_URL:-https://graphql.defang.io/v1/graphql}
+ ports:
+ - mode: ingress
+ target: 8080
+ published: 8080
+ deploy:
+ resources:
+ reservations:
+ memory: 256M
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3