diff --git a/README.md b/README.md index d4d7c2ce..30cbf000 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ When you add a new sample, make sure to add any config vals to the `deploy-chang | [BullMQ & BullBoard & Redis](./samples/bullmq-bullboard-redis) | A sample project with BullMQ, BullBoard, and Redis. | BullMQ, BullBoard, Redis, Express, Node.js, Message Queue, JavaScript | nodejs | | [Crew.ai Django Sample](./samples/crewai) | A sample application that uses Crew.ai to summarize text in a background task, streamed to the user in real-time. | Django, Celery, Redis, Postgres, AI, ML | Python | | [C# & ASP.NET Core](./samples/csharp-dotnet) | A simple task manager application using C# and ASP.NET Core. | ASP.NET Core, JavaScript, C# | C# | +| [Customer Installation Handoff](./samples/customer-handoff) | A server-backed foundation for handing customer cloud setup and deployment to Defang. | Defang, Customer Onboarding, Cloud, GitHub, Sample | nodejs, html, css, javascript | | [Defang Provider Handoff Sample](./samples/defang-provider-handoff) | A sample application that demonstrates how to provide a white-labeled static site for customers to configure their cloud accounts for your deployment. | Defang, Cloud, Deployment, Static Site | HTML, CSS, JavaScript | | [Django](./samples/django) | A simple Django app that uses SQLite as the database. | Django, SQLite, Python | python | | [Django Celery](./samples/django-celery) | A Django application that uses Celery for background tasks, Postgres as the database, and Redis as the message broker. | Django, Celery, Postgres, Redis | python, sql | diff --git a/samples/customer-handoff/README.md b/samples/customer-handoff/README.md new file mode 100644 index 00000000..e9be86cf --- /dev/null +++ b/samples/customer-handoff/README.md @@ -0,0 +1,152 @@ +# Customer Installation 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 server-backed sample starts a Defang Installation handoff after a +developer-owned signup, payment, or entitlement event. It keeps the Portal +credential on the server, creates or reuses a pending installation, and sends +the customer to the exact hosted setup URL returned by Defang. + +> [!IMPORTANT] +> The merged Portal contract currently requires a short-lived developer bearer +> token. Durable project-scoped machine credentials, credential rotation, a +> public status API, signed callbacks, and provider-verified revocation are +> tracked in [Portal issue #1086](https://github.com/DefangLabs/portal/issues/1086) +> and [Portal issue #1087](https://github.com/DefangLabs/portal/issues/1087); +> they are not available contracts yet. This sample is a current-contract +> foundation for evaluation, not an +> unattended production integration. + +## Responsibility boundary + +- Your application owns signup or payment, the customer relationship, the + external order/entitlement reference, and the database mapping that reference + to the returned `installationId`. +- The sample calls Defang only after that business event has been verified. It + sends no payment amount, product, receipt, or payment-provider data to Defang. +- Defang hosts customer sign-in and cloud trust setup. After setup completes, + Portal creates the stack and automatically dispatches the developer project's + deployment workflow. +- The customer owns the cloud account and approves the OIDC trust that the + deployment workflow uses. + +The public form exists only to make the boundary visible. A real application +must take the email and external reference from its authenticated backend and +persist the correlation before redirecting the customer. + +## Current behavior + +The sample derives a stable, opaque installation name from the external +reference. Retrying with the same customer and configuration therefore sends an +identical request; Portal returns the existing installation and handoff URL. +The raw external reference remains in the developer's system. + +The create response contains only the initial installation `status`. Portal does +not yet expose a supported third-party polling or callback contract. Deployment +is dispatched after the customer finishes setup, but later workflow transitions +are not continuously reconciled, so use Portal and GitHub Actions to inspect the +result during evaluation. + +## Prerequisites + +1. Install Docker Compose for the container workflow, or Node.js 22 or newer for + the focused contract tests. +2. Have a Defang developer workspace with a project whose deployment workflow is + configured for the chosen GitHub repository. +3. Obtain a current, short-lived Portal bearer token for that developer account. +4. Use a customer email inbox and test cloud account you control. + +## Configuration + +Set configuration in the server environment. Never put `PORTAL_ACCESS_TOKEN` in +client-side code, a committed file, a URL, or logs. + +| Variable | Required | Purpose | +| --- | --- | --- | +| `PORTAL_ACCESS_TOKEN` | Yes | Current short-lived developer bearer token; server only | +| `DEFANG_TENANT_ID` | Yes | Developer workspace UUID | +| `DEFANG_PROJECT_ID` | Yes | Project UUID owned by that workspace | +| `GITHUB_ORG` | Yes | GitHub owner of the configured repository | +| `GITHUB_REPOSITORY` | Yes | Exact repository name; wildcards are not used | +| `PORTAL_GRAPHQL_URL` | No | Defaults to `https://graphql.defang.io/v1/graphql` | +| `PORTAL_HANDOFF_ORIGIN` | No | Allowed redirect origin; defaults to `https://portal.defang.io` | +| `DEFANG_CLOUD_PROVIDER` | No | `aws`, `gcp`, or `azure`; defaults to `aws` | +| `DEFANG_RECIPE` | No | Defaults to `default` | +| `DEFANG_STACK_NAME` | No | Defaults to `production` | +| `GITHUB_REF_TYPE` | No | `all`, `branch`, or `environment`; defaults to `environment` | +| `GITHUB_REF_PATTERN` | For branch/environment | Defaults to `defang-production`, matching stack `production` | +| `INSTALLATION_NAME_PREFIX` | No | Non-secret installation-name prefix; defaults to `customer` | + +For Portal's dev environment, use these matching endpoint values together: + +```sh +export PORTAL_GRAPHQL_URL=https://graphql.dev.gnafed.click/v1/graphql +export PORTAL_HANDOFF_ORIGIN=https://portal.dev.gnafed.click +``` + +## Development + +Export all required values in your shell, then run: + +```sh +docker compose up --build +``` + +Open `http://localhost:8080`, enter a test customer email and external +reference, and submit the form. The backend will create or reuse the handoff and +respond with `303 See Other` to the exact `handoffUrl` returned by Portal. + +To test safely without a live credential or cloud account, run the contract tests. +They replace Portal at the HTTP boundary and cover request shape, idempotent +replay, redirect preservation, allowed-origin validation, and log redaction: + +```sh +cd app +npm test +``` + +## End-to-end evaluation + +1. Configure the sample for a real project and a test customer email. +2. Submit one external reference and record the returned redirect. +3. Submit the same reference and email again. Portal should reuse the same + installation and return the same redirect. +4. Sign in at the returned URL as the customer and complete cloud trust setup. +5. Confirm the configured deployment workflow starts in GitHub Actions. +6. Inspect the installation in Portal for the initial deployment result. + +Known current limitations: + +- The developer token expires and cannot be rotated or scoped as a machine + credential. Do not deploy this sample as an unattended service yet. +- Idempotency is based on the customer workspace plus derived installation name, + not a first-class vendor key. Enforce uniqueness and payload consistency in + your own database too. +- The external reference is not stored by Defang. Persist the returned + `installationId` in the same transaction as your local handoff state. +- There is no supported partner callback or polling endpoint yet. +- Setup links have no partner-controlled expiry/cancellation contract. +- Deployment dispatch is at-most-once, but `dispatching`/`unknown` outcomes do + not yet have an automatic reaper, and later workflow status is not reconciled. +- Customer self-service trust revocation and uninstall are not yet available. + +## Deployment + +The Compose file is deployable, but the current short-lived authentication +contract makes an unattended deployment unsuitable. When project-scoped machine +credentials ship, set the credential through environment configuration rather +than changing the browser or checking a secret into the repository: + +```sh +defang compose up +``` + +--- + +Title: Customer Installation Handoff + +Short Description: A server-backed foundation for handing customer cloud setup and deployment to Defang. + +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..cb133d75 --- /dev/null +++ b/samples/customer-handoff/app/portal-client.js @@ -0,0 +1,296 @@ +import { createHash } from "node:crypto"; + +export const CREATE_HANDOFF_MUTATION = ` + mutation CreateInstallationHandoff($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; +const PROVIDERS = new Set(["aws", "gcp", "azure"]); +const REF_TYPES = new Set(["all", "branch", "environment"]); +const INITIAL_STATUSES = new Set(["pending", "ready"]); + +export class HandoffError extends Error { + constructor(message, status = 502) { + super(message); + this.name = "HandoffError"; + this.status = status; + } +} + +function requireText(value, name, maxLength = 256, status = 500) { + if (typeof value !== "string" || !value.trim()) { + throw new HandoffError(`${name} is required.`, status); + } + const normalized = value.trim(); + if (normalized.length > maxLength) { + throw new HandoffError(`${name} is too long.`, status); + } + return normalized; +} + +function requireUuid(value, name) { + const normalized = requireText(value, name, 36); + if (!UUID_PATTERN.test(normalized)) { + throw new HandoffError(`${name} must be a UUID.`, 500); + } + return normalized; +} + +function requireHttpsUrl(value, name, { localhost = false } = {}) { + let url; + try { + url = new URL(requireText(value, name, 2_048)); + } catch { + throw new HandoffError(`${name} must be a valid URL.`, 500); + } + if ( + url.protocol !== "https:" && + !(localhost && ["localhost", "127.0.0.1"].includes(url.hostname)) + ) { + throw new HandoffError(`${name} must use HTTPS.`, 500); + } + return url; +} + +export function readConfig(env = process.env) { + const graphqlUrl = requireHttpsUrl( + env.PORTAL_GRAPHQL_URL ?? "https://graphql.defang.io/v1/graphql", + "PORTAL_GRAPHQL_URL", + { localhost: true }, + ); + const handoffOrigin = requireHttpsUrl( + env.PORTAL_HANDOFF_ORIGIN ?? "https://portal.defang.io", + "PORTAL_HANDOFF_ORIGIN", + { localhost: true }, + ); + const cloudProvider = requireText( + env.DEFANG_CLOUD_PROVIDER ?? "aws", + "DEFANG_CLOUD_PROVIDER", + 16, + ); + const refType = requireText( + env.GITHUB_REF_TYPE ?? "environment", + "GITHUB_REF_TYPE", + 16, + ); + const stackName = requireText( + env.DEFANG_STACK_NAME ?? "production", + "DEFANG_STACK_NAME", + 128, + ); + if (!PROVIDERS.has(cloudProvider)) { + throw new HandoffError( + "DEFANG_CLOUD_PROVIDER must be aws, gcp, or azure.", + 500, + ); + } + if (!REF_TYPES.has(refType)) { + throw new HandoffError( + "GITHUB_REF_TYPE must be all, branch, or environment.", + 500, + ); + } + + const refPattern = + refType === "all" + ? null + : requireText( + env.GITHUB_REF_PATTERN ?? + (refType === "environment" ? `defang-${stackName}` : undefined), + "GITHUB_REF_PATTERN", + ); + + return Object.freeze({ + graphqlUrl: graphqlUrl.toString(), + handoffOrigin: handoffOrigin.origin, + accessToken: requireText( + env.PORTAL_ACCESS_TOKEN, + "PORTAL_ACCESS_TOKEN", + 16_384, + ), + tenantId: requireUuid(env.DEFANG_TENANT_ID, "DEFANG_TENANT_ID"), + projectId: requireUuid(env.DEFANG_PROJECT_ID, "DEFANG_PROJECT_ID"), + recipe: requireText(env.DEFANG_RECIPE ?? "default", "DEFANG_RECIPE", 128), + stackName, + cloudProvider, + githubOrg: requireText(env.GITHUB_ORG, "GITHUB_ORG", 128), + githubRepository: requireText( + env.GITHUB_REPOSITORY, + "GITHUB_REPOSITORY", + 256, + ), + refType, + refPattern, + installationPrefix: requireText( + env.INSTALLATION_NAME_PREFIX ?? "customer", + "INSTALLATION_NAME_PREFIX", + 48, + ), + }); +} + +export function validateBusinessEvent(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new HandoffError("Customer details are required.", 400); + } + const externalReference = requireText( + value.externalReference, + "External reference", + 256, + 400, + ); + const customerEmail = requireText( + value.customerEmail, + "Customer email", + 320, + 400, + ).toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerEmail)) { + throw new HandoffError("Enter a valid customer email.", 400); + } + return { customerEmail, externalReference }; +} + +/** + * Portal does not yet accept a vendor idempotency key or external reference. + * A stable digest gives identical retries the same installation name without + * disclosing the developer's order/entitlement identifier to Defang. + */ +export function installationNameFor(prefix, externalReference) { + const digest = createHash("sha256") + .update(externalReference, "utf8") + .digest("hex") + .slice(0, 24); + return `${prefix}-${digest}`; +} + +export function buildHandoffInput(config, event) { + const { customerEmail, externalReference } = validateBusinessEvent(event); + return { + tenantId: config.tenantId, + customerEmail, + projectId: config.projectId, + installationName: installationNameFor( + config.installationPrefix, + externalReference, + ), + recipe: config.recipe, + stackName: config.stackName, + cloudProvider: config.cloudProvider, + githubOrg: config.githubOrg, + repoPattern: config.githubRepository, + refType: config.refType, + refPattern: config.refPattern, + }; +} + +function safePortalMessage(errors) { + const message = Array.isArray(errors) + ? errors.find((error) => typeof error?.message === "string")?.message + : null; + if (message?.startsWith("This installation name already exists")) { + return `${message.slice(0, 300)} Confirm that this retry uses the original customer and configuration.`; + } + if (message?.startsWith("Project not found in this tenant")) { + return "The configured project is not available in the developer workspace. Check DEFANG_PROJECT_ID and DEFANG_TENANT_ID."; + } + if (message?.startsWith("Forbidden")) { + return "The configured Portal credential cannot create handoffs for this workspace."; + } + if (message?.startsWith("Unauthorized")) { + return "Portal rejected the configured credential. Replace the expired credential and retry."; + } + return "Portal could not create the handoff. Check the server configuration and retry."; +} + +async function portalGraphql(config, query, variables, fetchImpl) { + let response; + try { + response = await fetchImpl(config.graphqlUrl, { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${config.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + throw new HandoffError( + "Portal did not respond. Check PORTAL_GRAPHQL_URL and retry.", + ); + } + + const payload = await response.json().catch(() => null); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new HandoffError( + "Portal rejected the configured credential. Replace the expired credential and retry.", + response.status, + ); + } + throw new HandoffError( + "Portal could not create the handoff.", + response.status, + ); + } + if (!payload || typeof payload !== "object") { + throw new HandoffError("Portal returned an invalid response."); + } + if (payload.errors?.length) { + throw new HandoffError(safePortalMessage(payload.errors), 422); + } + return payload.data; +} + +export async function createInstallationHandoff({ + config, + event, + fetchImpl = fetch, +}) { + const input = buildHandoffInput(config, event); + const data = await portalGraphql( + config, + CREATE_HANDOFF_MUTATION, + { input }, + fetchImpl, + ); + const handoff = data?.createInstallationHandoff; + if ( + !handoff || + !UUID_PATTERN.test(handoff.installationId ?? "") || + !INITIAL_STATUSES.has(handoff.status) || + typeof handoff.handoffUrl !== "string" + ) { + throw new HandoffError("Portal returned an invalid handoff."); + } + + let handoffUrl; + try { + handoffUrl = new URL(handoff.handoffUrl); + } catch { + throw new HandoffError("Portal returned an invalid handoff URL."); + } + if (handoffUrl.origin !== config.handoffOrigin) { + throw new HandoffError( + "Portal returned a handoff URL outside PORTAL_HANDOFF_ORIGIN.", + ); + } + + return { + installationId: handoff.installationId, + status: handoff.status, + // Preserve the exact literal returned by Portal. The redirect below must + // not normalize, rebuild, or decode this customer-facing identifier. + handoffUrl: handoff.handoffUrl, + installationName: input.installationName, + }; +} 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..e479bb74 --- /dev/null +++ b/samples/customer-handoff/app/portal-client.test.js @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildHandoffInput, + createInstallationHandoff, + HandoffError, + installationNameFor, + readConfig, +} 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"; +const LITERAL_HANDOFF_URL = `https://portal.dev.gnafed.click/clients/login?redirect=%2Finstallations%2F${INSTALLATION_ID}%2Fsetup`; + +function config() { + return readConfig({ + PORTAL_GRAPHQL_URL: "https://graphql.dev.gnafed.click/v1/graphql", + PORTAL_HANDOFF_ORIGIN: "https://portal.dev.gnafed.click", + PORTAL_ACCESS_TOKEN: "test-token-value", + DEFANG_TENANT_ID: TENANT_ID, + DEFANG_PROJECT_ID: PROJECT_ID, + DEFANG_CLOUD_PROVIDER: "aws", + GITHUB_ORG: "example-org", + GITHUB_REPOSITORY: "example-app", + GITHUB_REF_TYPE: "environment", + GITHUB_REF_PATTERN: "defang-production", + }); +} + +function event(overrides = {}) { + return { + customerEmail: "Cloud.Owner@Example.com", + externalReference: "order_demo_001", + ...overrides, + }; +} + +function jsonResponse(payload, init = {}) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +describe("handoff input", () => { + it("derives a stable opaque installation name from the external reference", () => { + const first = installationNameFor("customer", "order_demo_001"); + const retry = installationNameFor("customer", "order_demo_001"); + const different = installationNameFor("customer", "order_demo_002"); + + assert.equal(first, retry); + assert.notEqual(first, different); + assert.match(first, /^customer-[0-9a-f]{24}$/); + assert.equal(first.includes("order_demo_001"), false); + }); + + it("keeps the payment reference out of the Portal request", () => { + const input = buildHandoffInput(config(), event()); + assert.equal(input.customerEmail, "cloud.owner@example.com"); + assert.equal(input.repoPattern, "example-app"); + assert.equal(JSON.stringify(input).includes("order_demo_001"), false); + }); + + it("rejects missing business input as a client error", () => { + assert.throws( + () => + buildHandoffInput(config(), { + customerEmail: "cloud-owner@example.com", + }), + (error) => + error instanceof HandoffError && + error.status === 400 && + error.message === "External reference is required.", + ); + }); +}); + +describe("Portal client", () => { + it("uses the server credential and returns Portal's exact handoff URL", async () => { + let request; + const handoff = await createInstallationHandoff({ + config: config(), + event: event(), + fetchImpl: async (url, init) => { + request = { url, init }; + return jsonResponse({ + data: { + createInstallationHandoff: { + installationId: INSTALLATION_ID, + status: "pending", + handoffUrl: LITERAL_HANDOFF_URL, + }, + }, + }); + }, + }); + + assert.equal(request.url, "https://graphql.dev.gnafed.click/v1/graphql"); + assert.equal(request.init.headers.authorization, "Bearer test-token-value"); + assert.equal(handoff.status, "pending"); + assert.equal(handoff.handoffUrl, LITERAL_HANDOFF_URL); + }); + + it("sends the same request and gets the same installation on a retry", async () => { + const requests = []; + const fetchImpl = async (_url, init) => { + requests.push(JSON.parse(init.body)); + return jsonResponse({ + data: { + createInstallationHandoff: { + installationId: INSTALLATION_ID, + status: "pending", + handoffUrl: LITERAL_HANDOFF_URL, + }, + }, + }); + }; + + const first = await createInstallationHandoff({ + config: config(), + event: event(), + fetchImpl, + }); + const retry = await createInstallationHandoff({ + config: config(), + event: event(), + fetchImpl, + }); + + assert.deepEqual(requests[0], requests[1]); + assert.equal(first.installationId, retry.installationId); + assert.equal(first.handoffUrl, retry.handoffUrl); + }); + + it("rejects a handoff URL outside the configured Portal origin", async () => { + await assert.rejects( + createInstallationHandoff({ + config: config(), + event: event(), + fetchImpl: async () => + jsonResponse({ + data: { + createInstallationHandoff: { + installationId: INSTALLATION_ID, + status: "pending", + handoffUrl: "https://attacker.example/handoff", + }, + }, + }), + }), + (error) => + error instanceof HandoffError && + error.message === + "Portal returned a handoff URL outside PORTAL_HANDOFF_ORIGIN.", + ); + }); + + it("rejects a status outside the current create-handoff contract", async () => { + await assert.rejects( + createInstallationHandoff({ + config: config(), + event: event(), + fetchImpl: async () => + jsonResponse({ + data: { + createInstallationHandoff: { + installationId: INSTALLATION_ID, + status: "succeeded", + handoffUrl: LITERAL_HANDOFF_URL, + }, + }, + }), + }), + (error) => + error instanceof HandoffError && + error.message === "Portal returned an invalid handoff.", + ); + }); + + it("does not expose an unexpected upstream error", async () => { + await assert.rejects( + createInstallationHandoff({ + config: config(), + event: event(), + fetchImpl: async () => + jsonResponse({ errors: [{ message: "database detail: secret" }] }), + }), + (error) => + error instanceof HandoffError && + error.message === + "Portal could not create the handoff. Check the server configuration and retry.", + ); + }); +}); diff --git a/samples/customer-handoff/app/public/index.html b/samples/customer-handoff/app/public/index.html new file mode 100644 index 00000000..f88bb2d8 --- /dev/null +++ b/samples/customer-handoff/app/public/index.html @@ -0,0 +1,104 @@ + + + + + + + Customer installation handoff + + + +
+
+ + + Defang Deploy + +
Integration sample
+

