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 + +[![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 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 + + + + +
+
+ + + 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 +
+
+ +
+
+
+ +

Customer and deployment details

+
+

+
+ +
+
+ Developer access +

+ The token is sent only to this demo server and is never saved. +

+ + +
+ +
+ Handoff details +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+

+ Creating a handoff adds a pending installation to the customer’s + workspace. No workload is deployed yet. +

+ +
+
+
+ + + + +
+
+ + 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