Bagikan melalui


Apa yang disematkan untuk pengguna eksternal?

Penting

Fitur ini ada di Pratinjau Umum.

Halaman ini menjelaskan cara kerja penyematan untuk pengguna eksternal, cara mengonfigurasi ruang kerja Azure Databricks Anda untuk berbagi dasbor yang disematkan dengan aman, dan cara menggunakan aplikasi sampel untuk memulai. Penyematan untuk pengguna eksternal menggunakan perwakilan layanan dan token akses terlingkup untuk mengautentikasi dan mengotorisasi akses ke dasbor yang disematkan. Pendekatan ini memungkinkan Anda berbagi dasbor dengan pemirsa di luar organisasi Anda, seperti mitra dan pelanggan, tanpa menyediakan akun Azure Databricks untuk pengguna tersebut.

Untuk mempelajari tentang opsi penyematan lainnya, termasuk menyematkan dasbor untuk pengguna dalam organisasi Anda, lihat Menyematkan dasbor.

Cara kerja penyematan untuk pengguna eksternal

Diagram dan langkah-langkah bernomor yang mengikuti menjelaskan bagaimana pengguna diautentikasi dan dasbor diisi dengan hasil cakupan pengguna saat Anda menyematkan dasbor untuk pengguna eksternal.

Bagan alur yang menunjukkan pertukaran token yang diperlukan di seluruh aplikasi Anda dan ruang kerja Databricks.

  1. Autentikasi dan permintaan pengguna: Pengguna masuk ke aplikasi Anda. Frontend aplikasi Anda mengirimkan permintaan terautentikasi ke server Anda untuk token akses dasbor.
  2. Autentikasi perwakilan layanan: Server Anda menggunakan rahasia perwakilan layanan untuk meminta dan menerima token OAuth dari server Databricks. Ini adalah token yang terlingkup secara luas yang dapat memanggil semua API dasbor yang dapat diakses Azure Databricks atas nama perwakilan layanan. Server Anda memanggil /tokeninfo titik akhir menggunakan token ini, meneruskan informasi pengguna dasar, seperti external_viewer_id dan external_value. Lihat Menyajikan dasbor dengan aman kepada pengguna individual.
  3. Pembuatan token yang dilingkup pengguna: Dengan menggunakan respons dari /tokeninfo titik akhir dan titik akhir Databricks OpenID Connect (OIDC), server Anda menghasilkan token baru yang terlingkup erat yang mengodekan informasi pengguna yang telah Anda lewati.
  4. Penyajian dasbor dan pemfilteran data: Halaman aplikasi membuat instans DatabricksDashboard dari @databricks/aibi-client dan meneruskan token yang dilingkup pengguna selama konstruksi. Dasbor dirender dengan konteks pengguna. Token ini mengotorisasi akses, mendukung audit dengan external_viewer_id, dan membawa external_value pemfilteran data. Kueri dalam himpunan data dasbor dapat mereferensikan __aibi_external_value untuk menerapkan filter per pengguna, memastikan setiap penampil hanya melihat data yang diizinkan untuk dilihat.

Menyajikan dasbor dengan aman kepada pengguna individual

Konfigurasikan server aplikasi Anda untuk menghasilkan token unik yang dilingkup pengguna untuk setiap pengguna berdasarkan .external_viewer_id Ini memungkinkan Anda melacak tampilan dan penggunaan dasbor melalui log audit. external_viewer_id dipasangkan dengan external_value, yang bertindak sebagai variabel global yang dapat dimasukkan ke dalam kueri SQL yang digunakan dalam himpunan data dasbor. Ini memungkinkan Anda memfilter data yang ditampilkan di dasbor untuk setiap pengguna.

external_viewer_id diteruskan ke log audit dasbor Anda dan tidak boleh menyertakan informasi identitas pribadi. Nilai ini juga harus unik per pengguna.

external_value digunakan dalam pemrosesan kueri dan dapat menyertakan informasi yang dapat diidentifikasi secara pribadi.

Contoh berikut menunjukkan cara menggunakan nilai eksternal sebagai filter dalam kueri himpunan data:

