From 59af0f4e618924f573a6b841c203ec47f39e8920 Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Tue, 11 Aug 2026 12:34:42 -0700 Subject: [PATCH 1/4] ape and sdx ui --- .github/workflows/.build.yml | 61 + .github/workflows/sdx-brochure-ui.yaml | 15 + sdx/.gitignore | 7 +- sdx/ape/README.md | 103 + sdx/ape/functions/.gitignore | 1 + sdx/ape/functions/opal-pip-catalog/README.md | 79 + sdx/ape/functions/opal-pip-catalog/chart.yaml | 38 + sdx/ape/functions/opal-pip-catalog/main.ts | 204 ++ sdx/ape/functions/opal-policies/.manifest | 1 + sdx/ape/functions/opal-policies/README.md | 105 + sdx/ape/functions/opal-policies/chart.yaml | 45 + sdx/ape/functions/opal-policies/main.ts | 214 ++ sdx/ape/functions/pubsub-kafka/README.md | 40 + sdx/ape/functions/pubsub-kafka/chart.yaml | 31 + sdx/ape/functions/pubsub-kafka/main.ts | 210 ++ sdx/ape/functions/pubsub-webhook/README.md | 52 + sdx/ape/functions/pubsub-webhook/chart.yaml | 42 + sdx/ape/functions/pubsub-webhook/main.ts | 233 ++ sdx/ape/gwa/README.md | 19 + sdx/ape/gwa/gw-config.yaml | 55 + sdx/ape/opal-client/README.md | 29 + sdx/ape/opal-client/values.yaml | 29 + sdx/ape/opal-server/README.md | 41 + sdx/ape/opal-server/values.yaml | 53 + sdx/ape/policies/simple-get-only.rego | 11 + sdx/brochure-ui/.gitignore | 4 + sdx/brochure-ui/README.md | 82 + sdx/brochure-ui/components/ActivityFeed.tsx | 330 +++ sdx/brochure-ui/components/Breadcrumb.tsx | 69 + sdx/brochure-ui/components/ConsoleNav.tsx | 31 + sdx/brochure-ui/components/CopyButton.tsx | 32 + sdx/brochure-ui/components/EnvFilter.tsx | 105 + sdx/brochure-ui/components/Footer.tsx | 61 + sdx/brochure-ui/components/KeysetKeyCard.tsx | 62 + sdx/brochure-ui/components/Layout.tsx | 133 + sdx/brochure-ui/components/LogStream.tsx | 321 +++ sdx/brochure-ui/components/Markdown.tsx | 11 + sdx/brochure-ui/components/Nav.tsx | 176 ++ sdx/brochure-ui/components/OrgCard.tsx | 63 + sdx/brochure-ui/components/OrgPicker.tsx | 157 ++ sdx/brochure-ui/components/StatCard.tsx | 24 + sdx/brochure-ui/components/SubsystemCard.tsx | 71 + .../components/TimeSeriesChart.tsx | 162 ++ sdx/brochure-ui/components/TrustKeysets.tsx | 66 + .../components/VerificationBadge.tsx | 218 ++ sdx/brochure-ui/deno.json | 15 + sdx/brochure-ui/deno.lock | 156 ++ sdx/brochure-ui/lib/auth.ts | 293 ++ sdx/brochure-ui/lib/connections.ts | 149 + sdx/brochure-ui/lib/environments.ts | 109 + sdx/brochure-ui/lib/fonts.ts | 106 + sdx/brochure-ui/lib/jwks.ts | 190 ++ sdx/brochure-ui/lib/metrics.ts | 74 + sdx/brochure-ui/lib/public-bodies.ts | 90 + sdx/brochure-ui/lib/runtime-groups.ts | 34 + sdx/brochure-ui/lib/verification.ts | 1180 ++++++++ sdx/brochure-ui/main.ts | 1671 +++++++++++ sdx/brochure-ui/metrics-queries.yaml | 7 + sdx/brochure-ui/pages/ActivityConsolePage.tsx | 115 + sdx/brochure-ui/pages/ConnectionsPage.tsx | 2486 +++++++++++++++++ sdx/brochure-ui/pages/ConsolePage.tsx | 249 ++ sdx/brochure-ui/pages/HomePage.tsx | 149 + sdx/brochure-ui/pages/LogsPage.tsx | 50 + sdx/brochure-ui/pages/MetricsPage.tsx | 199 ++ sdx/brochure-ui/pages/OrgDetailPage.tsx | 236 ++ sdx/brochure-ui/pages/OrganizationsPage.tsx | 85 + sdx/brochure-ui/pages/RuntimesPage.tsx | 269 ++ sdx/brochure-ui/pages/ScopesPage.tsx | 391 +++ sdx/brochure-ui/pages/SubsystemDetailPage.tsx | 669 +++++ sdx/brochure-ui/pages/SubsystemsPage.tsx | 97 + sdx/brochure-ui/pages/TrafficPage.tsx | 328 +++ sdx/brochure-ui/pages/TrustPage.tsx | 450 +++ sdx/brochure-ui/public/bc_logo_header.svg | 18 + sdx/brochure-ui/public/css/BC_Sans.css | 47 + .../public/vendor/highlight-github.min.css | 10 + .../public/vendor/highlight.min.js | 1232 ++++++++ sdx/brochure-ui/public/vendor/js-yaml.min.js | 2 + sdx/brochure-ui/samples/pql_service_code.json | 232 ++ sdx/brochure-ui/scripts/build-chart.sh | 107 + sdx/brochure-ui/types.ts | 248 ++ sdx/brochure-ui/verify.ts | 903 ++++++ 81 files changed, 16541 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/.build.yml create mode 100644 .github/workflows/sdx-brochure-ui.yaml create mode 100644 sdx/ape/README.md create mode 100644 sdx/ape/functions/.gitignore create mode 100644 sdx/ape/functions/opal-pip-catalog/README.md create mode 100644 sdx/ape/functions/opal-pip-catalog/chart.yaml create mode 100644 sdx/ape/functions/opal-pip-catalog/main.ts create mode 100644 sdx/ape/functions/opal-policies/.manifest create mode 100644 sdx/ape/functions/opal-policies/README.md create mode 100644 sdx/ape/functions/opal-policies/chart.yaml create mode 100644 sdx/ape/functions/opal-policies/main.ts create mode 100644 sdx/ape/functions/pubsub-kafka/README.md create mode 100644 sdx/ape/functions/pubsub-kafka/chart.yaml create mode 100644 sdx/ape/functions/pubsub-kafka/main.ts create mode 100644 sdx/ape/functions/pubsub-webhook/README.md create mode 100644 sdx/ape/functions/pubsub-webhook/chart.yaml create mode 100644 sdx/ape/functions/pubsub-webhook/main.ts create mode 100644 sdx/ape/gwa/README.md create mode 100644 sdx/ape/gwa/gw-config.yaml create mode 100644 sdx/ape/opal-client/README.md create mode 100644 sdx/ape/opal-client/values.yaml create mode 100644 sdx/ape/opal-server/README.md create mode 100644 sdx/ape/opal-server/values.yaml create mode 100644 sdx/ape/policies/simple-get-only.rego create mode 100644 sdx/brochure-ui/.gitignore create mode 100644 sdx/brochure-ui/README.md create mode 100644 sdx/brochure-ui/components/ActivityFeed.tsx create mode 100644 sdx/brochure-ui/components/Breadcrumb.tsx create mode 100644 sdx/brochure-ui/components/ConsoleNav.tsx create mode 100644 sdx/brochure-ui/components/CopyButton.tsx create mode 100644 sdx/brochure-ui/components/EnvFilter.tsx create mode 100644 sdx/brochure-ui/components/Footer.tsx create mode 100644 sdx/brochure-ui/components/KeysetKeyCard.tsx create mode 100644 sdx/brochure-ui/components/Layout.tsx create mode 100644 sdx/brochure-ui/components/LogStream.tsx create mode 100644 sdx/brochure-ui/components/Markdown.tsx create mode 100644 sdx/brochure-ui/components/Nav.tsx create mode 100644 sdx/brochure-ui/components/OrgCard.tsx create mode 100644 sdx/brochure-ui/components/OrgPicker.tsx create mode 100644 sdx/brochure-ui/components/StatCard.tsx create mode 100644 sdx/brochure-ui/components/SubsystemCard.tsx create mode 100644 sdx/brochure-ui/components/TimeSeriesChart.tsx create mode 100644 sdx/brochure-ui/components/TrustKeysets.tsx create mode 100644 sdx/brochure-ui/components/VerificationBadge.tsx create mode 100644 sdx/brochure-ui/deno.json create mode 100644 sdx/brochure-ui/deno.lock create mode 100644 sdx/brochure-ui/lib/auth.ts create mode 100644 sdx/brochure-ui/lib/connections.ts create mode 100644 sdx/brochure-ui/lib/environments.ts create mode 100644 sdx/brochure-ui/lib/fonts.ts create mode 100644 sdx/brochure-ui/lib/jwks.ts create mode 100644 sdx/brochure-ui/lib/metrics.ts create mode 100644 sdx/brochure-ui/lib/public-bodies.ts create mode 100644 sdx/brochure-ui/lib/runtime-groups.ts create mode 100644 sdx/brochure-ui/lib/verification.ts create mode 100644 sdx/brochure-ui/main.ts create mode 100644 sdx/brochure-ui/metrics-queries.yaml create mode 100644 sdx/brochure-ui/pages/ActivityConsolePage.tsx create mode 100644 sdx/brochure-ui/pages/ConnectionsPage.tsx create mode 100644 sdx/brochure-ui/pages/ConsolePage.tsx create mode 100644 sdx/brochure-ui/pages/HomePage.tsx create mode 100644 sdx/brochure-ui/pages/LogsPage.tsx create mode 100644 sdx/brochure-ui/pages/MetricsPage.tsx create mode 100644 sdx/brochure-ui/pages/OrgDetailPage.tsx create mode 100644 sdx/brochure-ui/pages/OrganizationsPage.tsx create mode 100644 sdx/brochure-ui/pages/RuntimesPage.tsx create mode 100644 sdx/brochure-ui/pages/ScopesPage.tsx create mode 100644 sdx/brochure-ui/pages/SubsystemDetailPage.tsx create mode 100644 sdx/brochure-ui/pages/SubsystemsPage.tsx create mode 100644 sdx/brochure-ui/pages/TrafficPage.tsx create mode 100644 sdx/brochure-ui/pages/TrustPage.tsx create mode 100644 sdx/brochure-ui/public/bc_logo_header.svg create mode 100644 sdx/brochure-ui/public/css/BC_Sans.css create mode 100644 sdx/brochure-ui/public/vendor/highlight-github.min.css create mode 100644 sdx/brochure-ui/public/vendor/highlight.min.js create mode 100644 sdx/brochure-ui/public/vendor/js-yaml.min.js create mode 100644 sdx/brochure-ui/samples/pql_service_code.json create mode 100755 sdx/brochure-ui/scripts/build-chart.sh create mode 100644 sdx/brochure-ui/types.ts create mode 100644 sdx/brochure-ui/verify.ts diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml new file mode 100644 index 0000000..06bb91c --- /dev/null +++ b/.github/workflows/.build.yml @@ -0,0 +1,61 @@ +on: + workflow_call: + inputs: + name: + required: true + type: string + context: + required: true + type: string + default: '.' + +jobs: + build-image: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7 + + - name: Image meta + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }}/${{ inputs.name }} + + - name: Set DEPLOY_ID + run: | + echo "DEPLOY_ID=${{ steps.meta.outputs.version }}" >> "$GITHUB_OUTPUT" + echo "APP_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}" >> "$GITHUB_OUTPUT" + echo "APP_REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}" >> "$GITHUB_OUTPUT" + id: set-deploy-id + + - name: Get deploy ID + run: echo "The DEPLOY_ID is ${{ steps.set-deploy-id.outputs.DEPLOY_ID }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: false + + - name: Create image + uses: docker/build-push-action@v7 + with: + context: ${{ inputs.context }} + file: ${{ inputs.context }}/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + build-args: | + APP_VERSION=${{ steps.set-deploy-id.outputs.APP_VERSION }} + APP_REVISION=${{ steps.set-deploy-id.outputs.APP_REVISION }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/sdx-brochure-ui.yaml b/.github/workflows/sdx-brochure-ui.yaml new file mode 100644 index 0000000..38a3618 --- /dev/null +++ b/.github/workflows/sdx-brochure-ui.yaml @@ -0,0 +1,15 @@ +name: SDX Brochure UI + +on: + push: + branches: [feature/*] + +jobs: + build-brochure-ui: + uses: ./.github/workflows/.build.yml + permissions: + contents: read + packages: write # πŸš€ Crucial permission needed to push to ghcr.io + with: + name: sdx-ui + context: ./sdx/brochure-ui diff --git a/sdx/.gitignore b/sdx/.gitignore index 48d4974..c596f04 100644 --- a/sdx/.gitignore +++ b/sdx/.gitignore @@ -2,4 +2,9 @@ *.key *.ca-bundle LOCAL.md -*.tgz \ No newline at end of file +*.tgz +.claude +.values-*.yaml +**/public/app.js* +**/public/assets/** +**/public/fonts/** \ No newline at end of file diff --git a/sdx/ape/README.md b/sdx/ape/README.md new file mode 100644 index 0000000..6150aed --- /dev/null +++ b/sdx/ape/README.md @@ -0,0 +1,103 @@ +# Policy and Event Management + +Extending APS API Management solution to include a Policy Engine and Event Management. + +## Installation + +Steps for OPAL: + +- deploy opal-server +- publish opal-api-gateway configuration +- deploy opal-client +- deploy opal-policies +- deploy opal-pip-catalog + +Steps for Events: + +- deploy pubsub-webhook +- deploy pubsub-kafka + +## Usage + +### Event Publish + +Resources created: + +- `GatewayService for sdx-events.api.gov.bc.ca` + +> TODO: sdx-events by default is DENY +> TODO: upgrade jwt-keycloak (issuer, aud) - RS needs a token + +> WF: `/sdx/0//forward/` +> Create an endpoint that external shared services (like WF) can call to +> pass to the RS client, where it can get its own token +> WF Client - issue creds to get a client to call the RS +> WF gets a RS token, and then a RS token to get an Amina token + +```json +{ + "pattern": "events-publisher.r1", + "parameters": { + "service_id": "LAB.USR.ACOPE.HELLO-WORLD-APPLICATION.v0" + } +} +``` + +### Event Webhook + +Resources created: + +- `Webhook` + +```json +{ + "pattern": "events-webhook.r1", + "parameters": { + "conn_id": "42", + "client_id": "LAB.MIN.CITZ.SDG-FE", + "service_id": "LAB.USR.ACOPE.HELLO-WORLD-APPLICATION.v0", + "webhook_url": "https://bright-island-08.webhook.cool" + } +} +``` + +### OPAL Policy + +Resources created: + +- `RegoPolicy` + +> TODO: For "playground" have sample data for inputs + +```json +{ + "pattern": "opal-policy.r1", + "parameters": { + "subsystem_id": "LAB.USR.ACOPE.APS-KAFKA", + "name": "authz", + "policy": "package LAB_USR_ACOPE_APS_KAFKA.authz\n\nimport rego.v1\n\n# Default deny everything\ndefault allow := false\n\n# Allow GET requests\nallow if {\n input.method == \"GET\"\n}" + } +} +``` + +### OPAL Data Source + +Resources created: + +- `PolicyDataSource` + +> TODO: Create a gateway route for the PDPs to access (subsystem edge server) +> TODO: Use "internal" url +> TODO: Update PolicyDataSource to use the internal url +> TODO: Deploy opal-client to edge-server optionally (and cleanup bootstrap) + +```json +{ + "pattern": "opal-data-source.r1", + "parameters": { + "subsystem_id": "LAB.USR.ACOPE.APS-KAFKA", + "name": "user-gateways", + "upstream_url": "https://httpbun.com/any" + } +} +``` diff --git a/sdx/ape/functions/.gitignore b/sdx/ape/functions/.gitignore new file mode 100644 index 0000000..adbb97d --- /dev/null +++ b/sdx/ape/functions/.gitignore @@ -0,0 +1 @@ +data/ \ No newline at end of file diff --git a/sdx/ape/functions/opal-pip-catalog/README.md b/sdx/ape/functions/opal-pip-catalog/README.md new file mode 100644 index 0000000..b8acbba --- /dev/null +++ b/sdx/ape/functions/opal-pip-catalog/README.md @@ -0,0 +1,79 @@ +# opal-pip-catalog + +## AI Prompt + +- Look for files in current directory only +- All code in single `main.ts` TypeScript file +- Use Deno runtime +- Use SQLite database (`https://deno.land/x/sqlite`) +- Use `jsr:@std/yaml` for YAML handling +- Create database if it doesn't exist +- No environment variables +- Serve on port 8000 +- Database location: `./data/sqlite.db` +- Build REST API endpoints with database interactions + +## Requirements + +- want an endpoint that returns a static list of entries +- the entries can be empty by default + +## Running the API + +```sh +deno run --allow-net --allow-read --allow-write --allow-env main.ts +``` + +```sh +restish PUT http://localhost:8000/entries \ + 'name: abc, dst_path: /abc, topics[]: tenant_data, url: "https://httpbun.com"' +``` + +## Deployment + +```sh +helm upgrade --install opal-pip-catalog \ + --set fullnameOverride=opal-pip-catalog \ + -f chart.yaml \ + --set-file "config[0].contents=main.ts" \ +bcgov/generic-api +``` + +### Test a PIP + +#### Register a PIP + +```sh +restish PUT https://opal-pip-catalog-api-gov-bc-ca.dev.api.gov.bc.ca/entries \ + url: https://httpbun.com, \ + "topics: tenant_data", \ + dst_path: "/abc" +``` + +#### Troubleshoot + +```sh +-- get all entries +restish GET https://opal-pip-catalog-api-gov-bc-ca.dev.api.gov.bc.ca/entries + +-- get entry +restish GET https://opal-pip-catalog-api-gov-bc-ca.dev.api.gov.bc.ca/entries/1 + +-- add policy +restish PUT https://opal-pip-catalog-api-gov-bc-ca.dev.api.gov.bc.ca/entries \ + 'name: abc, dst_path: /abc, topics[]: tenant_data, url: "https://httpbun.com"' + +-- change notification + +-- get a token +restish POST https://opal-api-gov-bc-ca.dev.api.gov.bc.ca/token \ + -H "Authorization: Bearer $MASTER_TOKEN" \ + 'type: datasource, email: "aidan.cope@gov.bc.ca"' + +restish POST https://opal-api-gov-bc-ca.dev.api.gov.bc.ca/data/config \ + -H "Authorization: Bearer $CLIENT_TOKEN" \ + 'reason: just because, entries[]: {url: "https://httpbun.com/any", topics[]: tenant_data, dst_path: /abc}' + +restish GET https://opal-client-api-gov-bc-ca.dev.api.gov.bc.ca/v1/data/abc/headers + +``` diff --git a/sdx/ape/functions/opal-pip-catalog/chart.yaml b/sdx/ape/functions/opal-pip-catalog/chart.yaml new file mode 100644 index 0000000..3da544c --- /dev/null +++ b/sdx/ape/functions/opal-pip-catalog/chart.yaml @@ -0,0 +1,38 @@ +replicaCount: 1 + +rollingUpdate: + maxUnavailable: 100% + maxSurge: 100% + +image: + repository: denoland/deno + tag: 2.7.10 + pullPolicy: IfNotPresent + +persistence: + deno-dir: + mountPath: /deno-dir + data: + size: 5Mi + mountPath: /data + storageAccessMode: ReadWriteOnce + storageClassName: netapp-file-standard + +config: + - filename: main.ts + mountPath: /app/main.ts + +command: + - deno + +args: + - run + - --allow-net=:8000,deno.land,opal-api-gov-bc-ca.dev.api.gov.bc.ca + - --allow-read + - --allow-write + - --allow-env=OPAL_SERVER_URL + - /app/main.ts + +env: + OPAL_SERVER_URL: + value: "https://opal-api-gov-bc-ca.dev.api.gov.bc.ca" diff --git a/sdx/ape/functions/opal-pip-catalog/main.ts b/sdx/ape/functions/opal-pip-catalog/main.ts new file mode 100644 index 0000000..1029397 --- /dev/null +++ b/sdx/ape/functions/opal-pip-catalog/main.ts @@ -0,0 +1,204 @@ +import { DB } from "https://deno.land/x/sqlite@v3.9.1/mod.ts"; +import { parse as parseYaml } from "jsr:@std/yaml"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "npm:jose"; + +const originalLog = console.log; + +console.log = (...args) => { + originalLog(`[${new Date().toISOString()}]`, ...args); +}; + +const OPAL_SERVER_URL = + Deno.env.get("OPAL_SERVER_URL") || "http://localhost:7002"; + +const JWKS_URI = `${OPAL_SERVER_URL}/.well-known/jwks.json`; +const OPAL_DATA_CONFIG_URL = `${OPAL_SERVER_URL}/data/config`; + +const jwks = createRemoteJWKSet(new URL(JWKS_URI)); + +await Deno.mkdir("./data", { recursive: true }); + +const db = new DB("./data/sqlite.db"); + +db.execute(` + CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + url TEXT NOT NULL, + topics TEXT NOT NULL DEFAULT '[]', + dst_path TEXT NOT NULL + ) +`); + +// Migration: add name column to existing tables +try { + db.execute(`ALTER TABLE entries ADD COLUMN name TEXT`); +} catch { + // column already exists +} + +db.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_name ON entries(name)`, +); + +interface EntryRow { + [key: string]: unknown; + id: number; + name: string | null; + url: string; + topics: string; + dst_path: string; +} + +interface Entry { + id: number; + name: string | null; + url: string; + topics: string[]; + dst_path: string; + config?: { headers: { Authorization: string } }; +} + +function deserialize(row: EntryRow): Entry { + return { + ...row, + topics: JSON.parse(row.topics), + }; +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function parseBody(req: Request): Promise> { + const contentType = req.headers.get("content-type") ?? ""; + const text = await req.text(); + if (contentType.includes("yaml") || contentType.includes("yml")) { + return parseYaml(text) as Record; + } + return JSON.parse(text); +} + +async function handler(req: Request): Promise { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + if (path === "/entries" && method === "GET") { + const token = url.searchParams.get("token"); + let claims: JWTPayload | undefined; + + if (token) { + try { + const { payload } = await jwtVerify(token, jwks); + claims = payload; + } catch (err) { + console.warn("Invalid token:", token, err); + return json({ error: "invalid token found in pip catalog" }, 401); + } + } + + console.log( + "GET Entries " + (claims ? `for ${claims.sub}` : "without token"), + ); + + const rows = db.queryEntries("SELECT * FROM entries ORDER BY id"); + + const result = { + entries: rows.map(deserialize).map((entry) => ({ + ...{ url: entry.url, dst_path: entry.dst_path, topics: entry.topics }, + ...(token && { + config: { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }), + })), + ...(claims && { claims }), + }; + console.log("Returning entries:", JSON.stringify(result, null, 4)); + return json(result); + } + + if (path === "/entries" && method === "POST") { + const body = await parseBody(req); + const entryUrl = body.url as string | undefined; + const dstPath = body.dst_path as string | undefined; + const name = body.name as string | undefined; + if (!entryUrl) return json({ error: "url is required" }, 400); + if (!dstPath) return json({ error: "dst_path is required" }, 400); + const topics = JSON.stringify( + Array.isArray(body.topics) ? body.topics : [], + ); + db.query( + "INSERT INTO entries (name, url, topics, dst_path) VALUES (?, ?, ?, ?)", + [name ?? null, entryUrl, topics, dstPath], + ); + const [row] = db.queryEntries( + "SELECT * FROM entries WHERE id = ?", + [db.lastInsertRowId], + ); + return json(deserialize(row), 201); + } + + if (path === "/entries" && method === "PUT") { + const body = await parseBody(req); + const name = body.name as string | undefined; + const entryUrl = body.url as string | undefined; + const dstPath = body.dst_path as string | undefined; + if (!name) return json({ error: "name is required" }, 400); + if (!entryUrl) return json({ error: "url is required" }, 400); + if (!dstPath) return json({ error: "dst_path is required" }, 400); + const topics = JSON.stringify( + Array.isArray(body.topics) ? body.topics : [], + ); + db.query( + `INSERT INTO entries (name, url, topics, dst_path) VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET url=excluded.url, topics=excluded.topics, dst_path=excluded.dst_path`, + [name, entryUrl, topics, dstPath], + ); + console.log("PUT /entries called for", name); + const [row] = db.queryEntries( + "SELECT * FROM entries WHERE name = ?", + [name], + ); + return json(deserialize(row), 200); + } + + const entryMatch = path.match(/^\/entries\/(\d+)$/); + if (entryMatch) { + const id = Number(entryMatch[1]); + + if (method === "GET") { + const [row] = db.queryEntries( + "SELECT * FROM entries WHERE id = ?", + [id], + ); + if (!row) return json({ error: "not found" }, 404); + return json(deserialize(row)); + } + + if (method === "DELETE") { + const [row] = db.queryEntries( + "SELECT * FROM entries WHERE id = ?", + [id], + ); + if (!row) return json({ error: "not found" }, 404); + db.query("DELETE FROM entries WHERE id = ?", [id]); + return new Response(null, { status: 204 }); + } + } + + return json({ error: "not found" }, 404); +} + +Deno.addSignalListener("SIGTERM", () => { + Deno.exit(0); +}); + +console.log("Listening on http://localhost:8000"); +Deno.serve({ port: 8000 }, handler); diff --git a/sdx/ape/functions/opal-policies/.manifest b/sdx/ape/functions/opal-policies/.manifest new file mode 100644 index 0000000..10205b9 --- /dev/null +++ b/sdx/ape/functions/opal-policies/.manifest @@ -0,0 +1 @@ +{"revision":"2026-05-05T06:10:27.042Z","roots":[""]} \ No newline at end of file diff --git a/sdx/ape/functions/opal-policies/README.md b/sdx/ape/functions/opal-policies/README.md new file mode 100644 index 0000000..c40ebcb --- /dev/null +++ b/sdx/ape/functions/opal-policies/README.md @@ -0,0 +1,105 @@ +# pip-policies + +## AI Prompt + +- Look for files in current directory only +- All code in single `main.ts` TypeScript file +- Use Deno runtime +- Use `jsr:@std/yaml` for YAML handling +- No environment variables unless explicitely mentioned in requirements +- Listen for SIGTERM and call deno exit +- Serve on port 8000 + +Database specs: + +- Use SQLite database (`https://deno.land/x/sqlite`) +- Create database if it doesn't exist +- No environment variables for sqlite +- Database location: `./data/sqlite.db` + +## Requirements + +- want an endpoint that performs CRUD for Policies +- also support a PUT for "upsert" where it is transactionally safe +- Policy will have: { package: string, policy: string } +- "package" is a unique key + +- also want a `/bundle.tar.gz` that is an OPAL Bundle using all the "resources" where the resources are rego policies in the format: + +```json +{ + "package_name_1": "policy_1", + "package_name_2": "policy_2" +} +``` + +- include an ETag for the `bundle.tar.gz` +- when there is an update to the policies, call the opal webhook + +## Running the API + +```sh +deno run --no-prompt --allow-net --allow-read --allow-write --allow-env=OPAL_WEBHOOK_URL main.ts +``` + +```sh +restish PUT http://localhost:8000/policies/lab_min_citz_sys0 \ + package: lab_min_citz_sys0, policy: @../../policies/simple-get-only.rego + +restish GET http://localhost:8000/bundle.tar.gz +``` + +## Prerequisites + +1. Install the `opal-client` secret + +```sh +restish POST https://opal-api-gov-bc-ca.dev.api.gov.bc.ca/token \ + -H "Authorization: Bearer $MASTER_TOKEN" \ + type: client +``` + +```sh +export CLIENT_TOKEN="" +kubectl create secret --namespace b8840c-dev \ + --save-config --dry-run=client -o yaml \ + generic opal-policies-client-token \ + --from-literal=token=$CLIENT_TOKEN | kubectl apply -f - +``` + +## Deployment + +```sh +helm upgrade --install opal-policies \ + --set fullnameOverride=opal-policies \ + -f chart.yaml \ + --set-file "config[0].contents=main.ts" \ +bcgov/generic-api +``` + +### Test a policy + +#### Deploy policy + +```sh +restish PUT https://opal-policies-api-gov-bc-ca.dev.api.gov.bc.ca/policies/lab_min_citz_sys0.authz \ + package: lab_min_citz_sys0.authz, policy: @../../policies/simple-get-only.rego +``` + +#### Validate policy + +```sh +restish POST https://opal-client-api-gov-bc-ca.dev.api.gov.bc.ca/v1/data/lab_min_citz_sys0/authz/allow \ + 'input: {method: GET }' +``` + +#### Troubleshoot + +```sh +-- get all the policies +restish GET https://opal-policies-api-gov-bc-ca.dev.api.gov.bc.ca/policies + +-- get bundle +restish GET https://opal-policies-api-gov-bc-ca.dev.api.gov.bc.ca/bundle.tar.gz + +``` diff --git a/sdx/ape/functions/opal-policies/chart.yaml b/sdx/ape/functions/opal-policies/chart.yaml new file mode 100644 index 0000000..15679cc --- /dev/null +++ b/sdx/ape/functions/opal-policies/chart.yaml @@ -0,0 +1,45 @@ +replicaCount: 1 + +rollingUpdate: + maxUnavailable: 100% + maxSurge: 100% + +image: + repository: denoland/deno + tag: 2.7.10 + pullPolicy: IfNotPresent + +persistence: + deno-dir: + mountPath: /deno-dir + data: + size: 5Mi + mountPath: /data + storageAccessMode: ReadWriteOnce + storageClassName: netapp-file-standard + +config: + - filename: main.ts + mountPath: /app/main.ts + +command: + - deno + +args: + - run + - --allow-net=:8000,deno.land,opal-server:7002 + - --allow-read + - --allow-write + - --allow-env=OPAL_WEBHOOK_URL,OPAL_CLIENT_TOKEN + - /app/main.ts + +env: + OPAL_WEBHOOK_URL: + value: "http://opal-server:7002/webhook" + +extraEnvs: + - name: OPAL_CLIENT_TOKEN + valueFrom: + secretKeyRef: + name: opal-policies-client-token + key: token diff --git a/sdx/ape/functions/opal-policies/main.ts b/sdx/ape/functions/opal-policies/main.ts new file mode 100644 index 0000000..2e0e693 --- /dev/null +++ b/sdx/ape/functions/opal-policies/main.ts @@ -0,0 +1,214 @@ +import { DB } from "https://deno.land/x/sqlite@v3.9.1/mod.ts"; + +const originalLog = console.log; + +console.log = (...args) => { + originalLog(`[${new Date().toISOString()}]`, ...args); +}; + +function encodeTarHeader(name: string, size: number): Uint8Array { + const enc = new TextEncoder(); + const header = new Uint8Array(512); + const set = (offset: number, maxLen: number, value: string) => { + header.set(enc.encode(value).slice(0, maxLen), offset); + }; + set(0, 100, name); + set(100, 8, "0000644\0"); + set(108, 8, "0000000\0"); + set(116, 8, "0000000\0"); + set(124, 12, size.toString(8).padStart(11, "0") + "\0"); + set( + 136, + 12, + Math.floor(Date.now() / 1000) + .toString(8) + .padStart(11, "0") + "\0", + ); + header.fill(0x20, 148, 156); // checksum placeholder: spaces + header[156] = 0x30; // type '0' = regular file + set(257, 6, "ustar\0"); + set(263, 2, "00"); + let checksum = 0; + for (let i = 0; i < 512; i++) checksum += header[i]; + set(148, 8, checksum.toString(8).padStart(6, "0") + "\0 "); + return header; +} + +async function createOpalBundle( + policies: Array<{ pkg: string; policy: string }>, +): Promise { + const enc = new TextEncoder(); + const blocks: Uint8Array[] = []; + + const addFile = (name: string, content: Uint8Array) => { + blocks.push(encodeTarHeader(name, content.length)); + const padded = new Uint8Array(Math.ceil(content.length / 512) * 512); + padded.set(content); + blocks.push(padded); + }; + + addFile( + ".manifest", + enc.encode( + JSON.stringify({ revision: new Date().toISOString(), roots: [""] }), + ), + ); + + for (const { pkg, policy } of policies) { + addFile(pkg.replace(/\./g, "/") + ".rego", enc.encode(policy)); + } + + blocks.push(new Uint8Array(1024)); // end-of-archive + + const totalSize = blocks.reduce((sum, b) => sum + b.length, 0); + const tar = new Uint8Array(totalSize); + let offset = 0; + for (const block of blocks) { + tar.set(block, offset); + offset += block.length; + } + + const inputStream = new ReadableStream({ + start(controller) { + controller.enqueue(tar); + controller.close(); + }, + }); + const chunks: Uint8Array[] = []; + await inputStream.pipeThrough(new CompressionStream("gzip")).pipeTo( + new WritableStream({ + write(chunk) { + chunks.push(chunk); + }, + }), + ); + const gzipped = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0)); + let pos = 0; + for (const chunk of chunks) { + gzipped.set(chunk, pos); + pos += chunk.length; + } + return gzipped; +} + +const OPAL_WEBHOOK_URL = Deno.env.get("OPAL_WEBHOOK_URL"); +const OPAL_CLIENT_TOKEN = Deno.env.get("OPAL_CLIENT_TOKEN"); + +function notifyOpal(): void { + console.log("Notifying Opal of policy change...", OPAL_WEBHOOK_URL); + const headers: HeadersInit = {}; + if (OPAL_CLIENT_TOKEN) { + headers["Authorization"] = `Bearer ${OPAL_CLIENT_TOKEN}`; + } + fetch(OPAL_WEBHOOK_URL, { method: "POST", headers }).catch((err) => { + console.error("Failed to notify Opal of policy change", err); + }); +} + +await Deno.mkdir("./data", { recursive: true }); + +const db = new DB("./data/sqlite.db"); + +db.execute(` + CREATE TABLE IF NOT EXISTS policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package TEXT NOT NULL UNIQUE, + policy TEXT NOT NULL + ) +`); + +Deno.addSignalListener("SIGTERM", () => { + db.close(); + Deno.exit(0); +}); + +const handler = async (req: Request): Promise => { + const url = new URL(req.url); + const path = url.pathname; + + if (req.method === "GET" && path === "/policies") { + const rows = db.query<[number, string, string]>( + "SELECT id, package, policy FROM policies", + ); + return Response.json( + rows.map(([id, pkg, policy]) => ({ id, package: pkg, policy })), + ); + } + + if (req.method === "POST" && path === "/policies") { + const body = await req.json(); + const { package: pkg, policy } = body; + db.query("INSERT INTO policies (package, policy) VALUES (?, ?)", [ + pkg, + policy, + ]); + const id = db.lastInsertRowId; + notifyOpal(); + return Response.json({ id, package: pkg, policy }, { status: 201 }); + } + + const policyMatch = path.match(/^\/policies\/(.+)$/); + + if (req.method === "GET" && policyMatch) { + const pkg = decodeURIComponent(policyMatch[1]); + const rows = db.query<[number, string]>( + "SELECT id, policy FROM policies WHERE package = ?", + [pkg], + ); + if (rows.length === 0) return new Response("Not Found", { status: 404 }); + const [id, policy] = rows[0]; + return Response.json({ id, package: pkg, policy }); + } + + if (req.method === "PUT" && policyMatch) { + const pkg = decodeURIComponent(policyMatch[1]); + const body = await req.json(); + const { policy } = body; + db.query( + `INSERT INTO policies (package, policy) VALUES (?, ?) + ON CONFLICT(package) DO UPDATE SET policy = excluded.policy`, + [pkg, policy], + ); + const rows = db.query<[number]>( + "SELECT id FROM policies WHERE package = ?", + [pkg], + ); + const id = rows[0][0]; + notifyOpal(); + return Response.json({ id, package: pkg, policy }); + } + + if (req.method === "DELETE" && policyMatch) { + const pkg = decodeURIComponent(policyMatch[1]); + db.query("DELETE FROM policies WHERE package = ?", [pkg]); + notifyOpal(); + return new Response(null, { status: 204 }); + } + + if (req.method === "GET" && path === "/bundle.tar.gz") { + const rows = db.query<[string, string]>( + "SELECT package, policy FROM policies", + ); + const policies = rows.map(([pkg, policy]) => ({ pkg, policy })); + const bundle = await createOpalBundle(policies); + const digest = await crypto.subtle.digest("SHA-256", bundle); + const etag = `"${Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}"`; + + console.log("Serving bundle with ETag:", etag); + + return new Response(bundle, { + headers: { + "Content-Type": "application/gzip", + "Content-Disposition": 'attachment; filename="bundle.tar.gz"', + ETag: etag, + }, + }); + } + + return new Response("Not Found", { status: 404 }); +}; + +console.log("Listening on port 8000"); +Deno.serve({ port: 8000 }, handler); diff --git a/sdx/ape/functions/pubsub-kafka/README.md b/sdx/ape/functions/pubsub-kafka/README.md new file mode 100644 index 0000000..bfd0352 --- /dev/null +++ b/sdx/ape/functions/pubsub-kafka/README.md @@ -0,0 +1,40 @@ +# PubSub Kafka + +## Design decisions + +- Unique group ID per connection (kafka-controller-) so each client independently receives all partitions rather than competing for them +- fromBeginning: false so it only streams messages arriving after the connection opens +- Client disconnect is detected via req.signal which triggers consumer cleanup +- Errors are sent as event: error SSE frames before closing +- have the get messages take a query parameter to specify how many historical messages to return, + rather than unspecified where it brings new ones only + +### node-rdkafka + +``` +node-rdkafka ships native .node binaries (compiled C++), and Deno 2.x + doesn't support loading those regardless of flags. +``` + +## Development + +```sh +KAFKA_BROKERS=kafka:9092 deno run \ + --allow-net --allow-env main.ts +``` + +## Deployment + +```sh +helm upgrade --install pubsub-kafka \ + --set fullnameOverride=pubsub-kafka \ + -f chart.yaml \ + --set-file "config[0].contents=main.ts" \ +bcgov/generic-api +``` + +Testing: + +```sh +curl -v http://pubsub-kafka/localhost/messages +``` diff --git a/sdx/ape/functions/pubsub-kafka/chart.yaml b/sdx/ape/functions/pubsub-kafka/chart.yaml new file mode 100644 index 0000000..d1f949c --- /dev/null +++ b/sdx/ape/functions/pubsub-kafka/chart.yaml @@ -0,0 +1,31 @@ +replicaCount: 1 + +rollingUpdate: + maxUnavailable: 100% + maxSurge: 100% + +image: + repository: denoland/deno + tag: 2.7.10 + pullPolicy: IfNotPresent + +persistence: + deno-dir: + mountPath: /deno-dir + +config: + - filename: main.ts + mountPath: /app/main.ts + +command: + - deno + +args: + - run + - --allow-net=:8000,kafka:9092,*.kafka-controller-headless.b8840c-dev.svc.cluster.local,*.kafka-controller-headless.b8840c-test.svc.cluster.local,*.kafka-controller-headless.b8840c-prod.svc.cluster.local + - --allow-env=KAFKA_BROKERS,NODE_ENV,KAFKAJS_* + - /app/main.ts + +env: + KAFKA_BROKERS: + value: kafka:9092 diff --git a/sdx/ape/functions/pubsub-kafka/main.ts b/sdx/ape/functions/pubsub-kafka/main.ts new file mode 100644 index 0000000..85c852c --- /dev/null +++ b/sdx/ape/functions/pubsub-kafka/main.ts @@ -0,0 +1,210 @@ +import { Kafka } from "npm:kafkajs@2.2.4"; + +const KAFKA_BROKERS = (Deno.env.get("KAFKA_BROKERS") ?? "localhost:9092") + .split(",") + .map((b) => b.replace(/^https?:\/\//, "")) + .map((b) => (b.includes(":") ? b : `${b}:443`)); + +const GROUP_ID_PREFIX = "kafka-controller"; + +const kafka = new Kafka({ brokers: KAFKA_BROKERS, ssl: false }); + +const producer = kafka.producer(); +await producer.connect(); + +const encoder = new TextEncoder(); + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +Deno.serve({ port: 8000 }, async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const url = new URL(req.url); + const match = url.pathname.match(/^\/([^/]+)\/messages$/); + + if (!match) { + return new Response("Not Found", { status: 404, headers: CORS_HEADERS }); + } + + const topic = decodeURIComponent(match[1]); + + if (req.method === "POST") { + let body: { value?: unknown; key?: string; headers?: Record }; + try { + body = await req.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + const value = body.value !== undefined ? JSON.stringify(body.value) : null; + const key = body.key ?? null; + const headers = body.headers + ? Object.fromEntries( + Object.entries(body.headers).map(([k, v]) => [k, String(v)]), + ) + : undefined; + + try { + const result = await producer.send({ + topic, + messages: [{ key, value, headers }], + }); + return new Response(JSON.stringify(result[0]), { + status: 202, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + } + + if (req.method !== "GET") { + return new Response("Method Not Allowed", { status: 405, headers: CORS_HEADERS }); + } + + let resolvedTopic = topic; + const isWildcard = resolvedTopic.includes("*"); + const groupId = `${GROUP_ID_PREFIX}-${crypto.randomUUID()}`; + const consumer = kafka.consumer({ groupId }); + + if (isWildcard) { + resolvedTopic = "/" + resolvedTopic + "/"; // treat as "contains" if it has a wildcard + } + + const historyParam = url.searchParams.get("history"); + const historyCount = historyParam ? Math.max(0, parseInt(historyParam, 10) || 0) : 0; + + const seekTargets: Array<{ topic: string; partition: number; offset: string }> = []; + + try { + if (historyCount > 0 && !isWildcard) { + const admin = kafka.admin(); + await admin.connect(); + try { + const offsets = await admin.fetchTopicOffsets(topic); + for (const o of offsets) { + const high = parseInt(o.offset ?? "0", 10); + const start = Math.max(0, high - historyCount); + seekTargets.push({ topic, partition: o.partition, offset: start.toString() }); + } + } finally { + await admin.disconnect().catch(() => {}); + } + } + + await consumer.connect(); + await consumer.subscribe({ topic: resolvedTopic, fromBeginning: false }); + } catch (err) { + await consumer.disconnect().catch(() => {}); + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + let pingInterval: ReturnType; + + const body = new ReadableStream({ + start(controller) { + // Flush the stream open immediately so proxies don't buffer + controller.enqueue(encoder.encode(`: connected to ${resolvedTopic}\n\n`)); + + // Keepalive so the connection doesn't time out between messages + pingInterval = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: ping\n\n`)); + } catch { + clearInterval(pingInterval); + } + }, 15_000); + + consumer + .run({ + eachMessage: async ({ message }) => { + const data = { + offset: message.offset, + timestamp: message.timestamp, + key: message.key?.toString() ?? null, + value: (() => { + try { + return JSON.parse(message.value?.toString() ?? "null"); + } catch { + return message.value?.toString() ?? null; + } + })(), + headers: Object.fromEntries( + Object.entries(message.headers ?? {}).map(([k, v]) => [ + k, + v?.toString(), + ]), + ), + }; + try { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(data)}\n\n`), + ); + } catch { + // stream already closed (client disconnected) + } + }, + }) + .catch((err) => { + clearInterval(pingInterval); + const message = err instanceof Error ? err.message : String(err); + try { + controller.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ error: message })}\n\n`, + ), + ); + controller.close(); + } catch { + /* already closed */ + } + }); + + if (seekTargets.length > 0) { + consumer.on(consumer.events.GROUP_JOIN, () => { + for (const target of seekTargets) { + try { + consumer.seek(target); + } catch { + // partition not assigned to this consumer; ignore + } + } + }); + } + }, + cancel() { + clearInterval(pingInterval); + consumer.disconnect().catch(() => {}); + }, + }); + + return new Response(body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...CORS_HEADERS, + }, + }); +}); + +console.log("Listening on http://localhost:8000"); +console.log("GET /{topic}/messages[?history=N] β€” SSE stream (replay last N messages, then live)"); +console.log("POST /{topic}/messages β€” publish a message to the topic"); diff --git a/sdx/ape/functions/pubsub-webhook/README.md b/sdx/ape/functions/pubsub-webhook/README.md new file mode 100644 index 0000000..238c3a6 --- /dev/null +++ b/sdx/ape/functions/pubsub-webhook/README.md @@ -0,0 +1,52 @@ +# pubsub-webhook + +## AI Prompt + +- Look for files in current directory only +- All code in single `main.ts` TypeScript file +- Use Deno runtime +- Use `jsr:@std/yaml` for YAML handling +- No environment variables unless explicitely mentioned in requirements +- Listen for SIGTERM and call deno exit +- Serve on port 8000 + +Database specs: + +- Use SQLite database (`https://deno.land/x/sqlite`) +- Create database if it doesn't exist +- No environment variables for sqlite +- Database location: `./data/sqlite.db` + +## Requirements + +- want endpoints that provide CRUD for: Webhooks (conn_id:string, topic:string, webhook_url: string) +- on a configurable interval have it retrieve all the webhook records, get the topics and use npm:kafkajs to subscribe to all of the topics +- there will be multiple instances of this service, so it should only process each message once +- at the end of the interval gracefully close the connection before starting the next cycle again + +## Running the API + +```sh +deno run --no-prompt --allow-net --allow-read --allow-env --allow-write main.ts +``` + +### Testing + +```sh +-- add a webhook +restish PUT http://localhost:8000/webhooks \ + 'conn_id: 10, webhook_url: "http://localhost/go", topic: "abc"' + +-- list webhooks +restish GET http://localhost:8000/webhooks +``` + +## Deployment + +```sh +helm upgrade --install pubsub-webhook \ + --set fullnameOverride=pubsub-webhook \ + -f chart.yaml \ + --set-file "config[0].contents=main.ts" \ +bcgov/generic-api +``` diff --git a/sdx/ape/functions/pubsub-webhook/chart.yaml b/sdx/ape/functions/pubsub-webhook/chart.yaml new file mode 100644 index 0000000..5b476fe --- /dev/null +++ b/sdx/ape/functions/pubsub-webhook/chart.yaml @@ -0,0 +1,42 @@ +replicaCount: 1 + +rollingUpdate: + maxUnavailable: 100% + maxSurge: 100% + +image: + repository: denoland/deno + tag: 2.7.10 + pullPolicy: IfNotPresent + +persistence: + deno-dir: + mountPath: /deno-dir + data: + size: 5Mi + mountPath: /data + storageAccessMode: ReadWriteOnce + storageClassName: netapp-file-standard + +config: + - filename: main.ts + mountPath: /app/main.ts + +command: + - deno + +args: + - run + - --allow-net=:8000,kafka:9092,*.kafka-controller-headless.b8840c-dev.svc.cluster.local,*.webhook.cool:443 + - --allow-env=KAFKA_BROKERS,KAFKA_GROUP_ID,INTERVAL_SECONDS,NODE_ENV,KAFKAJS_* + - --allow-write=/data + - --allow-read=/data + - /app/main.ts + +env: + PUBSUB_KAFKA_URL: + value: http://pubsub-kafka + INTERVAL_SECONDS: + value: "60" + KAFKA_GROUP_ID: + value: pubsub-webhooks-group diff --git a/sdx/ape/functions/pubsub-webhook/main.ts b/sdx/ape/functions/pubsub-webhook/main.ts new file mode 100644 index 0000000..db1a6a9 --- /dev/null +++ b/sdx/ape/functions/pubsub-webhook/main.ts @@ -0,0 +1,233 @@ +import { DB } from "https://deno.land/x/sqlite@v3.9.1/mod.ts"; +import { Kafka } from "npm:kafkajs"; +import type { Consumer } from "npm:kafkajs"; + +interface Webhook { + id: number; + conn_id: string; + topic: string; + webhook_url: string; +} + +const INTERVAL_SECONDS = parseInt(Deno.env.get("INTERVAL_SECONDS") ?? "60"); +const KAFKA_BROKERS = (Deno.env.get("KAFKA_BROKERS") ?? "kafka:9092").split( + ",", +); +const KAFKA_GROUP_ID = + Deno.env.get("KAFKA_GROUP_ID") ?? "pubsub-webhooks-group"; + +function setupDatabase(): DB { + Deno.mkdirSync("./data", { recursive: true }); + const db = new DB("./data/sqlite.db"); + db.execute(` + CREATE TABLE IF NOT EXISTS webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conn_id TEXT NOT NULL UNIQUE, + topic TEXT NOT NULL, + webhook_url TEXT NOT NULL + ) + `); + // migration: add unique index for existing databases created before UNIQUE was in the schema + db.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_webhooks_conn_id ON webhooks (conn_id)`, + ); + return db; +} + +const db = setupDatabase(); + +async function handler(req: Request): Promise { + const { pathname } = new URL(req.url); + + try { + if (pathname === "/webhooks") { + if (req.method === "GET") { + const rows = db.queryEntries("SELECT * FROM webhooks"); + return Response.json(rows); + } + + if (req.method === "PUT") { + const body = await req.json(); + const { conn_id, topic, webhook_url } = body; + if (!conn_id || !topic || !webhook_url) { + return Response.json( + { error: "conn_id, topic, and webhook_url are required" }, + { status: 400 }, + ); + } + db.query( + `INSERT INTO webhooks (conn_id, topic, webhook_url) + VALUES (?, ?, ?) + ON CONFLICT(conn_id) DO UPDATE SET + topic = excluded.topic, + webhook_url = excluded.webhook_url`, + [conn_id, topic, webhook_url], + ); + const [row] = db.queryEntries( + "SELECT * FROM webhooks WHERE conn_id = ?", + [conn_id], + ); + return Response.json(row); + } + + if (req.method === "POST") { + const body = await req.json(); + const { conn_id, topic, webhook_url } = body; + if (!conn_id || !topic || !webhook_url) { + return Response.json( + { error: "conn_id, topic, and webhook_url are required" }, + { status: 400 }, + ); + } + db.query( + "INSERT INTO webhooks (conn_id, topic, webhook_url) VALUES (?, ?, ?)", + [conn_id, topic, webhook_url], + ); + const [row] = db.queryEntries( + "SELECT * FROM webhooks WHERE id = ?", + [db.lastInsertRowId], + ); + return Response.json(row, { status: 201 }); + } + } + + const idMatch = pathname.match(/^\/webhooks\/(\d+)$/); + if (idMatch) { + const id = parseInt(idMatch[1]); + + if (req.method === "GET") { + const [row] = db.queryEntries( + "SELECT * FROM webhooks WHERE id = ?", + [id], + ); + if (!row) return Response.json({ error: "Not found" }, { status: 404 }); + return Response.json(row); + } + + if (req.method === "PUT") { + const [existing] = db.queryEntries( + "SELECT * FROM webhooks WHERE id = ?", + [id], + ); + if (!existing) + return Response.json({ error: "Not found" }, { status: 404 }); + const body = await req.json(); + const conn_id = body.conn_id ?? existing.conn_id; + const topic = body.topic ?? existing.topic; + const webhook_url = body.webhook_url ?? existing.webhook_url; + db.query( + "UPDATE webhooks SET conn_id = ?, topic = ?, webhook_url = ? WHERE id = ?", + [conn_id, topic, webhook_url, id], + ); + const [updated] = db.queryEntries( + "SELECT * FROM webhooks WHERE id = ?", + [id], + ); + return Response.json(updated); + } + + if (req.method === "DELETE") { + const [existing] = db.queryEntries( + "SELECT * FROM webhooks WHERE id = ?", + [id], + ); + if (!existing) + return Response.json({ error: "Not found" }, { status: 404 }); + db.query("DELETE FROM webhooks WHERE id = ?", [id]); + return new Response(null, { status: 204 }); + } + } + } catch (err) { + console.error("Handler error:", err); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } + + return Response.json({ error: "Not found" }, { status: 404 }); +} + +let consumer: Consumer | null = null; + +async function runCycle() { + if (consumer) { + try { + await consumer.disconnect(); + } catch { + /* ignore disconnect errors */ + } + consumer = null; + } + + const rows = db.queryEntries("SELECT * FROM webhooks"); + const topics = [...new Set(rows.map((r) => r.topic))]; + + if (topics.length === 0) return; + + const kafka = new Kafka({ brokers: KAFKA_BROKERS }); + consumer = kafka.consumer({ groupId: KAFKA_GROUP_ID }); + + try { + await consumer.connect(); + await consumer.subscribe({ topics, fromBeginning: false }); + + await consumer.run({ + eachMessage: async ({ topic, message }) => { + const webhooks = db.queryEntries( + "SELECT * FROM webhooks WHERE topic = ?", + [topic], + ); + const payload = message.value?.toString() ?? "{}"; + await Promise.allSettled( + webhooks.map((w) => { + console.log( + "Triggering webhook", + w.webhook_url, + "for topic", + topic, + ); + fetch(w.webhook_url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }); + }), + ); + }, + }); + } catch (err) { + console.error( + "Kafka error, will retry next cycle:", + err instanceof Error ? err.message : err, + ); + try { + await consumer.disconnect(); + } catch { + /* ignore */ + } + consumer = null; + } +} + +const server = Deno.serve({ port: 8000 }, handler); + +runCycle().catch((err) => + console.error( + "Initial cycle error:", + err instanceof Error ? err.message : err, + ), +); +const intervalId = setInterval(runCycle, INTERVAL_SECONDS * 1000); + +Deno.addSignalListener("SIGTERM", async () => { + console.log("Received SIGTERM, shutting down..."); + clearInterval(intervalId); + if (consumer) { + try { + await consumer.disconnect(); + } catch { + /* ignore */ + } + } + db.close(); + await server.shutdown(); + Deno.exit(0); +}); diff --git a/sdx/ape/gwa/README.md b/sdx/ape/gwa/README.md new file mode 100644 index 0000000..ff28257 --- /dev/null +++ b/sdx/ape/gwa/README.md @@ -0,0 +1,19 @@ +# Gateway configuration + +### API gateway for OPAL services + +There are four services that have been introduced to support a policy engine. + +New APS Gateway in DEV `gw-16a07` (on Gold cluster) + +- DNS `opal.api.gov.bc.ca` -> http://opal-server:7002 +- DNS `opal-pip-catalog.api.gov.bc.ca` -> http://opal-pip-catalog +- DNS `opal-policies.api.gov.bc.ca` -> http://opal-policies +- `opal-client.api.gov.bc.ca` -> http://opal-client + +Gateway configuration at: `gw-config.yaml` + +- https://opal-api-gov-bc-ca.dev.api.gov.bc.ca/ +- https://opal-pip-catalog-api-gov-bc-ca.dev.api.gov.bc.ca/entries +- https://opal-policies-api-gov-bc-ca.dev.api.gov.bc.ca/tuples +- https://opal-client-api-gov-bc-ca.dev.api.gov.bc.ca/ diff --git a/sdx/ape/gwa/gw-config.yaml b/sdx/ape/gwa/gw-config.yaml new file mode 100644 index 0000000..bbf177b --- /dev/null +++ b/sdx/ape/gwa/gw-config.yaml @@ -0,0 +1,55 @@ +kind: GatewayService +name: opal-server +url: http://opal-server:7002 +tags: [ns.gw-16a07] +routes: + - name: opal-server + tags: [ns.gw-16a07] + hosts: + - opal.api.gov.bc.ca + protocols: + - http + - https + strip_path: false +--- +kind: GatewayService +name: opal-pip-catalog +url: http://opal-pip-catalog +tags: [ns.gw-16a07] +routes: + - name: opal-pip-catalog + tags: [ns.gw-16a07] + hosts: + - opal-pip-catalog.api.gov.bc.ca + protocols: + - http + - https + strip_path: false +--- +kind: GatewayService +name: opal-policies +url: http://opal-policies +tags: [ns.gw-16a07] +routes: + - name: opal-policies + tags: [ns.gw-16a07] + hosts: + - opal-policies.api.gov.bc.ca + protocols: + - http + - https + strip_path: false +--- +kind: GatewayService +name: opal-client +url: http://opal-test-opal-client:8181 +tags: [ns.gw-16a07] +routes: + - name: opal-client + tags: [ns.gw-16a07] + hosts: + - opal-client.api.gov.bc.ca + protocols: + - http + - https + strip_path: false diff --git a/sdx/ape/opal-client/README.md b/sdx/ape/opal-client/README.md new file mode 100644 index 0000000..83d3438 --- /dev/null +++ b/sdx/ape/opal-client/README.md @@ -0,0 +1,29 @@ +# opal-server + +## Installation + +### Prerequisites + +1. Install the `opal-client` secret + +```sh +restish POST https://opal-api-gov-bc-ca.dev.api.gov.bc.ca/token \ + -H "Authorization: Bearer $MASTER_TOKEN" \ + type: client +``` + +```sh +export CLIENT_TOKEN="" +kubectl create secret --namespace b8840c-dev \ + --save-config --dry-run=client -o yaml \ + generic opal-client \ + --from-literal=OPAL_CLIENT_TOKEN=$CLIENT_TOKEN | kubectl apply -f - +``` + +### Install + +```sh +helm upgrade --install opal-test \ + -f ./values.yaml \ + permitio/opal +``` diff --git a/sdx/ape/opal-client/values.yaml b/sdx/ape/opal-client/values.yaml new file mode 100644 index 0000000..2f0ec19 --- /dev/null +++ b/sdx/ape/opal-client/values.yaml @@ -0,0 +1,29 @@ +openshift: + enabled: true + securityContext: + runAsUser: 1001610000 + runAsGroup: 1001610000 + fsGroup: 1001610000 + containerSecurityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + +server: + enabled: false + +client: + enabled: true + extraEnv: + OPAL_DATA_TOPICS: "policy_data,tenant_data" + OPAL_DATA_UPDATER_ENABLED: "true" + #OPAL_SERVER_URL: "https://opal-api-gov-bc-ca.dev.api.gov.bc.ca" + OPAL_SERVER_WS_URL: "wss://opal-api-gov-bc-ca.dev.api.gov.bc.ca" + OPAL_LOG_COLORIZE: "false" + + serverUrl: "https://opal-api-gov-bc-ca.dev.api.gov.bc.ca" + secrets: + - opal-client + +image: + client: + tag: "0.9.5" diff --git a/sdx/ape/opal-server/README.md b/sdx/ape/opal-server/README.md new file mode 100644 index 0000000..94c6800 --- /dev/null +++ b/sdx/ape/opal-server/README.md @@ -0,0 +1,41 @@ +# opal-server + +## Installation + +### Prerequisites + +1. Install the `opal-server` secret + +`ssh-keygen -t rsa -b 4096 -m pem` + +In Vault, store: + +- opal-master-token +- opal-encrypt-key +- opal-encrypt-crt + +```sh +export MASTER_TOKEN="" +kubectl create secret --namespace b8840c-dev \ + --save-config --dry-run=client -o yaml \ + generic opal-server \ + --from-literal=OPAL_AUTH_MASTER_TOKEN=$MASTER_TOKEN \ + --from-file=OPAL_AUTH_PUBLIC_KEY=./id_rsa.pub \ + --from-file=OPAL_AUTH_PRIVATE_KEY=./id_rsa | kubectl apply -f - +``` + +### Install + +```sh +helm upgrade --install opal \ + -f ./values.yaml \ + permitio/opal +``` + +### Getting a token for a data source + +```sh +restish POST https://opal-api-gov-bc-ca.dev.api.gov.bc.ca/token \ + -H "Authorization: Bearer $MASTER_TOKEN" \ + 'type: datasource, claims.client_id: share0' +``` diff --git a/sdx/ape/opal-server/values.yaml b/sdx/ape/opal-server/values.yaml new file mode 100644 index 0000000..a52c738 --- /dev/null +++ b/sdx/ape/opal-server/values.yaml @@ -0,0 +1,53 @@ +openshift: + enabled: true + securityContext: + runAsUser: 1001610000 + runAsGroup: 1001610000 + fsGroup: 1001610000 + containerSecurityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + +server: + policyRepoUrl: "" + policyRepoSshKey: null + policyRepoClonePath: "/tmp/opal/policy" + policyRepoMainBranch: master + pollingInterval: 30 + + dataConfigSources: + # Option #1 - No data sources + # config: + # entries: [] + + # Option #2 - Dynamically get data sources + external_source_url: "https://opal-pip-catalog-api-gov-bc-ca.dev.api.gov.bc.ca/entries" + + # Option #3 - Example static data sources (endpoint is empty by default) + # config: + # entries: + # - url: http://opal-server:7002/policy-data + # topics: ["policy_data"] + # dst_path: "/static" + + # Option #4 - Leave config empty and instead supply using the OPAL_DATA_CONFIG_SOURCES environment variable through env or secret + config: null + + extraEnv: + OPAL_ALL_DATA_URL: "http://opal-server:7002/policy-data" + OPAL_POLICY_SOURCE_TYPE: "API" + OPAL_POLICY_BUNDLE_URL: http://opal-policies + OPAL_POLICY_REPO_POLLING_INTERVAL: 30 + OPAL_LOG_LEVEL: DEBUG + OPAL_LOG_COLORIZE: "false" + OPAL_AUTH_JWT_ISSUER: "https://opal-api-gov-bc-ca.dev.api.gov.bc.ca" + + secrets: + - opal-server + +client: + enabled: false + +image: + server: + tag: "0.9.5" diff --git a/sdx/ape/policies/simple-get-only.rego b/sdx/ape/policies/simple-get-only.rego new file mode 100644 index 0000000..ceba4ed --- /dev/null +++ b/sdx/ape/policies/simple-get-only.rego @@ -0,0 +1,11 @@ +package lab_min_citz_sys0.authz + +import rego.v1 + +# Default deny everything +default allow := false + +# Allow GET requests +allow if { + input.method == "GET" +} \ No newline at end of file diff --git a/sdx/brochure-ui/.gitignore b/sdx/brochure-ui/.gitignore new file mode 100644 index 0000000..be7f633 --- /dev/null +++ b/sdx/brochure-ui/.gitignore @@ -0,0 +1,4 @@ +config*.yaml +chart.yaml +node_modules/ +dist/ \ No newline at end of file diff --git a/sdx/brochure-ui/README.md b/sdx/brochure-ui/README.md new file mode 100644 index 0000000..8aa3770 --- /dev/null +++ b/sdx/brochure-ui/README.md @@ -0,0 +1,82 @@ +# SDX Brochure + +## AI Prompt + +- Look for files in current directory only +- Starting code should be in `main.ts` TypeScript file +- Pages can be organized as separate pages in a pages folder +- Components common across pages can be created under a components folder +- Use the design components from https://www2.gov.bc.ca/gov/content/digital/design-system/components +- Use Deno runtime +- Use `jsr:@std/yaml` for YAML handling +- No environment variables +- Serve on port 8000 +- Use latest tailwindcss +- Use latest React version + +## Requirements + +- Create a static website that is structured the same way as https://liityntakatalogi.suomi.fi/en_GB +- on the home page, can you find a nice background image that represents a secure data exchange +- Subsystems will come from https://api-gov-bc-ca.dev.api.gov.bc.ca/ds/api/sdx/v1/catalog/subsystems +- Organizations come from https://api-gov-bc-ca.dev.api.gov.bc.ca/ds/api/sdx/v1/catalog/organizations +- Instructions and Support can just be a link to https://developer.gov.bc.ca/docs/default/component/aps-infra-platform-docs/concepts/secure-data-exchange/ +- Add a drill-down Organization detail page that mimics https://liityntakatalogi.suomi.fi/en_GB/organization/arek-oy +- Add a drill-down Subsystem detail page that mimics https://liityntakatalogi.suomi.fi/en_GB/dataset/prodpensionprovider +- Use Services from https://api-gov-bc-ca.dev.api.gov.bc.ca/ds/api/sdx/v1/catalog/services to provide the details about what services are available by a subsystem +- For the API service operations, can you group them by the tags and put the tags in alphabetical order +- for the operations, make sure that the METHOD is spaced so that the path is aligned the same as other rows +- within the tag group of operations, sort the operations by path +- The service description can be markdown - can you add markdown support on the description +- add a "copy to clipboard" for the sdx identifier +- make the "copy to clipboard" a component and include it for the service sdx identifier as well +- can the copy button be made more sutle where there is no "copy" text and it's just the icon within any borders and next to the text +- add an "Activity" page and on it use the pql_service_code.json to render the metric as a time-series graph +- add a selectable "refresh interval" (Off, 10s, 30s) +- after the refresh it resets to off -fix it so that stays on the setting before page refreshes +- for the services detail on the subsystem, there is a `"specVersion": "asyncapi=3.1.0"` property that can have a value of "openapi=xxx" or "asyncapi=xxx". Use this information to show whether it is an OpenAPI or AsyncAPI spec (and what version). And then provide the operations in a way that makes sense in a subscriber/producer model +- Create a new Trust page that gets the data from a JWKS registry (https://sdx.gov.bc.ca/.well-known/jwks.json) and outputs nice information about each JWK record. If the record has an x5c then display each cert in the chain, showing key data and show its validity. +- update the org details page so that the "subsystem" card has an indication of whether it is a + "client only" vs has related services. + +### Verification + +- add to the activity log detail a "verification" - which will show various verification statuses (perhaps with a "security shield checkmark/cross") + - put the display in its own component, and the logic in the lib folder + - the verification will use the request/response header "X-Edge-Token" and "X-Entity-Sig" + - "X-Entity-Sig" will use https://sdx.gov.bc.ca/keysets/sdx.org.min.citz/.well-known/jwks.json; the "sdx.org.min.citz" can be derived from the "X-Client-ID" in the case of it being in the request header, and service id if in response header. The Entity-Sig uses the signature of the X-Edge-Token JWT signature segment (3). Show an indication of result. + - X-Edge-Token can be checked by looking in the token for the jwks_url and using that to validate the token using one of the public keys. Show an indication of result. +- for x-edge-token verification, use the "jwks_uri" instead. For the X-Entity-Sig, map the + `client:LAB.MIN.CITZ.SDG-FE` with the format `client:...`, + to `sdx.org.min.citz`, which is "sdx.org.." +- for X-Entity-Sig validation, check against each key in the jwks +- for the verification, check both request and response for X-Entity-Sig and X-Edge-Token and report + on them. Also if the jwk has the x5c, validate the certificate chain and report on "cert chain + pass" +- for each X-Entity-Sig verification, show the "O" of the leaf if there is a cert chain + +## Running the Application + +```sh +deno run --allow-net --allow-read --allow-env --allow-write main.ts +``` + +## Deployment + +```sh +./scripts/build-chart.sh && \ +helm upgrade --install sdx-brochure \ + --set fullnameOverride=sdx-brochure \ + -f chart.yaml -f .values-dev.yaml -f config[0].contents=@config-dev.yaml \ +bcgov/generic-api +``` + +### Production + +```sh +./scripts/build-chart.sh && \ +helm upgrade --install sdx-brochure \ + --set fullnameOverride=sdx-brochure \ + -f chart.yaml -f .values-prod.yaml \ +bcgov/generic-api +``` diff --git a/sdx/brochure-ui/components/ActivityFeed.tsx b/sdx/brochure-ui/components/ActivityFeed.tsx new file mode 100644 index 0000000..57c49ff --- /dev/null +++ b/sdx/brochure-ui/components/ActivityFeed.tsx @@ -0,0 +1,330 @@ +import type { ActivityRecord } from "../types.ts"; + +interface ActivityFeedProps { + activity: ActivityRecord[]; + pageSize: number; + /** Endpoint the "Load more" button pages against. May include a query string. */ + apiPath?: string; +} + +const DIALOG_STYLE = ` +dialog.sdx-activity-dialog { border: none; border-radius: 8px; padding: 0; max-width: 720px; width: 92%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); } +dialog.sdx-activity-dialog::backdrop { background: rgba(0,0,0,0.45); } +`; + +// Rendered client-side so dates/times reflect the viewer's local timezone (the +// API returns UTC), paging appends via the configured API path, and blob +// details open in a dialog. +const FEED_SCRIPT = ` +(function(){ + var DATA = window.__ACTIVITY__ || []; + var PAGE = window.__ACTIVITY_PAGE_SIZE__ || 20; + var API = window.__ACTIVITY_API__ || '/api/activity'; + var listEl = document.getElementById('activity-list'); + var moreWrap = document.getElementById('activity-more-wrap'); + var moreBtn = document.getElementById('activity-more'); + var emptyEl = document.getElementById('activity-empty'); + var errEl = document.getElementById('activity-error'); + var dlg = document.getElementById('activity-detail'); + var dlgBlobWrap = document.getElementById('activity-detail-blob-wrap'); + var dlgBlob = document.getElementById('activity-detail-blob'); + var dlgParams = document.getElementById('activity-detail-params'); + var dlgTitle = document.getElementById('activity-detail-title'); + var skip = 0, lastKey = null, currentUl = null; + + var WARN_ICON = ''; + function isErrorResult(result){ + var r = String(result || '').toLowerCase(); + return r === 'failure' || r === 'failed' || r === 'error'; + } + + function resolveMessage(msg, params){ + return String(msg).replace(/\\{(\\w+)\\}/g, function(_, k){ + return params[k] !== undefined ? params[k] : '{' + k + '}'; + }); + } + function initials(actor){ + var t = (actor || '').trim(); + if(!t) return '?'; + if(t.indexOf(',') >= 0){ + var parts = t.split(','); + var last = parts[0].trim(); + var first = (parts[1] || '').trim().split(/\\s+/)[0] || ''; + return ((last[0] || '') + (first[0] || '')).toUpperCase(); + } + var w = t.split(/\\s+/); + if(w.length >= 2) return (w[0][0] + w[1][0]).toUpperCase(); + return t.slice(0,2).toUpperCase(); + } + function dateKey(d){ return d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate(); } + function dateHeader(d){ return d.toLocaleDateString(undefined, {year:'numeric', month:'long', day:'numeric'}); } + function timeLabel(d){ + var s = d.toLocaleTimeString(undefined, {hour:'numeric', minute:'2-digit'}); + return s.replace(/\\bAM\\b/, 'a.m.').replace(/\\bPM\\b/, 'p.m.'); + } + function detailParams(params){ + var skipKeys = {actor:1, action:1, accessAction:1, entity:1}; + var out = []; + for(var k in params){ + if(params.hasOwnProperty(k) && !skipKeys[k] && params[k] !== '' && params[k] != null) out.push([k, params[k]]); + } + return out; + } + function toYaml(blob){ + var obj = blob; + if(typeof blob === 'string'){ + // The detail blob is JSON; parse it so it can be re-emitted as YAML. + try { obj = JSON.parse(blob); } + catch(_) { return blob; } // not JSON β€” show the raw string unchanged + } + try { + if(window.jsyaml && window.jsyaml.dump){ + return window.jsyaml.dump(obj, {noRefs:true, lineWidth:100, sortKeys:false}); + } + } catch(_) {} + return JSON.stringify(obj, null, 2); + } + function openDialog(record){ + var params = record.params || {}; + dlgTitle.textContent = resolveMessage(record.message, params); + if(record.blob !== undefined && record.blob !== null){ + // Reset so highlight.js (which marks elements as already-highlighted) re-runs. + dlgBlob.removeAttribute('data-highlighted'); + dlgBlob.className = 'language-yaml whitespace-pre-wrap break-all'; + dlgBlob.textContent = toYaml(record.blob); + try { if(window.hljs) window.hljs.highlightElement(dlgBlob); } catch(_) {} + dlgBlobWrap.style.display = ''; + } else { + dlgBlob.textContent = ''; + dlgBlobWrap.style.display = 'none'; + } + dlgParams.innerHTML = ''; + var dps = detailParams(params); + if(dps.length){ + for(var i=0;i= 0){ + p.appendChild(document.createTextNode(text.slice(0, idx))); + var st = document.createElement('strong'); st.className = 'font-semibold'; st.textContent = verb; p.appendChild(st); + p.appendChild(document.createTextNode(text.slice(idx + verb.length))); + } else { p.appendChild(document.createTextNode(text)); } + top.appendChild(p); + var hasBlob = record.blob !== undefined && record.blob !== null; + if(hasBlob || detailParams(params).length){ + var btn = document.createElement('button'); btn.type = 'button'; + btn.className = 'text-xs text-[#003366] font-semibold hover:underline'; + btn.textContent = 'More details'; + (function(rec){ btn.addEventListener('click', function(){ openDialog(rec); }); })(record); + top.appendChild(btn); + } + body.appendChild(top); + var tm = document.createElement('p'); tm.className = 'text-sm text-gray-500 mt-0.5 tabular-nums'; + tm.textContent = timeLabel(new Date(record.activityAt)); + body.appendChild(tm); + li.appendChild(body); + return li; + } + function append(records){ + for(var i=0;i= PAGE) ? '' : 'none'; } + + if(!DATA.length){ emptyEl.style.display = ''; moreWrap.style.display = 'none'; } + else { append(DATA); skip = DATA.length; setMore(DATA.length); } + + if(moreBtn){ + moreBtn.addEventListener('click', function(){ + moreBtn.disabled = true; moreBtn.textContent = 'Loading…'; + var sep = API.indexOf('?') >= 0 ? '&' : '?'; + fetch(API + sep + 'first=' + PAGE + '&skip=' + skip, {headers:{'accept':'application/json'}}) + .then(function(res){ if(!res.ok) throw new Error('HTTP ' + res.status); return res.json(); }) + .then(function(recs){ + recs = Array.isArray(recs) ? recs : []; + append(recs); skip += recs.length; setMore(recs.length); + moreBtn.disabled = false; moreBtn.textContent = 'Load more'; + }) + .catch(function(e){ + errEl.textContent = 'Could not load more activity: ' + e.message; errEl.style.display = ''; + moreBtn.disabled = false; moreBtn.textContent = 'Load more'; + }); + }); + } + document.addEventListener('click', function(e){ + var c = e.target.closest && e.target.closest('[data-close-activity]'); + if(c){ e.preventDefault(); if(typeof dlg.close === 'function') dlg.close(); else dlg.removeAttribute('open'); } + }); +})(); +`; + +/** + * Client-rendered activity feed: a day-grouped list with a "Load more" button + * and a details dialog. Shared by the public catalogue Activity page and the + * Member Console org-scoped Activity page; `apiPath` selects the paging + * endpoint. + */ +export function ActivityFeed({ + activity, + pageSize, + apiPath = "/api/activity", +}: ActivityFeedProps) { + const dataJson = JSON.stringify(activity).replace( + / + {/* Vendored, self-hosted libraries (no third-party runtime dependency): + js-yaml renders the JSON detail blob as YAML, highlight.js styles it. */} + +