Hand cloud setup to your customer

+

+ This form stands in for your trusted backend after signup, payment, or + another entitlement event. Your server creates one repeat-safe handoff; + Defang hosts login, cloud trust setup, and deployment. +

+
+ 1 Entitlement confirmed + + 2 Customer setup + + 3 Deploy +
+
+ +
+
+
+ +

Continue to hosted setup

+
+

Server-authenticated

+
+ +
+
+ Customer and entitlement +

+ In production, take these values from your authenticated customer + and your own order database—not from a public form. +

+
+ + + +
+ +
+

+ Submitting the same reference and details reuses the same Portal + installation. The response redirects to the exact URL returned by + Defang. +

+ +
+
+
+ + +
+
+ + diff --git a/samples/customer-handoff/app/public/styles.css b/samples/customer-handoff/app/public/styles.css new file mode 100644 index 00000000..2be8de1a --- /dev/null +++ b/samples/customer-handoff/app/public/styles.css @@ -0,0 +1,487 @@ +: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; +} + +.notice { + margin: 0; + padding: 18px 30px; + border-top: 1px solid rgba(214, 255, 87, 0.2); + color: #d4d4d8; + background: rgba(214, 255, 87, 0.06); + font-size: 13px; + line-height: 1.6; +} + +.notice strong { + color: var(--accent); +} + +.error-page { + padding: 30px; +} + +.error-page p:not(.section-label) { + max-width: 680px; + color: var(--muted); + line-height: 1.6; +} + +.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, + .notice, + .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..ed87ff82 --- /dev/null +++ b/samples/customer-handoff/app/server.js @@ -0,0 +1,164 @@ +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, + HandoffError, + readConfig, +} from "./portal-client.js"; + +const publicDirectory = fileURLToPath(new URL("./public", import.meta.url)); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", +}; + +function securityHeaders(contentType) { + return { + "cache-control": "no-store", + "content-security-policy": + "default-src 'self'; style-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + "content-type": contentType, + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + }; +} + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function sendError(response, status, message) { + response.writeHead(status, securityHeaders("text/html; charset=utf-8")); + response.end(` +Handoff failed +

Check the request and try again

${escapeHtml(message)}

Back to the sample
`); +} + +async function readForm(request) { + const contentType = request.headers["content-type"] ?? ""; + if (!contentType.startsWith("application/x-www-form-urlencoded")) { + throw new HandoffError("Submit the handoff form to continue.", 415); + } + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 16 * 1024) { + throw new HandoffError("The handoff request is too large.", 413); + } + chunks.push(chunk); + } + const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8")); + return { + customerEmail: form.get("customerEmail"), + externalReference: form.get("externalReference"), + }; +} + +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, + securityHeaders( + contentTypes[extname(filePath)] ?? "application/octet-stream", + ), + ); + createReadStream(filePath).pipe(response); + return true; +} + +export function createAppServer({ + config, + fetchImpl = fetch, + logger = console, +} = {}) { + const resolvedConfig = config ?? readConfig(); + return createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + + try { + if (request.method === "GET" && url.pathname === "/health") { + response.writeHead( + 200, + securityHeaders("application/json; charset=utf-8"), + ); + return response.end(JSON.stringify({ status: "ok" })); + } + + if (request.method === "POST" && url.pathname === "/handoff") { + // This route represents the trusted boundary after the developer's own + // signup/payment/entitlement check. Payment data is never sent to Defang. + const event = await readForm(request); + const handoff = await createInstallationHandoff({ + config: resolvedConfig, + event, + fetchImpl, + }); + logger.info( + JSON.stringify({ + level: "info", + message: "Installation handoff created or reused", + installationId: handoff.installationId, + installationName: handoff.installationName, + status: handoff.status, + }), + ); + response.writeHead(303, { + "cache-control": "no-store", + location: handoff.handoffUrl, + "referrer-policy": "no-referrer", + }); + return response.end(); + } + + if ( + request.method === "GET" && + (await serveStatic(url.pathname, response)) + ) { + return; + } + + sendError(response, 404, "Page not found."); + } catch (error) { + const status = error instanceof HandoffError ? error.status : 500; + const message = + error instanceof HandoffError + ? error.message + : "The handoff could not be created. Check the server logs and retry."; + if (!(error instanceof HandoffError)) { + logger.error("Unexpected handoff error", error); + } + sendError(response, status, message); + } + }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const port = Number.parseInt(process.env.PORT ?? "8080", 10); + createAppServer().listen(port, "0.0.0.0", () => { + console.info(`Customer handoff sample listening on port ${port}`); + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid configuration"; + console.error(`Customer handoff sample could not start: ${message}`); + process.exitCode = 1; + } +} diff --git a/samples/customer-handoff/app/server.test.js b/samples/customer-handoff/app/server.test.js new file mode 100644 index 00000000..4ae96b37 --- /dev/null +++ b/samples/customer-handoff/app/server.test.js @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { after, before, describe, it } from "node:test"; +import { readConfig } from "./portal-client.js"; +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 SECRET_TOKEN = "never-log-this-token"; + +const config = readConfig({ + PORTAL_GRAPHQL_URL: "https://graphql.dev.gnafed.click/v1/graphql", + PORTAL_HANDOFF_ORIGIN: "https://portal.dev.gnafed.click", + PORTAL_ACCESS_TOKEN: SECRET_TOKEN, + DEFANG_TENANT_ID: TENANT_ID, + DEFANG_PROJECT_ID: PROJECT_ID, + DEFANG_CLOUD_PROVIDER: "aws", + GITHUB_ORG: "example-org", + GITHUB_REPOSITORY: "example-app", + GITHUB_REF_TYPE: "environment", + GITHUB_REF_PATTERN: "production", +}); + +const logs = []; +let upstreamRequest; +let server; +let baseUrl; + +function jsonResponse(payload) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("sample server", () => { + before(async () => { + server = createAppServer({ + config, + logger: { + info: (message) => logs.push(message), + error: (message) => logs.push(message), + }, + fetchImpl: async (url, init) => { + upstreamRequest = { url, init }; + return jsonResponse({ + data: { + createInstallationHandoff: { + installationId: INSTALLATION_ID, + status: "pending", + handoffUrl: HANDOFF_URL, + }, + }, + }); + }, + }); + 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 () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("serves health and the form without exposing the server credential", async () => { + const health = await fetch(`${baseUrl}/health`); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { status: "ok" }); + + const page = await fetch(baseUrl); + const html = await page.text(); + assert.equal(page.status, 200); + assert.match(html, /Create handoff and continue/); + assert.equal(html.includes(SECRET_TOKEN), false); + }); + + it("redirects to the exact literal returned by Portal and logs no secret", async () => { + const response = await fetch(`${baseUrl}/handoff`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + customerEmail: "cloud-owner@example.com", + externalReference: "order_demo_001", + }), + redirect: "manual", + }); + + assert.equal(response.status, 303); + assert.equal(response.headers.get("location"), HANDOFF_URL); + assert.equal( + upstreamRequest.init.headers.authorization, + `Bearer ${SECRET_TOKEN}`, + ); + const serializedLogs = logs.join("\n"); + assert.equal(serializedLogs.includes(SECRET_TOKEN), false); + assert.equal(serializedLogs.includes("cloud-owner@example.com"), false); + assert.equal(serializedLogs.includes("order_demo_001"), false); + assert.equal(serializedLogs.includes(HANDOFF_URL), false); + assert.match(serializedLogs, new RegExp(INSTALLATION_ID)); + }); +}); diff --git a/samples/customer-handoff/compose.yaml b/samples/customer-handoff/compose.yaml new file mode 100644 index 00000000..415b11f2 --- /dev/null +++ b/samples/customer-handoff/compose.yaml @@ -0,0 +1,36 @@ +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} + PORTAL_HANDOFF_ORIGIN: ${PORTAL_HANDOFF_ORIGIN:-https://portal.defang.io} + PORTAL_ACCESS_TOKEN: + DEFANG_TENANT_ID: + DEFANG_PROJECT_ID: + DEFANG_RECIPE: ${DEFANG_RECIPE:-default} + DEFANG_STACK_NAME: ${DEFANG_STACK_NAME:-production} + DEFANG_CLOUD_PROVIDER: ${DEFANG_CLOUD_PROVIDER:-aws} + GITHUB_ORG: + GITHUB_REPOSITORY: + GITHUB_REF_TYPE: ${GITHUB_REF_TYPE:-environment} + GITHUB_REF_PATTERN: ${GITHUB_REF_PATTERN:-defang-production} + INSTALLATION_NAME_PREFIX: ${INSTALLATION_NAME_PREFIX:-customer} + 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 diff --git a/samples/defang-provider-handoff/README.md b/samples/defang-provider-handoff/README.md index 538a5996..2083f965 100644 --- a/samples/defang-provider-handoff/README.md +++ b/samples/defang-provider-handoff/README.md @@ -1,5 +1,12 @@ # Defang Provider Handoff Sample +> [!WARNING] +> This is the legacy AWS-only manual-invite prototype. It does not use the +> current Installation handoff contract and should not be the basis of a new +> integration. Use the maintained +> [Customer Installation Handoff](https://github.com/DefangLabs/samples/tree/main/samples/customer-handoff) +> sample instead. + [![1-click-deploy](https://raw.githubusercontent.com/DefangLabs/defang-assets/main/Logos/Buttons/SVG/deploy-with-defang.svg)](https://portal.defang.io/sample/defang-provider-handoff) If you are using Defang to deploy your application into your customer's cloud accounts, you may want to provide a white-labeled static site that your customers can use to configure their cloud account for your deployment. This sample demonstrates how to do that.