SELECT *
FROM sales
WHERE region = __aibi_external_value

Gambaran umum penyetelan

Bagian ini mencakup gambaran umum konseptual tingkat tinggi tentang langkah-langkah yang perlu Anda lakukan untuk menyiapkan penyematan dasbor di lokasi eksternal.

Untuk menyematkan dasbor di aplikasi eksternal, Anda terlebih dahulu membuat perwakilan layanan di Azure Databricks dan menghasilkan rahasia. Perwakilan layanan harus diberikan akses baca ke dasbor dan data yang mendasarnya. Server Anda menggunakan rahasia perwakilan layanan untuk mengambil token yang dapat mengakses API dasbor atas nama perwakilan layanan. Dengan token ini, server memanggil /tokeninfo titik akhir API, titik akhir OpenID Connect (OIDC) yang mengembalikan informasi profil pengguna dasar, termasuk external_value nilai dan external_viewer_id . Nilai-nilai ini memungkinkan Anda mengaitkan permintaan dengan pengguna individual.

Dengan menggunakan token yang diperoleh dari perwakilan layanan, server Anda menghasilkan token baru yang dilingkupkan ke pengguna tertentu yang mengakses dasbor. Token yang dilingkup pengguna ini diteruskan ke halaman aplikasi, di mana aplikasi membuat instans DatabricksDashboard objek dari @databricks/aibi-client pustaka. Token membawa informasi khusus pengguna yang mendukung audit dan memberlakukan pemfilteran sehingga setiap pengguna hanya melihat data yang diizinkan untuk diakses. Dari perspektif pengguna, masuk ke aplikasi secara otomatis menyediakan akses ke dasbor yang disematkan dengan visibilitas data yang benar.

Batas laju dan pertimbangan performa

Penyematan eksternal memiliki batas laju 20 beban dasbor per detik. Anda dapat membuka lebih dari 20 dasbor sekaligus, tetapi tidak lebih dari 20 dapat mulai memuat secara bersamaan.

Prasyarat

Untuk menerapkan penyematan eksternal, pastikan Anda memenuhi prasyarat berikut:

  • Anda harus memiliki setidaknya izin CAN MANAGE di dasbor yang diterbitkan. Lihat Tutorial: Menggunakan dasbor sampel untuk membuat dan menerbitkan dasbor contoh dengan cepat, jika perlu.
  • Anda harus menginstal Databricks CLI versi 0.205 atau lebih tinggi. Lihat Menginstal atau memperbarui Databricks CLI untuk instruksi. Untuk mengonfigurasi dan menggunakan autentikasi OAuth, lihat Autentikasi pengguna-ke-mesin (U2M) OAuth.
  • Admin ruang kerja harus menentukan daftar domain yang disetujui yang dapat menghosting dasbor yang disematkan. Lihat Mengelola penyematan dasbor untuk instruksi.
  • Aplikasi eksternal untuk menghosting dasbor tersemat Anda. Anda dapat menggunakan aplikasi Anda sendiri atau menggunakan aplikasi sampel yang disediakan.

Langkah 1: Membuat perwakilan layanan

Buat perwakilan layanan untuk bertindak sebagai identitas untuk aplikasi eksternal Anda dalam Azure Databricks. Perwakilan layanan ini mengautentikasi permintaan atas nama aplikasi Anda.

Untuk membuat perwakilan layanan:

  1. Sebagai admin ruang kerja, masuk ke ruang kerja Azure Databricks.
  2. Klik nama pengguna Anda di bilah atas ruang kerja Azure Databricks dan pilih Pengaturan.
  3. Klik Identitas dan akses di panel kiri.
  4. Di samping Perwakilan layanan, klik Kelola.
  5. Klik Tambahkan principal layanan.
  6. Klik Tambahkan baru.
  7. Masukkan nama deskriptif untuk perwakilan layanan.
  8. Klik Tambahkan.
  9. Buka perwakilan layanan yang baru saja Anda buat dari halaman daftar Perwakilan layanan . Gunakan bidang Filter entri teks untuk mencarinya berdasarkan nama, jika perlu.
  10. Pada halaman Detail perwakilan layanan , rekam Id Aplikasi. Verifikasi bahwa kotak centang Akses Databricks SQL dan akses Ruang Kerja dipilih.

