Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
75 changes: 75 additions & 0 deletions samples/customer-handoff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Programmatic Customer Handoff

[![1-click-deploy](https://raw.githubusercontent.com/DefangLabs/defang-assets/main/Logos/Buttons/SVG/deploy-with-defang.svg)](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
13 changes: 13 additions & 0 deletions samples/customer-handoff/app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
12 changes: 12 additions & 0 deletions samples/customer-handoff/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "customer-handoff",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"engines": {
"node": ">=22"
}
}
239 changes: 239 additions & 0 deletions samples/customer-handoff/app/portal-client.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading