Author SHA1 Message Date
Sharang ParnerkarandClaude Fable 5 778b587640 fix(ci): clear the 2 HIGH browserslist advisories blocking the image build
ci / e2e (pull_request) Blocked by required conditions
ci / shared (pull_request) Successful in 12s
ci / test (pull_request) Successful in 10m20s
ci / image (pull_request) Skipped
The `shared` job's trivy fs scan started failing on CVE-2026-73088 and
CVE-2026-73089 (browserslist 4.28.2, prototype pollution + unbounded DoS,
both fixed in 4.28.7). Because `image` is gated on `shared`, no image was
built and the portal entitlement fix could not deploy.

Not introduced by any code change: `shared` passed on the previous commit
(ac28e22) with the same dependency tree. Trivy refreshes its vulnerability DB
each run, so these advisories were published in between.

Cleared with a pnpm override, the same pattern already used here for nanoid,
postcss, @auth/core and sharp. browserslist is transitive (not a declared
dependency), so an override is the only lever. Lockfile now resolves 4.28.8.

Verified: tsc --noEmit 0, next build 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
2026-09-02 12:00:26 +02:00
sharang faa6e83513 fix(products): source products and entitlements from the registry, not hard-coded ids (#25)
ci / e2e (push) Blocked by required conditions
ci / shared (push) Failing after 12s
ci / test (push) Successful in 10m15s
ci / image (push) Skipped
2026-09-02 09:47:52 +00:00
sharang ac28e2256f fix(ci): docker:27-cli image job; clear postcss and sharp HIGHs (#24)
ci / test (push) Successful in 10m17s
ci / shared (push) Successful in 14s
ci / image (push) Successful in 50s
ci / e2e (push) Canceled after 0s
2026-08-31 15:51:39 +00:00
sharang 63fde7993c fix(deps): next-auth beta.32 — the advisory targets next-auth itself (#23)
ci / shared (push) Failing after 12s
ci / test (push) Successful in 10m14s
ci / image (push) Skipped
ci / e2e (push) Canceled after 0s
2026-08-31 08:40:14 +00:00
sharang ba483cae97 fix(ci): make main deployable again — coverage, CVEs, and the image job (#22)
ci / shared (push) Failing after 12s
ci / test (push) Successful in 10m17s
ci / image (push) Skipped
ci / e2e (push) Canceled after 0s
2026-08-30 21:33:08 +00:00
sharang f2f9ab74c8 RBAC: send a client_credentials service token to tenant-registry (#21)
ci / shared (push) Failing after 12s
ci / test (push) Failing after 5m3s
ci / e2e (push) Skipped
ci / image (push) Skipped
2026-08-25 09:15:47 +00:00
6 changed files with 492 additions and 340 deletions
+37 -21
View File
@@ -115,41 +115,57 @@ jobs:
# Builds the portal image and ships it through the same path every
# other service in orca-infra uses: push :latest + :sha-<sha> to
# repo.breakpilot.com, then POST a github-style payload to the
# orca webhook so the master pulls and redeploys breakpilot-portal.
#
# 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.
# orca webhook so the master pulls and redeploys the portal services.
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
# 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:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: repo.breakpilot.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
repo.breakpilot.com/breakpilot/portal:latest
repo.breakpilot.com/breakpilot/portal:sha-${{ github.sha }}
- name: Checkout
run: |
apk add --no-cache git curl openssl
git clone --depth 1 --branch ${GITHUB_REF_NAME} $(echo ${GITHUB_SERVER_URL} | sed -E "s#^(https?://)#\1ci:${{ secrets.GITHUB_TOKEN }}@#")/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
run: echo "$REGISTRY_PASS" | docker login repo.breakpilot.com -u "$REGISTRY_USER" --password-stdin
- 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)
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"
chmod +x /usr/local/bin/cosign 2>/dev/null || true
cosign sign --yes --key env://COSIGN_KEY repo.breakpilot.com/breakpilot/portal:latest || echo "::warning::cosign failed"
- name: trigger orca redeploy
# Signs the POST with HMAC-SHA256 over the JSON body using the
# secret orca generated when the webhook was registered. Orca's
# 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:
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
run: |
set -eu
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}')"
curl -ksSf -X POST \
@@ -157,4 +173,4 @@ jobs:
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY" \
https://46.225.100.82:6880/api/v1/webhooks/github
http://46.225.100.82:6880/api/v1/webhooks/github
+12 -3
View File
@@ -23,8 +23,8 @@
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.17.0",
"next": "16.2.6",
"next-auth": "5.0.0-beta.25",
"next": "16.2.11",
"next-auth": "5.0.0-beta.32",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^3.6.0"
@@ -48,5 +48,14 @@
"workerDirectory": [
"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"
}
}
}
}
+339 -304
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -107,13 +107,22 @@ export default async function ProductsPage({
title="Findings across products"
tail={
<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
key={o}
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
key={p.id}
href={`/${slug}/products?p=${p.slug}`}
className={"btn btn-sm" + (productFilter === p.slug ? " btn-primary" : "")}
>
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
{p.name}
</Link>
))}
</span>
+65 -5
View File
@@ -10,11 +10,51 @@
// Once the registry is enriched to carry the design fields end-to-end this
// module collapses into a thin pass-through.
import { tenantBySlug, type TenantRecord } from "@/lib/fixtures";
import { fetchTenantBySlug, type Tenant } from "@/lib/tenant-registry";
import { tenantBySlug, type ProductDef, type TenantRecord } from "@/lib/fixtures";
import {
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
type CatalogEntry,
type Entitlement,
type Tenant,
} from "@/lib/tenant-registry";
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> {
const fx = tenantBySlug(slug);
if (fx) return fx;
@@ -30,6 +70,26 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
}
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
// placeholders that won't blow up the layout.
return {
@@ -51,12 +111,12 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
contactEmail: "—",
renewal: "—",
since: live.created_at?.slice(0, 10) ?? "—",
entitled: [],
trialing: [],
entitled,
trialing,
trialEnds: live.trial_ends_at ?? undefined,
seed: 0,
findingCount: 0,
products: [],
products,
findings: [],
activity: [],
audit: [],
+25 -2
View File
@@ -107,10 +107,10 @@ describe("serviceToken", () => {
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,
json: async () => ({}),
} as Response);
await expect(serviceToken()).rejects.toThrow("service token request failed: 401");
@@ -130,13 +130,36 @@ describe("serviceToken", () => {
test("a failed fetch does not poison the cache", async () => {
configure();
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) } as Response)
.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"));