Langkah 2: Membuat rahasia OAuth

Buat rahasia untuk perwakilan layanan dan kumpulkan nilai konfigurasi berikut, yang akan Anda butuhkan untuk aplikasi eksternal Anda:

  • ID perwakilan layanan (klien)
  • Rahasia klien

Perwakilan layanan menggunakan rahasia OAuth untuk memverifikasi identitasnya saat meminta token akses dari aplikasi eksternal Anda.

Untuk menghasilkan rahasia:

  1. Klik Rahasia di halaman Detail perwakilan layanan .
  2. Klik Buat rahasia.
  3. Masukkan nilai seumur hidup untuk rahasia baru dalam hari (misalnya, antara 1 dan 730 hari).
  4. Salin rahasianya segera. Anda tidak dapat melihat rahasia ini lagi setelah Anda meninggalkan layar ini.

Langkah 3: Tetapkan izin ke perwakilan layanan Anda

Perwakilan layanan yang Anda buat bertindak sebagai identitas yang menyediakan akses dasbor melalui aplikasi Anda. Izinnya hanya berlaku jika dasbor tidak diterbitkan dengan izin berbagi data. Jika izin data bersama digunakan, kredensial penerbit mengakses data. Untuk detail dan rekomendasi selengkapnya, lihat Menyematkan pendekatan autentikasi.

  1. Klik Dasbor di bar samping ruang kerja untuk membuka halaman daftar dasbor.
  2. Klik nama dasbor yang ingin Anda sematkan. Dasbor yang diterbitkan terbuka.
  3. Klik Bagikan.
  4. Gunakan bidang entri teks dalam dialog Berbagi untuk menemukan perwakilan layanan Anda lalu klik di atasnya. Atur tingkat izin ke CAN RUN. Lalu, klik Tambahkan.
  5. Rekam ID dasbor. Anda dapat menemukan ID dasbor di URL dasbor (misalnya, https://<your-workspace-url>/dashboards/<dashboard-id>). Lihat Detail ruang kerja Databricks.

Nota

Jika Anda menerbitkan dasbor dengan izin data individual, Anda harus memberikan akses perwakilan layanan anda ke data yang digunakan di dasbor. Akses komputasi selalu menggunakan kredensial penerbit, sehingga Anda tidak perlu memberikan izin komputasi kepada perwakilan layanan.

Untuk membaca dan menampilkan data, perwakilan layanan harus memiliki setidaknya SELECT hak istimewa pada tabel dan tampilan yang dirujuk di dasbor. Lihat Siapa yang dapat mengelola hak istimewa?.

Langkah 4: Gunakan contoh aplikasi untuk mengautentikasi dan menghasilkan token

Gunakan contoh aplikasi untuk berlatih menyematkan dasbor Anda secara eksternal. Aplikasi ini mencakup instruksi dan kode yang memulai pertukaran token yang diperlukan untuk menghasilkan token terlingkup. Blok kode berikut tidak memiliki dependensi. Salin dan simpan salah satu aplikasi berikut.

Phyton

Salin dan simpan ini dalam file bernama example.py.

#!/usr/bin/env python3

import os
import sys
import json
import base64
import urllib.request
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler

# -----------------------------------------------------------------------------
# Config
# -----------------------------------------------------------------------------
CONFIG = {
    "instance_url": os.environ.get("INSTANCE_URL"),
    "dashboard_id": os.environ.get("DASHBOARD_ID"),
    "service_principal_id": os.environ.get("SERVICE_PRINCIPAL_ID"),
    "service_principal_secret": os.environ.get("SERVICE_PRINCIPAL_SECRET"),
    "external_viewer_id": os.environ.get("EXTERNAL_VIEWER_ID"),
    "external_value": os.environ.get("EXTERNAL_VALUE"),
    "workspace_id": os.environ.get("WORKSPACE_ID"),
    "port": int(os.environ.get("PORT", 3000)),
}

basic_auth = base64.b64encode(
    f"{CONFIG['service_principal_id']}:{CONFIG['service_principal_secret']}".encode()
).decode()

# -----------------------------------------------------------------------------
# HTTP Request Helper
# -----------------------------------------------------------------------------
def http_request(url, method="GET", headers=None, body=None):
    headers = headers or {}
    if body is not None and not isinstance(body, (bytes, str)):
        raise ValueError("Body must be bytes or str")

    req = urllib.request.Request(url, method=method, headers=headers)
    if body is not None:
        if isinstance(body, str):
            body = body.encode()
        req.data = body

    try:
        with urllib.request.urlopen(req) as resp:
            data = resp.read().decode()
            try:
                return {"data": json.loads(data)}
            except json.JSONDecodeError:
                return {"data": data}
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"HTTP {e.code}: {e.read().decode()}") from None

