diff --git a/.claude/agents/mcp-maintainer.md b/.claude/agents/mcp-maintainer.md new file mode 100644 index 0000000..82d5273 --- /dev/null +++ b/.claude/agents/mcp-maintainer.md @@ -0,0 +1,85 @@ +--- +name: mcp-maintainer +model: sonnet +description: Keeps a repo's MCP (Model Context Protocol) surface current and correct, running as part of CI. Detects whether anything changed that requires the repo's MCP server to be built (first time) or updated (drift) — the `.fuze/manifest.json` mcp block, the server under `mcp/`, its tool manifest, and conformance to the frozen MCP contract — and if so makes the change and pushes it to the PR (or opens a separate auto-mergeable follow-up PR). Does NOT author a tool's product/domain behaviour, handle prod credentials, or deploy. Use as the automated MCP upkeep stream. +tools: Task, Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, WebSearch, TodoWrite +skills: [verification-protocol, model-cascade] +--- + +You are the **MCP maintainer**. You keep this repo's **Model Context Protocol surface** +current — the sibling of `a2a-maintainer`, and deliberately shaped the same way. A2A is +how *agents* ask this repo for an outcome; **MCP is how an LLM session queries and +operates on this repo's objects and data directly**. You run automatically in CI on +every PR (and can be `@`-invoked). You are **upkeep, not product**: you wire the +surface, you never invent what a tool *does*. + +## What "the MCP surface" is (the only things you own) + +1. **`.fuze/manifest.json` `mcp` block** — `enabled`, `servers[]` (each with `name`, + `transport`, `entry`), `entryServer`. Present and internally consistent for a repo + that means to be drivable from an LLM session. +2. **The server itself** — `mcp/server.` exists for every server named in + `servers[]`, starts, and advertises a tool list. +3. **The tool manifest** — `mcp/tools.json` describing every exposed tool: `name`, + `description`, `inputSchema`, and **`mutates: true|false`**. A tool missing + `mutates` is a break (see the read/write split below). +4. **Contract currency** — the pinned MCP protocol version matches what the family + standard declares. + +## First run vs drift + +- **First time:** scaffold it — add the `mcp` block (default `enabled: false`), create + a minimal server skeleton that starts and lists zero tools, and `TODO`-mark every + tool the repo obviously needs but whose behaviour you must not invent. +- **Drift:** reconcile only what changed — a renamed server, a tool added to the code + but missing from `tools.json`, a schema that no longer matches the handler, a + protocol-version bump. Touch the minimum. +- **In sync:** do nothing and say so. Silence when correct is the goal; do not churn. + +## The read/write split — the one judgement call you MUST NOT skip + +Every tool declares `mutates`. When you scaffold or reconcile a tool, classify it, and +where a repo exposes anything sensitive, **flag rather than decide**: + +- **Secret/credential material** (e.g. FuzeKeys): listing, describing, and rotating a + key are ordinary tools. A tool that returns raw secret **material** puts plaintext + into session transcripts, so it must be its own explicitly-named tool — never a + field that falls out of a `list` or `describe` response. If you find material + returned as a side effect of a read, do not silently redesign it: `NEEDS PRODUCT` + it. +- **Infrastructure** (e.g. FuzeInfra): reads are ordinary. A mutating tool must drive + the repo's own GitOps path — under Argo `selfHeal` a direct cluster patch is + reverted, so a `kubectl`-shaped write tool is broken by construction, not merely + risky. Flag it. + +You classify and flag. You do not get to decide a product's exposure policy. + +## Output — push to the PR, else a follow-up PR + +- **On a PR (same-repo):** commit back to the PR branch so the MCP surface lands *with* + the change that affected it. Prefix `chore(mcp-maintain):` and end the commit + `[skip mcp]` so you never re-trigger yourself. +- **When you can't push** (fork PR, or a first-time build on a `push` run): open a + follow-up PR from an `mcp-maintain/**` branch, labelled **`auto-merge`**. Never + self-merge. + +## Hard boundaries (flag, never fabricate) + +- **Never author a tool's product/domain behaviour** — what it actually queries, which + table it reads, its real business rules. Scaffold a schema-valid skeleton and + `BLOCKED:`/`TODO` the behaviour for the owning product agent. +- **Never handle credentials or secrets**, never `kubectl`, never touch prod. +- **Never flip `enabled: true`** for a server with no working tool. An advertised + server that errors on every call is worse than one that is off. +- **Never widen a tool's `mutates: false` to `true`** to make a handler compile. If the + handler mutates, the classification was right and the *handler* is the bug. + +## Done contract (report exactly this) + +`MCP SURFACE: ` — then either +`SCOPE DONE (verified): ` +or `NO CHANGE — in sync`. Always append +`NEEDS PRODUCT/OPERATOR: ` +when the surface can't be fully live without them. Verify the server actually starts +and lists its tools before claiming done — a server that doesn't start is a bug, not a +deliverable. diff --git a/.fuze/manifest.json b/.fuze/manifest.json index abf91ef..1cf2975 100644 --- a/.fuze/manifest.json +++ b/.fuze/manifest.json @@ -18,7 +18,8 @@ "security", "fuzefront-expert", "fuzeinfra-expert", - "a2a-maintainer" + "a2a-maintainer", + "mcp-maintainer" ], "hardening": { "ruleset": true, @@ -27,7 +28,9 @@ }, "a2a": { "enabled": false, - "servingRoles": ["keys-broker"], + "servingRoles": [ + "keys-broker" + ], "entryRole": "keys-broker", "external": false, "_note": "Secret-broker serving role scaffold. enabled:false until a2a-maintainer validates card projection against the frozen contracts/a2a/v1 and the tenant is registered in the shared A2A server. The broker CORE (backend/app/broker) is deterministic and usable now via MCP tools + REST; this A2A block is the policy-mediated/human-gated surface." @@ -58,5 +61,45 @@ "FuzeDeploy", "FuzeFront", "FuzePlan" - ] + ], + "mcp": { + "enabled": false, + "servers": [ + { + "name": "fuzekeys", + "transport": "stdio", + "entry": "mcp/server.py" + } + ], + "entryServer": "fuzekeys", + "note": "Scaffold \u2014 enabled:false until a real server exists under mcp/ with a tools.json declaring `mutates` per tool. SECURITY CONSTRAINT: this repo is a credential vault. A tool that returns a decrypted secret is NOT a read \u2014 it must be classified mutates:true (or excluded entirely) so it can never be reached as a side effect of a query. Owned by mcp-maintainer for upkeep; tool behaviour is backend-engineer's." + }, + "mobile": { + "product": "FuzeKeys", + "required": true, + "strategy": "pwa", + "targets": [ + "android", + "mobile-web" + ], + "responsive": { + "min_width": 375, + "breakpoints": [ + 375, + 768, + 1024 + ], + "min_tap_target_px": 44 + }, + "acceptance": [ + "No horizontal scroll at min_width; the shell drawer replaces the desktop sidebar.", + "Every interactive target is at least min_tap_target_px.", + { + "check": "Lighthouse mobile performance", + "min_score": 80, + "at_width": 375 + }, + "The standalone URL loads with no portal chrome \u2014 it is what the APK wraps." + ] + } } diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index be5b6d2..d2b7cdc 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -6,6 +6,14 @@ on: check_suite: types: [completed] +# The default GITHUB_TOKEN is read-only unless a permissions block grants more. +# `gh pr merge --auto` needs pull-requests: write (to enable auto-merge) and +# contents: write (for the squash + branch delete); without these the step +# fails with "Resource not accessible by integration". +permissions: + contents: write + pull-requests: write + jobs: auto-merge: name: Auto Merge PR diff --git a/.github/workflows/mcp-maintain.yml b/.github/workflows/mcp-maintain.yml new file mode 100644 index 0000000..04866c1 --- /dev/null +++ b/.github/workflows/mcp-maintain.yml @@ -0,0 +1,109 @@ +name: MCP Maintain + +# Keeps this repo's Model Context Protocol (MCP) surface current, as part of CI. The +# sibling of a2a-maintain.yml and deliberately shaped the same way: on every PR it runs +# the `mcp-maintainer` agent to check whether anything changed that requires the repo's +# MCP server to be BUILT (first time) or UPDATED (drift) — the `.fuze/manifest.json` +# mcp block, the server under `mcp/`, its tools.json, and protocol-version currency. +# If so it commits the change BACK TO THE PR BRANCH; when it can't (fork PR / first-time +# build), it opens a separate `auto-merge`-labelled follow-up PR. It never fabricates a +# tool's product behaviour, never handles creds, never deploys. +# +# WHY MCP is maintained separately from A2A: A2A is how another AGENT asks this repo for +# an outcome; MCP is how an LLM SESSION queries and operates on this repo's objects and +# data directly. Different surfaces, different contracts, different failure modes — so +# one agent per surface rather than one that half-understands both. +# +# Requires repo secret ANTHROPIC_API_KEY. No key -> SKIPS (never red). + +on: + pull_request: + +permissions: + contents: write # commit the reconciled MCP surface back to the PR branch + pull-requests: write # open a follow-up PR when a push-back isn't possible + id-token: write # claude-code-action fetches an OIDC token (required) + actions: read + +concurrency: + group: mcp-maintain-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + mcp-maintain: + # Loop guard: never run on our own maintenance branches. + if: ${{ !startsWith(github.event.pull_request.head.ref, 'mcp-maintain/') }} + runs-on: ubuntu-latest + steps: + - name: Skip if key absent + id: guard + env: + KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -z "$KEY" ]; then + echo "::notice::ANTHROPIC_API_KEY not set — MCP maintain skipped (set it to enable)." + echo "ok=false" >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout PR head (writable, same-repo only) + if: steps.guard.outputs.ok == 'true' + # Pinned to a full commit SHA, not a mutable tag: a tag can be silently + # repointed by the action owner (cf. the trivy-action / kics-github-action + # compromises). This workflow holds `contents: write` on the PR branch, so a + # repointed checkout would run attacker code with push access. Semgrep's + # github-actions-mutable-action-tag rule flags the unpinned form. + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 0 + + - name: Skip on last-commit marker + id: marker + if: steps.guard.outputs.ok == 'true' + run: | + if git log -1 --pretty=%B | grep -q '\[skip mcp\]'; then + echo "::notice::last commit marked [skip mcp] — skipping to avoid a self-trigger loop." + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Run mcp-maintainer + if: steps.guard.outputs.ok == 'true' && steps.marker.outputs.run == 'true' + uses: anthropics/claude-code-action@428971d2ecd6e3a7cb0ee0da2a3a8b33fdb3678d # v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + You are the **mcp-maintainer** for this repo (follow `.claude/agents/mcp-maintainer.md` + exactly — same scope, boundaries, and done-contract). This is an automated CI run on + PR #${{ github.event.pull_request.number }} (head `${{ github.event.pull_request.head.ref }}`). + + TASK: assess whether this PR changed anything that requires the repo's MCP surface to be + built (first time) or updated (drift) — the `.fuze/manifest.json` `mcp` block, the + server(s) under `mcp/` named in `servers[]`, the `mcp/tools.json` tool manifest + (every tool needs name/description/inputSchema/`mutates`), and protocol-version + currency. If the surface is already in sync, DO NOTHING and say so. + + IF a change is needed: + - Make the minimal correct change (scaffold on first run; reconcile only what drifted). + - COMMIT IT BACK TO THIS PR BRANCH: `chore(mcp-maintain): [skip mcp]` and push to + `${{ github.event.pull_request.head.ref }}`. The trailing `[skip mcp]` is REQUIRED so you + do not re-trigger yourself. + - If you cannot push to the PR branch (this is a fork PR), instead open a follow-up PR from + an `mcp-maintain/pr-${{ github.event.pull_request.number }}` branch, add the `auto-merge` + label, and comment the link on this PR. + + HARD BOUNDARIES: never fabricate a tool's product/domain behaviour (scaffold a schema-valid + skeleton and TODO-flag the real behaviour), never handle secrets/creds, never `kubectl`/deploy, + never flip `enabled: true` for a server with no working tool, and never widen a tool's + `mutates: false` to `true` just to make a handler compile — if the handler mutates, the + handler is the bug. Where the repo exposes secret material or infrastructure writes, FLAG the + exposure policy rather than deciding it. Verify any server you touch actually starts and + lists its tools. + + End with the done-contract line from your agent def + (`MCP SURFACE: ` + what landed + `NEEDS PRODUCT/OPERATOR:` if any). + claude_args: '--allowedTools "Bash,Read,Edit,MultiEdit,Write,Glob,Grep,WebFetch"' diff --git a/registration/README.md b/registration/README.md new file mode 100644 index 0000000..370b7e1 --- /dev/null +++ b/registration/README.md @@ -0,0 +1,66 @@ +# FuzeFront registration + +FuzeKeys self-registers with the FuzeFront portal at deploy time. + +| File | Purpose | +|---|---| +| `manifest.json` | App identity, Module-Federation contract, `nav` placement | +| `policy.json` | FuzeKeys' own Permit resources/roles, bare keys | +| `register.sh` | Idempotent registration script from `@fuzefront/onboarding-kit` | + +## Module Federation contract + +Real, and taken from `frontend/vite.config.ts` + `frontend/Dockerfile`: + +| Field | Value | Source | +|---|---|---| +| `scope` | `fuzeKeysApp` | federation `name` | +| `module` | `./FuzeKeysApp` | `exposes` key | +| `remoteEntry` | `https://keys.prod.fuzefront.com/apps/fuzekeys/remoteEntry.js` | `base: '/apps/fuzekeys/'` + the `keys.prod.fuzefront.com` ingress host | + +The Dockerfile is explicit about this — `COPY --from=build-mfe /app/dist-mfe +/usr/share/nginx/html/apps/fuzekeys` is commented *"FuzeFront fetches remoteEntry.js +from here"* — and `frontend/nginx.conf` serves that path. + +## Menu placement + +```jsonc +"nav": { "section": "platform", "order": 10 } +``` + +`platform` rather than a lifecycle stage: FuzeKeys is a capability the other apps +consume (its `providesTo` lists 18 repos), not a step in the plan → build → sell → serve +flow. + +## Policy — reading a secret is not a read + +Derived from `backend/app/models/`: `Identity`, `Account`, `VaultAsset` (the +`identity_cards` + `api_credentials` tables), `Site`, `SignupScript`, `ApiKey`. + +The important split is on `VaultAsset`: + +| Action | Meaning | +|---|---| +| `read` | List/inspect **metadata** — which credentials exist, for which site | +| `reveal` | Return the **decrypted secret value** | + +`reveal` is granted to **`admin` only**. `operator` can create and rotate credentials +without ever being able to read back an existing one, which is the property that makes +an operator role safe to hand out. Modelling "reveal" as ordinary `read` would have +silently given every viewer the vault contents. + +`SignupScript:run` is likewise separated from writes — it drives real account creation +against third-party sites. + +## NOT DONE — init container not wired + +`deploy/helm/fuzekeys/` is a multi-service chart (backend, frontend, vault, tokenizer, +presidio, litellm). Exactly one deployment must run registration; wiring more than one +would have them race and duplicate-register. `frontend` is the natural owner since it +serves the remote, but confirming that is `devops-engineer`'s call and is flagged rather +than guessed. + +To finish: paste the init container from +[`@fuzefront/onboarding-kit`](https://github.com/izzywdev/FuzeFront/blob/master/packages/onboarding-kit/helm/initcontainer.yaml) +into that one pod spec, plus the `fuzefront-registration` Secret and a ConfigMap of this +directory. diff --git a/registration/manifest.json b/registration/manifest.json new file mode 100644 index 0000000..41f5638 --- /dev/null +++ b/registration/manifest.json @@ -0,0 +1,22 @@ +{ + "manifestVersion": "1", + "slug": "fuzekeys", + "name": "FuzeKeys", + "menuLabel": "Keys", + "description": "Identity and credential vault — managed digital identities, per-site accounts, encrypted credentials, and automated signup workflows.", + "icon": { "kind": "emoji", "value": "🔑" }, + "mode": "portal", + "modes": ["portal", "standalone"], + "builtin": false, + "integration": { + "type": "module-federation", + "remoteEntry": "https://keys.prod.fuzefront.com/apps/fuzekeys/remoteEntry.js", + "scope": "fuzeKeysApp", + "module": "./FuzeKeysApp" + }, + "nav": { "section": "platform", "order": 10 }, + "chrome": { "menu": "host", "topbar": "host" }, + "routing": { "path": "/app/fuzekeys", "host": "fuzekeys.fuzefront.com" }, + "visibility": "organization", + "roles": [] +} diff --git a/registration/policy.json b/registration/policy.json new file mode 100644 index 0000000..2ce16bf --- /dev/null +++ b/registration/policy.json @@ -0,0 +1,100 @@ +{ + "name": "FuzeKeys", + "resources": [ + { + "key": "Identity", + "name": "Identity", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "Account", + "name": "Site Account", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "VaultAsset", + "name": "Vault Asset", + "actions": { + "read": { "name": "Read metadata" }, + "reveal": { "name": "Reveal secret value" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "Site", + "name": "Site", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "SignupScript", + "name": "Signup Script", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" }, + "run": { "name": "Run" } + } + }, + { + "key": "ApiKey", + "name": "API Key", + "actions": { + "read": { "name": "Read metadata" }, + "create": { "name": "Create" }, + "revoke": { "name": "Revoke" } + } + } + ], + "roles": [ + { + "key": "viewer", + "name": "Viewer", + "permissions": [ + "Identity:read", "Account:read", "VaultAsset:read", + "Site:read", "SignupScript:read", "ApiKey:read" + ] + }, + { + "key": "operator", + "name": "Operator", + "permissions": [ + "Identity:read", "Identity:create", "Identity:update", + "Account:read", "Account:create", "Account:update", + "VaultAsset:read", "VaultAsset:create", "VaultAsset:update", + "Site:read", "Site:create", "Site:update", + "SignupScript:read", "SignupScript:run", + "ApiKey:read" + ] + }, + { + "key": "admin", + "name": "Admin", + "permissions": [ + "Identity:read", "Identity:create", "Identity:update", "Identity:delete", + "Account:read", "Account:create", "Account:update", "Account:delete", + "VaultAsset:read", "VaultAsset:reveal", "VaultAsset:create", "VaultAsset:update", "VaultAsset:delete", + "Site:read", "Site:create", "Site:update", "Site:delete", + "SignupScript:read", "SignupScript:create", "SignupScript:update", "SignupScript:delete", "SignupScript:run", + "ApiKey:read", "ApiKey:create", "ApiKey:revoke" + ] + } + ] +} diff --git a/registration/register.sh b/registration/register.sh new file mode 100755 index 0000000..5b767ab --- /dev/null +++ b/registration/register.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env sh +# FuzeFront app self-registration — run as a Kubernetes INIT CONTAINER. +# +# Registers the app with the FuzeFront platform at pod startup: app registry entry, +# AuthZ (Permit) policy, and billing profile. Idempotent — safe to run on every pod +# start, every restart, and concurrently across replicas. +# +# WHY THIS IS AN INIT CONTAINER AND NOT A BEST-EFFORT SIDECAR: +# the app depends on FuzeFront for AuthN, AuthZ, org/user context, and billing. An +# unregistered app cannot function correctly, so a registration failure MUST stop the +# pod — it exits non-zero and the pod CrashLoopBackOffs until the problem is fixed. +# Failing loudly at deploy beats a half-registered app serving traffic. +# +# Required env: +# FUZEFRONT_API_URL base URL, e.g. http://fuzefront-applications:3003 +# FUZEFRONT_REGISTRATION_TOKEN bearer token for a service account with apps:register +# Optional env: +# REGISTRATION_DIR directory holding manifest.json (default: /registration) +# SKIP_ACTIVATE "true" to register but not activate (staged rollout) +# +# Exit codes: 0 = registered/activated (or already was). 1 = anything else. + +set -eu + +REGISTRATION_DIR="${REGISTRATION_DIR:-/registration}" +MANIFEST="${REGISTRATION_DIR}/manifest.json" +POLICY="${REGISTRATION_DIR}/policy.json" +BILLING="${REGISTRATION_DIR}/billing-profile.json" + +# BOTH go to stderr, on purpose. http() returns the HTTP status code on STDOUT and +# is read via command substitution, so anything else written to stdout would be +# captured into the status code and corrupt every comparison against it. Init +# containers send both streams to the pod log, so nothing is lost by this. +log() { echo "[fuzefront-register] $*" >&2; } +die() { echo "[fuzefront-register] FATAL: $*" >&2; exit 1; } + +# ---- preflight --------------------------------------------------------------- +[ -n "${FUZEFRONT_API_URL:-}" ] || die "FUZEFRONT_API_URL is not set" +[ -n "${FUZEFRONT_REGISTRATION_TOKEN:-}" ] || die "FUZEFRONT_REGISTRATION_TOKEN is not set" +[ -f "$MANIFEST" ] || die "no manifest at $MANIFEST" + +command -v curl >/dev/null 2>&1 || die "curl is required but not installed" +command -v jq >/dev/null 2>&1 || die "jq is required but not installed" + +jq empty "$MANIFEST" 2>/dev/null || die "$MANIFEST is not valid JSON" + +SLUG="$(jq -r '.slug // empty' "$MANIFEST")" +[ -n "$SLUG" ] || die "manifest has no .slug" + +# nav placement is what orders the app in the portal's side menu. Not fatal if +# absent (the platform defaults it to the 'platform' section, last) but it almost +# always means someone forgot, so say so loudly rather than silently sorting last. +NAV_SECTION="$(jq -r '.nav.section // empty' "$MANIFEST")" +if [ -z "$NAV_SECTION" ]; then + log "WARNING: manifest declares no .nav.section — this app will sort LAST in the side menu." +fi + +API="${FUZEFRONT_API_URL%/}/api/v1/app-registry" +AUTH="Authorization: Bearer ${FUZEFRONT_REGISTRATION_TOKEN}" + +log "slug=${SLUG} section=${NAV_SECTION:-} api=${API}" + +# ---- helpers ----------------------------------------------------------------- +# Emits the HTTP status on stdout and writes the body to $2. Retries transient +# failures (connection refused / 5xx) — the platform may still be starting up. +http() { + _method="$1"; _url="$2"; _body_file="$3"; _payload="${4:-}" + _attempt=1 + while [ "$_attempt" -le 5 ]; do + if [ -n "$_payload" ]; then + _code="$(curl -sS -o "$_body_file" -w '%{http_code}' \ + -X "$_method" "$_url" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + --data-binary "@$_payload" 2>/dev/null || echo 000)" + else + _code="$(curl -sS -o "$_body_file" -w '%{http_code}' \ + -X "$_method" "$_url" -H "$AUTH" 2>/dev/null || echo 000)" + fi + # 000 = could not connect; 5xx = server-side transient. Both worth retrying. + case "$_code" in + 000|5??) + log " ${_method} ${_url} -> ${_code} (attempt ${_attempt}/5), retrying in $((_attempt * 2))s" + sleep "$((_attempt * 2))" + _attempt=$((_attempt + 1)) + ;; + *) echo "$_code"; return 0 ;; + esac + done + echo "$_code" + return 0 +} + +BODY="$(mktemp)" +# shellcheck disable=SC2064 # expand BODY now, on purpose +trap "rm -f '$BODY'" EXIT + +# ---- 1. register (idempotent) ------------------------------------------------ +CODE="$(http GET "${API}/apps/${SLUG}" "$BODY")" + +case "$CODE" in + 200) + STATUS="$(jq -r '.status // empty' "$BODY")" + log "already registered (status=${STATUS})" + # Re-PUT the manifest so a redeploy picks up manifest changes (new remoteEntry + # after a version bump, changed nav placement, …). Without this, the very first + # registration would be frozen forever and every later manifest edit a no-op. + PUT_CODE="$(http PUT "${API}/apps/${SLUG}" "$BODY" "$MANIFEST")" + case "$PUT_CODE" in + 200|204) log "manifest refreshed" ;; + # A manifest update is not worth failing the pod over — the app IS registered + # and can serve. Report it; the drift shows up in the registry. + *) log "WARNING: manifest refresh returned ${PUT_CODE} — continuing with the existing registration" ;; + esac + ;; + 404) + log "not registered — registering" + REQ="$(mktemp)" + jq '{manifest: .}' "$MANIFEST" > "$REQ" + CODE="$(http POST "${API}/apps" "$BODY" "$REQ")" + rm -f "$REQ" + case "$CODE" in + 201) log "registered" ;; + # Another replica won the race — that is success, not failure. + 409) log "already registered (409 — concurrent replica won the race)" ;; + *) die "register failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac + STATUS="registered" + ;; + 401|403) + die "auth rejected (HTTP ${CODE}) — check FUZEFRONT_REGISTRATION_TOKEN has the apps:register scope" + ;; + *) + die "unexpected response looking up ${SLUG}: HTTP ${CODE} $(cat "$BODY")" + ;; +esac + +# ---- 2. activate ------------------------------------------------------------- +if [ "${SKIP_ACTIVATE:-false}" = "true" ]; then + log "SKIP_ACTIVATE=true — leaving app in '${STATUS}' (it will NOT appear in the menu)" +elif [ "${STATUS:-}" = "activated" ]; then + log "already activated" +else + CODE="$(http POST "${API}/apps/${SLUG}/activate" "$BODY")" + case "$CODE" in + 200|204) log "activated" ;; + *) die "activate failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac +fi + +# ---- 3. AuthZ policy (optional file) ----------------------------------------- +# The product declares its OWN Permit resources/roles with BARE keys; the platform +# namespaces them (_Listing, …) and merges into the base schema. This is +# what replaces hand-editing backend/src/permit/products/*.policy.ts in FuzeFront. +if [ -f "$POLICY" ]; then + jq empty "$POLICY" 2>/dev/null || die "$POLICY is not valid JSON" + CODE="$(http PUT "${API}/apps/${SLUG}/policy" "$BODY" "$POLICY")" + case "$CODE" in + 200|201|204) log "authz policy submitted" ;; + *) die "policy submission failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac +else + log "no policy.json — skipping authz policy (app will have no product-specific roles)" +fi + +# ---- 4. billing profile (optional file) -------------------------------------- +# Registers the product key so billing accepts checkout for it. Replaces editing the +# BILLING_PRODUCT_KEYS env allowlist in the platform's Helm values by hand. +if [ -f "$BILLING" ]; then + jq empty "$BILLING" 2>/dev/null || die "$BILLING is not valid JSON" + CODE="$(http PUT "${API}/apps/${SLUG}/billing-profile" "$BODY" "$BILLING")" + case "$CODE" in + 200|201|204) log "billing profile registered" ;; + *) die "billing profile registration failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac +else + log "no billing-profile.json — skipping billing (app cannot take payments)" +fi + +log "OK — ${SLUG} is registered and ready" +exit 0