Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
778b587640 | ||
|
|
faa6e83513 | ||
|
|
ac28e2256f | ||
|
|
63fde7993c | ||
|
|
ba483cae97 | ||
|
|
f2f9ab74c8 | ||
|
|
7e62c0162f | ||
|
|
329cc0e57a |
+39
-23
@@ -114,42 +114,58 @@ jobs:
|
|||||||
image:
|
image:
|
||||||
# Builds the portal image and ships it through the same path every
|
# Builds the portal image and ships it through the same path every
|
||||||
# other service in orca-infra uses: push :latest + :sha-<sha> to
|
# other service in orca-infra uses: push :latest + :sha-<sha> to
|
||||||
# repo.meghsakha.com, then POST a github-style payload to the
|
# repo.breakpilot.com, then POST a github-style payload to the
|
||||||
# orca webhook so the master pulls and redeploys breakpilot-portal.
|
# orca webhook so the master pulls and redeploys the portal services.
|
||||||
#
|
|
||||||
# Webhook target (registered once on the master via
|
|
||||||
# orca webhooks add --repo platform/portal \
|
|
||||||
# --service breakpilot-portal --branch main
|
|
||||||
# ) accepts unsigned payloads — orca matches on repo + branch.
|
|
||||||
needs: [shared, test]
|
needs: [shared, test]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('Dockerfile') != ''
|
# NOTE: no hashFiles() here — at job level it evaluates BEFORE checkout
|
||||||
|
# against an empty workspace, so the old condition was always false and
|
||||||
|
# this job silently never ran (deployment sat on a June-10 image).
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
# The runner's default job container has no docker CLI, so
|
||||||
|
# docker/login-action + docker/build-push-action die with "Unable to
|
||||||
|
# locate executable file: docker" (proven by tenant-registry run 5441).
|
||||||
|
# Same pattern as the proven breakpilot-compliance build-push-deploy.yml:
|
||||||
|
# run in docker:27-cli and use plain docker commands.
|
||||||
|
container: docker:27-cli
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- uses: docker/login-action@v3
|
run: |
|
||||||
with:
|
apk add --no-cache git curl openssl
|
||||||
registry: repo.meghsakha.com
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} $(echo ${GITHUB_SERVER_URL} | sed -E "s#^(https?://)#\1ci:${{ secrets.GITHUB_TOKEN }}@#")/${GITHUB_REPOSITORY}.git .
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
- name: Login
|
||||||
- uses: docker/build-push-action@v6
|
env:
|
||||||
with:
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
push: true
|
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||||
tags: |
|
run: echo "$REGISTRY_PASS" | docker login repo.breakpilot.com -u "$REGISTRY_USER" --password-stdin
|
||||||
repo.meghsakha.com/breakpilot/portal:latest
|
|
||||||
repo.meghsakha.com/breakpilot/portal:sha-${{ github.sha }}
|
- name: Build + push
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker build \
|
||||||
|
-t repo.breakpilot.com/breakpilot/portal:latest \
|
||||||
|
-t repo.breakpilot.com/breakpilot/portal:sha-${GITHUB_SHA} \
|
||||||
|
.
|
||||||
|
docker push repo.breakpilot.com/breakpilot/portal:latest
|
||||||
|
docker push repo.breakpilot.com/breakpilot/portal:sha-${GITHUB_SHA}
|
||||||
|
|
||||||
- name: sign image (cosign)
|
- name: sign image (cosign)
|
||||||
run: |
|
run: |
|
||||||
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
||||||
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
||||||
cosign sign --yes --key env://COSIGN_KEY repo.meghsakha.com/breakpilot/portal:latest || echo "::warning::cosign failed"
|
cosign sign --yes --key env://COSIGN_KEY repo.breakpilot.com/breakpilot/portal:latest || echo "::warning::cosign failed"
|
||||||
|
|
||||||
- name: trigger orca redeploy
|
- name: trigger orca redeploy
|
||||||
# Signs the POST with HMAC-SHA256 over the JSON body using the
|
# Signs the POST with HMAC-SHA256 over the JSON body using the
|
||||||
# secret orca generated when the webhook was registered. Orca's
|
# secret orca generated when the webhook was registered. Orca's
|
||||||
# endpoint is publicly reachable on the master, so the signature
|
# endpoint is publicly reachable on the master, so the signature
|
||||||
# gates who can fire a deploy.
|
# gates who can fire a deploy. Plain http like the proven
|
||||||
|
# compliance trigger-orca job (the endpoint does not serve TLS).
|
||||||
env:
|
env:
|
||||||
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
|
set -eu
|
||||||
BODY='{"repository":{"full_name":"platform/portal"},"ref":"refs/heads/main"}'
|
BODY='{"repository":{"full_name":"platform/portal"},"ref":"refs/heads/main"}'
|
||||||
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -hex | awk '{print $NF}')"
|
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -hex | awk '{print $NF}')"
|
||||||
curl -ksSf -X POST \
|
curl -ksSf -X POST \
|
||||||
@@ -157,4 +173,4 @@ jobs:
|
|||||||
-H "X-GitHub-Event: push" \
|
-H "X-GitHub-Event: push" \
|
||||||
-H "X-Hub-Signature-256: $SIG" \
|
-H "X-Hub-Signature-256: $SIG" \
|
||||||
-d "$BODY" \
|
-d "$BODY" \
|
||||||
https://46.225.100.82:6880/api/v1/webhooks/github
|
http://46.225.100.82:6880/api/v1/webhooks/github
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
- name: verify stage soak (>= 24h on this image)
|
- name: verify stage soak (>= 24h on this image)
|
||||||
run: |
|
run: |
|
||||||
IMG=registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
|
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
|
||||||
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
||||||
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
||||||
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
- name: re-tag image as semver + env-prod
|
- name: re-tag image as semver + env-prod
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.breakpilot.com
|
registry: repo.breakpilot.com
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
IMG=registry.breakpilot.com/${{ github.event.repository.name }}
|
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}
|
||||||
docker pull $IMG:env-stage
|
docker pull $IMG:env-stage
|
||||||
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
||||||
docker tag $IMG:env-stage $IMG:env-prod
|
docker tag $IMG:env-stage $IMG:env-prod
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
|
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
|
||||||
https://gitea.meghsakha.com/api/v1/repos/${{ github.repository }}/releases
|
https://git.breakpilot.com/api/v1/repos/${{ github.repository }}/releases
|
||||||
|
|
||||||
rollback-on-failure:
|
rollback-on-failure:
|
||||||
needs: promote
|
needs: promote
|
||||||
|
|||||||
+12
-3
@@ -23,8 +23,8 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.7.2",
|
"geist": "^1.7.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.11",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
@@ -48,5 +48,14 @@
|
|||||||
"workerDirectory": [
|
"workerDirectory": [
|
||||||
"public"
|
"public"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"nanoid@<3.3.18": ">=3.3.18",
|
||||||
|
"postcss@<8.5.18": ">=8.5.18",
|
||||||
|
"@auth/core@<0.41.3": ">=0.41.3",
|
||||||
|
"sharp@<0.35.0": ">=0.35.0",
|
||||||
|
"browserslist@<4.28.7": ">=4.28.7"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+339
-304
File diff suppressed because it is too large
Load Diff
@@ -107,13 +107,22 @@ export default async function ProductsPage({
|
|||||||
title="Findings across products"
|
title="Findings across products"
|
||||||
tail={
|
tail={
|
||||||
<span className="row" style={{ gap: 6 }}>
|
<span className="row" style={{ gap: 6 }}>
|
||||||
{["all", "compliance-scanner", "certifai"].map((o) => (
|
{/* Derived from the tenant's own products, not a hard-coded list —
|
||||||
|
a hard-coded id (e.g. "compliance-scanner") silently filters to
|
||||||
|
nothing when the registry calls the product something else. */}
|
||||||
|
<Link
|
||||||
|
href={`/${slug}/products`}
|
||||||
|
className={"btn btn-sm" + (productFilter === "all" ? " btn-primary" : "")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Link>
|
||||||
|
{t.products.map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={o}
|
key={p.id}
|
||||||
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
|
href={`/${slug}/products?p=${p.slug}`}
|
||||||
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
|
className={"btn btn-sm" + (productFilter === p.slug ? " btn-primary" : "")}
|
||||||
>
|
>
|
||||||
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
|
{p.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+65
-5
@@ -10,11 +10,51 @@
|
|||||||
// Once the registry is enriched to carry the design fields end-to-end this
|
// Once the registry is enriched to carry the design fields end-to-end this
|
||||||
// module collapses into a thin pass-through.
|
// module collapses into a thin pass-through.
|
||||||
|
|
||||||
import { tenantBySlug, type TenantRecord } from "@/lib/fixtures";
|
import { tenantBySlug, type ProductDef, type TenantRecord } from "@/lib/fixtures";
|
||||||
import { fetchTenantBySlug, type Tenant } from "@/lib/tenant-registry";
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchEntitlements,
|
||||||
|
fetchTenantBySlug,
|
||||||
|
type CatalogEntry,
|
||||||
|
type Entitlement,
|
||||||
|
type Tenant,
|
||||||
|
} from "@/lib/tenant-registry";
|
||||||
|
|
||||||
export type PortalTenant = TenantRecord;
|
export type PortalTenant = TenantRecord;
|
||||||
|
|
||||||
|
/** Registry catalog entry -> the shape the product screens render.
|
||||||
|
*
|
||||||
|
* The registry `key` becomes both `id` and `slug`, deliberately. The screens
|
||||||
|
* compare `entitled.includes(p.id)`, and `entitled` is built from the SAME
|
||||||
|
* registry keys below — so the two sides cannot drift apart. Hard-coding a
|
||||||
|
* product list here is what previously made a real entitlement (`compliance`)
|
||||||
|
* fail to match a hand-written product id (`compliance-scanner`).
|
||||||
|
*
|
||||||
|
* `frameworks` is empty because the registry contract does not carry it
|
||||||
|
* (PRODUCT_INTEGRATION_SPEC has products publish a manifest later); the UI
|
||||||
|
* already renders an empty list without complaint.
|
||||||
|
*/
|
||||||
|
function productFromCatalog(entry: CatalogEntry): ProductDef {
|
||||||
|
return {
|
||||||
|
id: entry.key,
|
||||||
|
slug: entry.key,
|
||||||
|
name: entry.name,
|
||||||
|
mono: entry.name.replace(/[^A-Za-z]/g, "").slice(0, 2).toUpperCase() || "??",
|
||||||
|
status: "live",
|
||||||
|
blurb: entry.description,
|
||||||
|
frameworks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entitlements the tenant may actually use right now. */
|
||||||
|
function activeEntitlements(items: Entitlement[], nowMs: number): Entitlement[] {
|
||||||
|
return items.filter((e) => {
|
||||||
|
if (!e.enabled) return false;
|
||||||
|
if (!e.expires_at) return true;
|
||||||
|
return new Date(e.expires_at).getTime() > nowMs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadTenantForShell(slug: string): Promise<PortalTenant | null> {
|
export async function loadTenantForShell(slug: string): Promise<PortalTenant | null> {
|
||||||
const fx = tenantBySlug(slug);
|
const fx = tenantBySlug(slug);
|
||||||
if (fx) return fx;
|
if (fx) return fx;
|
||||||
@@ -30,6 +70,26 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
|
|||||||
}
|
}
|
||||||
if (!live) return null;
|
if (!live) return null;
|
||||||
|
|
||||||
|
// Entitlements and catalog are what make a started trial actually show as
|
||||||
|
// entitled. Both are best-effort: the shell must still render if the
|
||||||
|
// registry answers the tenant but not these, so a failure degrades to an
|
||||||
|
// empty product grid rather than a 404 on the whole page.
|
||||||
|
const now = Date.now();
|
||||||
|
const [catalog, entitlements] = await Promise.all([
|
||||||
|
fetchCatalog().catch(() => [] as CatalogEntry[]),
|
||||||
|
fetchEntitlements(live.id).catch(() => [] as Entitlement[]),
|
||||||
|
]);
|
||||||
|
const active = activeEntitlements(entitlements, now);
|
||||||
|
const entitled = active.map((e) => e.product);
|
||||||
|
const trialing = active
|
||||||
|
.filter((e) => Boolean(e.expires_at))
|
||||||
|
.map((e) => e.product);
|
||||||
|
// Show the catalog, with the entitled ones first so the grid leads with what
|
||||||
|
// the tenant can actually open.
|
||||||
|
const products = catalog
|
||||||
|
.map(productFromCatalog)
|
||||||
|
.sort((a, b) => Number(entitled.includes(b.id)) - Number(entitled.includes(a.id)));
|
||||||
|
|
||||||
// Minimal shim so the shell can render. Design-rich fields fall back to
|
// Minimal shim so the shell can render. Design-rich fields fall back to
|
||||||
// placeholders that won't blow up the layout.
|
// placeholders that won't blow up the layout.
|
||||||
return {
|
return {
|
||||||
@@ -51,12 +111,12 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
|
|||||||
contactEmail: "—",
|
contactEmail: "—",
|
||||||
renewal: "—",
|
renewal: "—",
|
||||||
since: live.created_at?.slice(0, 10) ?? "—",
|
since: live.created_at?.slice(0, 10) ?? "—",
|
||||||
entitled: [],
|
entitled,
|
||||||
trialing: [],
|
trialing,
|
||||||
trialEnds: live.trial_ends_at ?? undefined,
|
trialEnds: live.trial_ends_at ?? undefined,
|
||||||
seed: 0,
|
seed: 0,
|
||||||
findingCount: 0,
|
findingCount: 0,
|
||||||
products: [],
|
products,
|
||||||
findings: [],
|
findings: [],
|
||||||
activity: [],
|
activity: [],
|
||||||
audit: [],
|
audit: [],
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resetServiceTokenCache,
|
||||||
|
serviceAuthHeader,
|
||||||
|
serviceToken,
|
||||||
|
} from "./service-token";
|
||||||
|
|
||||||
|
const ISSUER = "https://auth.breakpilot.com/realms/breakpilot-dev";
|
||||||
|
|
||||||
|
function tokenResponse(value: string, expiresIn = 300) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: value, expires_in: expiresIn }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure() {
|
||||||
|
process.env.KEYCLOAK_ISSUER = ISSUER;
|
||||||
|
process.env.PORTAL_SVC_CLIENT_ID = "portal-svc";
|
||||||
|
process.env.PORTAL_SVC_CLIENT_SECRET = "shh";
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetServiceTokenCache();
|
||||||
|
delete process.env.PORTAL_SVC_CLIENT_ID;
|
||||||
|
delete process.env.PORTAL_SVC_CLIENT_SECRET;
|
||||||
|
delete process.env.KEYCLOAK_ISSUER;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serviceToken", () => {
|
||||||
|
test("returns null and makes no request when unconfigured", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
expect(await serviceToken()).toBeNull();
|
||||||
|
expect(await serviceAuthHeader()).toEqual({});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requests a client_credentials token against the realm", async () => {
|
||||||
|
configure();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
|
||||||
|
expect(await serviceToken()).toBe("tok-1");
|
||||||
|
|
||||||
|
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(`${ISSUER}/protocol/openid-connect/token`);
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
const body = new URLSearchParams(init.body as string);
|
||||||
|
expect(body.get("grant_type")).toBe("client_credentials");
|
||||||
|
expect(body.get("client_id")).toBe("portal-svc");
|
||||||
|
expect(body.get("client_secret")).toBe("shh");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caches the token across calls", async () => {
|
||||||
|
configure();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
|
||||||
|
await serviceToken();
|
||||||
|
await serviceToken();
|
||||||
|
await serviceToken();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("de-dupes concurrent fetches into one request", async () => {
|
||||||
|
configure();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
|
||||||
|
const results = await Promise.all([
|
||||||
|
serviceToken(),
|
||||||
|
serviceToken(),
|
||||||
|
serviceToken(),
|
||||||
|
]);
|
||||||
|
expect(results).toEqual(["tok-1", "tok-1", "tok-1"]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshes shortly before expiry", async () => {
|
||||||
|
configure();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T10:00:00Z"));
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-1", 300))
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-2", 300));
|
||||||
|
|
||||||
|
expect(await serviceToken()).toBe("tok-1");
|
||||||
|
// 4 minutes in: still inside the window
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T10:04:00Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-1");
|
||||||
|
// 4:40 — inside the 30s refresh margin
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T10:04:40Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-2");
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces a rejected token request instead of calling unauthenticated", async () => {
|
||||||
|
configure();
|
||||||
|
// no json() on the mock: fetchToken throws on !ok before reading the body
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(serviceToken()).rejects.toThrow("service token request failed: 401");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces a malformed token response", async () => {
|
||||||
|
configure();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(serviceToken()).rejects.toThrow("no access_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed fetch does not poison the cache", async () => {
|
||||||
|
configure();
|
||||||
|
vi.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503 } as Response)
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-ok"));
|
||||||
|
|
||||||
|
await expect(serviceToken()).rejects.toThrow();
|
||||||
|
expect(await serviceToken()).toBe("tok-ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a response without expires_in gets the 300s default lifetime", async () => {
|
||||||
|
configure();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-30T10:00:00Z"));
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: "tok-default" }),
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-next"));
|
||||||
|
|
||||||
|
expect(await serviceToken()).toBe("tok-default");
|
||||||
|
// 4 minutes in: still inside the defaulted 300s window
|
||||||
|
vi.setSystemTime(new Date("2026-08-30T10:04:00Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-default");
|
||||||
|
// past the default expiry
|
||||||
|
vi.setSystemTime(new Date("2026-08-30T10:05:01Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-next");
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serviceAuthHeader carries the bearer token when configured", async () => {
|
||||||
|
configure();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
expect(await serviceAuthHeader()).toEqual({ authorization: "Bearer tok-1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Client-credentials service token for portal → tenant-registry calls.
|
||||||
|
//
|
||||||
|
// tenant-registry's API is INTERNAL_SERVICE_ONLY: once its AUTH_ENABLED
|
||||||
|
// flips, every route except /healthz and /readyz needs a Keycloak token
|
||||||
|
// whose audience contains `tenant-registry`. The portal is a service
|
||||||
|
// principal here — this is machine-to-machine, unrelated to the visitor's
|
||||||
|
// SSO session (that one authenticates a human against `dev-portal`).
|
||||||
|
//
|
||||||
|
// Inert until configured: with no PORTAL_SVC_CLIENT_ID / _SECRET the
|
||||||
|
// helper returns null and callers send no Authorization header, which is
|
||||||
|
// exactly today's behaviour against a tenant-registry that is not yet
|
||||||
|
// enforcing. Configure both to switch the portal over.
|
||||||
|
//
|
||||||
|
// Server-only: the client secret must never reach the browser. Every
|
||||||
|
// caller (src/lib/tenant-registry.ts) already runs server-side.
|
||||||
|
|
||||||
|
const REFRESH_MARGIN_SECONDS = 30;
|
||||||
|
|
||||||
|
type CachedToken = { value: string; expiresAt: number };
|
||||||
|
|
||||||
|
let cached: CachedToken | null = null;
|
||||||
|
// de-dupes concurrent fetches: many parallel renders share one request
|
||||||
|
let inFlight: Promise<CachedToken> | null = null;
|
||||||
|
|
||||||
|
function config(): { issuer: string; clientId: string; secret: string } | null {
|
||||||
|
const clientId = process.env.PORTAL_SVC_CLIENT_ID;
|
||||||
|
const secret = process.env.PORTAL_SVC_CLIENT_SECRET;
|
||||||
|
const issuer = process.env.KEYCLOAK_ISSUER;
|
||||||
|
if (!clientId || !secret || !issuer) return null;
|
||||||
|
return { issuer, clientId, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchToken(cfg: {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
secret: string;
|
||||||
|
}): Promise<CachedToken> {
|
||||||
|
const res = await fetch(`${cfg.issuer}/protocol/openid-connect/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: cfg.clientId,
|
||||||
|
client_secret: cfg.secret,
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`service token request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
|
if (!body.access_token) {
|
||||||
|
throw new Error("service token response carried no access_token");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: body.access_token,
|
||||||
|
expiresAt: Date.now() / 1000 + (body.expires_in ?? 300),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid service token, or null when the portal is not configured to
|
||||||
|
* send one. Cached in memory and refreshed shortly before expiry (realm
|
||||||
|
* tokens live 5 minutes).
|
||||||
|
*/
|
||||||
|
export async function serviceToken(): Promise<string | null> {
|
||||||
|
const cfg = config();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
if (cached && now < cached.expiresAt - REFRESH_MARGIN_SECONDS) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
if (!inFlight) {
|
||||||
|
inFlight = fetchToken(cfg).finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cached = await inFlight;
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization header for an outbound call, or {} when unconfigured. */
|
||||||
|
export async function serviceAuthHeader(): Promise<Record<string, string>> {
|
||||||
|
const token = await serviceToken();
|
||||||
|
return token ? { authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: drop the cached token. */
|
||||||
|
export function resetServiceTokenCache(): void {
|
||||||
|
cached = null;
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Tenant Registry client — covers everything the portal needs to call
|
// Tenant Registry client — covers everything the portal needs to call
|
||||||
// from server components and server actions.
|
// from server components and server actions.
|
||||||
|
|
||||||
|
import { serviceAuthHeader } from "./service-token";
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -74,9 +76,12 @@ async function req<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<{ status: number; data: T | null }> {
|
): Promise<{ status: number; data: T | null }> {
|
||||||
|
// tenant-registry is INTERNAL_SERVICE_ONLY — the portal calls it as a
|
||||||
|
// service principal. Unconfigured ⇒ {} ⇒ unchanged, header-less calls.
|
||||||
|
const auth = await serviceAuthHeader();
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: { accept: "application/json" },
|
headers: { accept: "application/json", ...auth },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user