# -----------------------------------------------------------------------------
# Token logic
# -----------------------------------------------------------------------------
def get_scoped_token():
    # 1. Get all-api token
    oidc_res = http_request(
        f"{CONFIG['instance_url']}/oidc/v1/token",
        method="POST",
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": f"Basic {basic_auth}",
        },
        body=urllib.parse.urlencode({
            "grant_type": "client_credentials",
            "scope": "all-apis"
        })
    )
    oidc_token = oidc_res["data"]["access_token"]

    # 2. Get token info
    token_info_url = (
        f"{CONFIG['instance_url']}/api/2.0/lakeview/dashboards/"
        f"{CONFIG['dashboard_id']}/published/tokeninfo"
        f"?external_viewer_id={urllib.parse.quote(CONFIG['external_viewer_id'])}"
        f"&external_value={urllib.parse.quote(CONFIG['external_value'])}"
    )
    token_info = http_request(
        token_info_url,
        headers={"Authorization": f"Bearer {oidc_token}"}
    )["data"]

    # 3. Generate scoped token
    params = token_info.copy()
    authorization_details = params.pop("authorization_details", None)
    params.update({
        "grant_type": "client_credentials",
        "authorization_details": json.dumps(authorization_details)
    })

    scoped_res = http_request(
        f"{CONFIG['instance_url']}/oidc/v1/token",
        method="POST",
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": f"Basic {basic_auth}",
        },
        body=urllib.parse.urlencode(params)
    )
    return scoped_res["data"]["access_token"]

# -----------------------------------------------------------------------------
# HTML generator
# -----------------------------------------------------------------------------
def generate_html(token):
    return f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard Demo</title>
    <style>
        body {{ font-family: system-ui; margin: 0; padding: 20px; background: #f5f5f5; }}
        .container {{ max-width: 1200px; margin: 0 auto; height:calc(100vh - 40px) }}
    </style>
</head>
<body>
    <div id="dashboard-content" class="container"></div>
    <script type="module">
        import {{ DatabricksDashboard }} from "https://cdn.jsdelivr.net/npm/@databricks/aibi-client@0.0.0-alpha.7/+esm";
        const dashboard = new DatabricksDashboard({{
            instanceUrl: "{CONFIG['instance_url']}",
            workspaceId: "{CONFIG['workspace_id']}",
            dashboardId: "{CONFIG['dashboard_id']}",
            token: "{token}",
            container: document.getElementById("dashboard-content")
        }});
        dashboard.initialize();
    </script>
</body>
</html>"""

# -----------------------------------------------------------------------------
# HTTP server
# -----------------------------------------------------------------------------
class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/":
            self.send_response(404)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Not Found")
            return

        try:
            token = get_scoped_token()
            html = generate_html(token)
            status = 200
        except Exception as e:
            html = f"<h1>Error</h1><p>{e}</p>"
            status = 500

        self.send_response(status)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        self.wfile.write(html.encode())

def start_server():
    missing = [k for k, v in CONFIG.items() if not v]
    if missing:
        print(f"Missing: {', '.join(missing)}", file=sys.stderr)
        sys.exit(1)

    server = HTTPServer(("localhost", CONFIG["port"]), RequestHandler)
    print(f":rocket: Server running on http://localhost:{CONFIG['port']}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        sys.exit(0)

if __name__ == "__main__":
    start_server()

JavaScript

Salin dan simpan ini dalam file bernama example.js.

#!/usr/bin/env node

const http = require('http');
const https = require('https');
const { URL, URLSearchParams } = require('url');

// This constant is just a mapping of environment variables to their respective
// values.
const CONFIG = {
  instanceUrl: process.env.INSTANCE_URL,
  dashboardId: process.env.DASHBOARD_ID,
  servicePrincipalId: process.env.SERVICE_PRINCIPAL_ID,
  servicePrincipalSecret: process.env.SERVICE_PRINCIPAL_SECRET,
  externalViewerId: process.env.EXTERNAL_VIEWER_ID,
  externalValue: process.env.EXTERNAL_VALUE,
  workspaceId: process.env.WORKSPACE_ID,
  port: process.env.PORT || 3000,
};

const basicAuth = Buffer.from(`${CONFIG.servicePrincipalId}:${CONFIG.servicePrincipalSecret}`).toString('base64');

// ------------------------------------------------------------------------------------------------
// Main
// ------------------------------------------------------------------------------------------------

function startServer() {
  const missing = Object.keys(CONFIG).filter((key) => !CONFIG[key]);
  if (missing.length > 0) throw new Error(`Missing: ${missing.join(', ')}`);

  const server = http.createServer(async (req, res) => {
    // This is a demo server, we only support GET requests to the root URL.
    if (req.method !== 'GET' || req.url !== '/') {
      res.writeHead(404, { 'Content-Type': 'text/plain' });
      res.end('Not Found');
      return;
    }

    let html = '';
    let status = 200;

    try {
      const token = await getScopedToken();
      html = generateHTML(token);
    } catch (error) {
      html = `<h1>Error</h1><p>${error.message}</p>`;
      status = 500;
    } finally {
      res.writeHead(status, { 'Content-Type': 'text/html' });
      res.end(html);
    }
  });

  server.listen(CONFIG.port, () => {
    console.log(`🚀 Server running on http://localhost:${CONFIG.port}`);
  });

  process.on('SIGINT', () => process.exit(0));
  process.on('SIGTERM', () => process.exit(0));
}

async function getScopedToken() {
  // 1. Get all-api token. This will allow you to access the /tokeninfo
  // endpoint, which contains the information required to generate a scoped token
  const {
    data: { access_token: oidcToken },
  } = await httpRequest(`${CONFIG.instanceUrl}/oidc/v1/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: `Basic ${basicAuth}`,
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      scope: 'all-apis',
    }),
  });

  // 2. Get token info. This information is **required** for generating a token that is correctly downscoped.
  // A correctly downscoped token will only have access to a handful of APIs, and within those APIs, only
  // a the specific resources required to render the dashboard.
  //
  // This is essential to prevent leaking a privileged token.
  //
  // At the time of writing, OAuth tokens in Databricks are valid for 1 hour.
  const tokenInfoUrl = new URL(
    `${CONFIG.instanceUrl}/api/2.0/lakeview/dashboards/${CONFIG.dashboardId}/published/tokeninfo`,
  );
  tokenInfoUrl.searchParams.set('external_viewer_id', CONFIG.externalViewerId);
  tokenInfoUrl.searchParams.set('external_value', CONFIG.externalValue);

  const { data: tokenInfo } = await httpRequest(tokenInfoUrl.toString(), {
    headers: { Authorization: `Bearer ${oidcToken}` },
  });

  // 3. Generate scoped token. This call is very similar to what was issued before, but now we are providing the scoping to make the generated token
  // safe to pass to a browser.
  const { authorization_details, ...params } = tokenInfo;
  const {
    data: { access_token },
  } = await httpRequest(`${CONFIG.instanceUrl}/oidc/v1/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: `Basic ${basicAuth}`,
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      ...params,
      authorization_details: JSON.stringify(authorization_details),
    }),
  });

  return access_token;
}

startServer();

// ------------------------------------------------------------------------------------------------
// Helper functions
// ------------------------------------------------------------------------------------------------

/**
 * Helper function to create HTTP requests.
 * @param {string} url - The URL to make the request to.
 * @param {Object} options - The options for the request.
 * @param {string} options.method - The HTTP method to use.
 * @param {Object} options.headers - The headers to include in the request.
 * @param {Object} options.body - The body to include in the request.
 * @returns {Promise<Object>} A promise that resolves to the response data.
 */
function httpRequest(url, { method = 'GET', headers = {}, body } = {}) {
  return new Promise((resolve, reject) => {
    const isHttps = url.startsWith('https://');
    const lib = isHttps ? https : http;
    const options = new URL(url);
    options.method = method;
    options.headers = headers;

    const req = lib.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => (data += chunk));
      res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          try {
            resolve({ data: JSON.parse(data) });
          } catch {
            resolve({ data });
          }
        } else {
          reject(new Error(`HTTP ${res.statusCode}: ${data}`));
        }
      });
    });

    req.on('error', reject);

    if (body) {
      if (typeof body === 'string' || Buffer.isBuffer(body)) {
        req.write(body);
      } else if (body instanceof URLSearchParams) {
        req.write(body.toString());
      } else {
        req.write(JSON.stringify(body));
      }
    }
    req.end();
  });
}

function generateHTML(token) {
  return `<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard Demo</title>
    <style>
        body { font-family: system-ui; margin: 0; padding: 20px; background: #f5f5f5; }
        .container { max-width: 1200px; margin: 0 auto; height:calc(100vh - 40px) }
    </style>
</head>
<body>
    <div id="dashboard-content" class="container"></div>
    <script type="module">
        /**
         * We recommend bundling the dependency instead of using a CDN. However, for demonstration purposes,
         * we are just using a CDN.
         * 
         * We do not recommend one CDN over another and encourage decoupling the dependency from third-party code.
         */
        import { DatabricksDashboard } from "https://cdn.jsdelivr.net/npm/@databricks/aibi-client@0.0.0-alpha.7/+esm";
    
        const dashboard = new DatabricksDashboard({
            instanceUrl: "${CONFIG.instanceUrl}",
            workspaceId: "${CONFIG.workspaceId}",
            dashboardId: "${CONFIG.dashboardId}",
            token: "${token}",
            container: document.getElementById("dashboard-content")
        });
        
        dashboard.initialize();
    </script>
</body>
</html>`;
}

Langkah 5: Jalankan aplikasi contoh

Ganti nilai berikut lalu jalankan blok kode dari terminal Anda. Nilai Anda tidak boleh dikelilingi oleh tanda kurung sudut (< >):

  • Gunakan URL ruang kerja untuk menemukan dan mengganti nilai berikut:
    • <your-instance>
    • <workspace_id>
    • <dashboard_id>
  • Ganti nilai berikut dengan nilai yang Anda buat saat membuat perwakilan layanan (langkah 2):
    • <service_principal_id>
    • <service_principal_secret> (rahasia klien)
  • Ganti nilai berikut dengan pengidentifikasi yang terkait dengan pengguna aplikasi eksternal:
    • <some-external-viewer>
    • <some-external-value>
  • Ganti </path/to/example> dengan jalur ke file atau .py yang .js Anda buat di langkah sebelumnya. Sertakan ekstensi file.

Nota

Jangan sertakan informasi pengidentifikasi pribadi (PII) dalam EXTERNAL_VIEWER_ID nilai .


INSTANCE_URL='https://<your-instance>.databricks.com' \
WORKSPACE_ID='<workspace_id>' \
DASHBOARD_ID='<dashboard_id>' \
SERVICE_PRINCIPAL_ID='<service-principal-id>' \
SERVICE_PRINCIPAL_SECRET='<service-principal_secret>' \
EXTERNAL_VIEWER_ID='<some-external-viewer>' \
EXTERNAL_VALUE='<some-external-value>' \
~</path/to/example>

# Terminal will output: :rocket: Server running on http://localhost:3000