Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,67 @@
# Build context for deployments/Dockerfile (`COPY . .` in the build stage).
# The image is PUBLIC (community-admin), so keep secrets, local state, test
# artefacts and anything the `next build` does not need out of the context.
# Needed by the build: package*.json, next.config.mjs, tsconfig*.json,
# next-env.d.ts, postcss/tailwind config, app/, src/, public/, config/.

# Dependencies / build output (rebuilt inside the image)
node_modules
dist
.next
out
build
*.tsbuildinfo

# VCS / CI / editor / OS
.git
.gitignore
.github
.vscode
.idea
*.swp
.DS_Store
Thumbs.db

# Env files and local state — every variant (the build reads none of them;
# runtime config is passed with `docker run -e` / k8s env)
.env
.env.*
*.local
.DS_Store
.local
.claude
.cursor

# Keys and certificates
*.pem
*.key
*.p8
*.p12
*.pfx
*.crt
*.cer
id_rsa*
*.keystore

# Tests and test artefacts (the image runs no tests)
tests
test-results
playwright-report
blob-report
coverage
.nyc_output
**/*.test.ts
**/*.test.tsx
vitest.config.ts

# Vite-era tooling not used by `next build`
vite.config.ts
index.html
tsconfig.node.json

# Docs, logs and Docker files themselves
docs
*.md
*.log
npm-debug.log*
deployments
.dockerignore
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ API_BASE_URL=https://api.norbix.ai
HUB_VERSION=v3
API_VERSION=v3

# Self-hosted: the project this portal serves (pr_{base62} or pr-{32 hex}).
# Read at RUNTIME by the server and emitted as <meta name="norbix-project">,
# so a published image needs no rebuild per customer. Unset = managed (the
# project comes from the pr-<hex> host label).
PROJECT_ID=

# Optional Norbix environment (self-hosted only). Unset = PROD.
# Set to "staging" or "test" to target that project environment; the backend
# sends it as the norbix-env header on every request. Managed deployments
Expand Down
8 changes: 3 additions & 5 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,16 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# Build the managed-service flavor. Multi-arch push happens on tags only;
# PRs just build to validate the Dockerfile.
- name: Build image (managed)
# One build serves every edition (managed, enterprise, community): the
# edition is read at runtime from the Hub's /echo, not at build time.
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
file: deployments/Dockerfile
push: false
load: true
tags: norbix/admin:ci
build-args: |
VITE_ADMIN_RELEASE=ManagedService

- name: Trivy image scan
uses: aquasecurity/trivy-action@0.28.0
Expand Down
15 changes: 9 additions & 6 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ name: Staging · Build & Deploy

# Continuous deployment of the ADMIN portal to the staging box (Hostinger, k3s).
#
# Admin ships only for managed + enterprise (there is no community-admin
# image), so the triggers are:
# This workflow stages managed + enterprise only. The self-hosted
# community-admin image is the SAME build (the edition is decided at runtime
# from the Hub's /echo `release`, never at build time) and is not staged here,
# so the triggers are:
#
# push a tag staging-ms-v2.1.0 → build `admin` (ManagedService) → deploy to norbix-ms
# push a tag staging-ent-v2.1.0 → build `enterprise-admin` (Enterprise) → deploy to norbix-enterprise
Expand All @@ -30,7 +32,7 @@ on:
workflow_dispatch:
inputs:
release:
description: 'Release to build & deploy (admin has no self-hosted image)'
description: 'Release to build & deploy (community-admin is not staged here)'
required: true
default: 'managed'
type: choice
Expand Down Expand Up @@ -65,7 +67,7 @@ jobs:
staging-managed-*) RELEASE=managed ; VERSION="${TAG#staging-managed-}" ;;
staging-ms-*) RELEASE=managed ; VERSION="${TAG#staging-ms-}" ;;
staging-self-hosted-*|staging-sh-*)
echo "::notice::Admin has no self-hosted image — nothing to build for '$TAG'."
echo "::notice::Admin self-hosted (community-admin) is not staged by this workflow — nothing to build for '$TAG'."
echo "release=skip" >> "$GITHUB_OUTPUT"; exit 0 ;;
*) echo "::error::Tag '$TAG' must look like staging-<ms|ent>-v<X.Y.Z>"; exit 1 ;;
esac
Expand Down Expand Up @@ -125,8 +127,9 @@ jobs:
platforms: linux/amd64
push: true
tags: ${{ steps.ref.outputs.image_ref }}
build-args: |
VITE_ADMIN_RELEASE=${{ needs.plan.outputs.flavor }}
# No edition build arg: the Dockerfile has none and the portal reads
# the edition at runtime (/echo `release`), so every flavour is the
# same build. `flavor` only scopes the cache.
cache-from: type=gha,scope=staging-${{ needs.plan.outputs.flavor }}
cache-to: type=gha,mode=max,scope=staging-${{ needs.plan.outputs.flavor }}

Expand Down
17 changes: 14 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@
// screens keep the same theming. During the phased migration the actual UI is
// still served by the Vite app; this layout is the shell the ported screens
// will mount into.
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import '../src/styles.css';
import { runtimeProjectId } from './lib/runtimeProject';

export const metadata = {
title: 'Admin Portal',
};
// Per request (the catch-all page is force-dynamic), so PROJECT_ID is read
// from the RUNTIME server env, not baked in at build: one public image works
// for any self-hosted customer. Emitted as <meta name="norbix-project">, which
// the client resolver reads (src/config/project.ts). Unset → no tag, and the
// project comes from the host label as before (managed).
export function generateMetadata(): Metadata {
const projectId = runtimeProjectId(process.env.PROJECT_ID);
return {
title: 'Admin Portal',
...(projectId ? { other: { 'norbix-project': projectId } } : {}),
};
}

export default function RootLayout({ children }: { children: ReactNode }) {
return (
Expand Down
28 changes: 28 additions & 0 deletions app/lib/runtimeProject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { runtimeProjectId } from './runtimeProject';
import { viewIdFromHex } from '@/config/project';

describe('runtimeProjectId', () => {
it('returns null when unset or blank', () => {
expect(runtimeProjectId(undefined)).toBeNull();
expect(runtimeProjectId('')).toBeNull();
expect(runtimeProjectId(' ')).toBeNull();
});

it('keeps a pr_{base62} view id (trimmed)', () => {
expect(runtimeProjectId(' pr_5R4dlqJeXx943tOzSDEwbS ')).toBe(
'pr_5R4dlqJeXx943tOzSDEwbS',
);
});

it('converts a pr-{hex} host label to the view id', () => {
const hex = '4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2';
expect(runtimeProjectId(`pr-${hex}`)).toBe(viewIdFromHex(hex));
});

it('rejects anything else', () => {
expect(runtimeProjectId('my-project')).toBeNull();
expect(runtimeProjectId('pr_"><script>')).toBeNull();
expect(runtimeProjectId('pr-1234')).toBeNull();
});
});
24 changes: 24 additions & 0 deletions app/lib/runtimeProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Runtime project pin for self-hosted installs (server env, read per request).
//
// NEXT_PUBLIC_ADMIN_PROJECT_ID is inlined into the browser bundle at BUILD
// time, so a public image (community-admin) cannot carry a customer's project.
// Instead the server reads PROJECT_ID at request time and the root layout
// emits it as <meta name="norbix-project" content="…">, which the client
// resolver already reads (src/config/project.ts, step 3). One image, any
// customer, any domain.
import { parseProjectIdFromHost } from '@/config/project';

// The gateway's ProjectId.ViewId: "pr_" + base62.
const VIEW_ID = /^pr_[0-9A-Za-z]{1,40}$/;

/**
* Normalise a PROJECT_ID env value. Accepts the view id (`pr_{base62}`) or the
* host label (`pr-{32 hex}`, converted to the view id). Anything else → null,
* so a typo never reaches the gateway as a project header.
*/
export function runtimeProjectId(raw: string | undefined): string | null {
const value = raw?.trim();
if (!value) return null;
if (VIEW_ID.test(value)) return value;
return parseProjectIdFromHost(value);
}
22 changes: 16 additions & 6 deletions deployments/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,26 @@
# HUB_BASE_URL — e.g. https://hub.norbix.ai
# API_BASE_URL — e.g. https://api.norbix.ai
# ENV — optional Norbix environment (staging/test); unset = PROD
# PROJECT_ID — self-hosted: the project this portal serves (pr_{base62}
# or pr-{32 hex}). Emitted per request as a meta tag, so one
# public image works for any customer on any domain.
#
# There is NO edition build arg: Community / ManagedService / Enterprise is
# decided at RUNTIME (the Hub's /echo `release`, and API_KEY present =
# self-hosted). The same build is published as admin, enterprise-admin and
# community-admin.
#
# Browser values (NEXT_PUBLIC_*) are the exception: Next inlines them into the
# bundle at BUILD time, so they are build args, not runtime env:
# NEXT_PUBLIC_ADMIN_PROJECT_ID — pin one project (self-hosted / custom
# domain); skips host-label resolution
# bundle at BUILD time, so they are build args, not runtime env. Leave them
# EMPTY for any published image (they would pin every user of the image):
# NEXT_PUBLIC_ADMIN_PROJECT_ID — private single-project builds only;
# prefer the runtime PROJECT_ID
# NEXT_PUBLIC_ADMIN_CONFIG_MODE — 'dynamic' (default) or 'static'
#
# docker build -f deployments/Dockerfile -t norbix-admin \
# --build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_xxxx .
# docker run -p 3100:3100 -e API_KEY=... norbix-admin
# docker build -f deployments/Dockerfile -t norbix/community-admin .
# docker run -p 3100:3100 -e PROJECT_ID=pr_xxxx -e API_KEY=... \
# -e HUB_BASE_URL=https://hub.example.com \
# -e API_BASE_URL=https://api.example.com norbix/community-admin

ARG NODE_VERSION=22

Expand Down
83 changes: 83 additions & 0 deletions docs/tasks/community-admin-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Task: community-admin image (self-hosted, public-safe)

Branch `feat/community-admin-image` (worktree `worktrees/admin/feat/community-admin-image`).
Not pushed, no PR, no image published.

## Goal
A clean, public-safe `community-admin` Docker image for the self-hosted edition:
no secrets / test files / source maps in it, and all customer config supplied at
runtime so one image works for any customer on any domain.

## Plan
1. Find whether Admin behaves differently per edition at build time.
2. Harden `.dockerignore`.
3. Make sure customer config (API URL, project id, API key) is runtime, not baked.
4. Verify (lint / format / typecheck / tests / next build / docker build + FS scan).

## Changes
- fix(docker): no edition build arg exists — removed the dead `VITE_ADMIN_RELEASE`
build arg from `.github/workflows/staging.yml` and `docker-image.yml`; fixed the
"admin has no self-hosted image" wording — done
- feat(docker): harden `.dockerignore` (env variants, keys, tests, artefacts,
`.next`, editor/CI dirs, Vite-era files, docs) — done
- feat(docker): runtime `PROJECT_ID` → `<meta name="norbix-project">` emitted by
`app/layout.tsx` (`generateMetadata`, per request), validated by
`app/lib/runtimeProject.ts` (+ 4 unit tests) — done
- docs: Dockerfile header, `.env.example`, `src/config/project.ts` resolver
comment — done

## Findings
1. Edition is decided at RUNTIME, never at build time — so the community image is
the same build, tagged `community-admin`.
```ts
// src/App.tsx
isManaged = echo.release === 'ManagedService'; // <-- here: from the Hub's /echo
```
```ts
// app/api/structure/route.ts
// API_KEY unset → managed → call the PRIVATE internal endpoint // <-- here: key presence = self-hosted
```
2. The staging/CI workflows passed a build arg nothing reads:
```yaml
build-args: |
VITE_ADMIN_RELEASE=${{ needs.plan.outputs.flavor }} # <-- here: no ARG in Dockerfile, no reader in code
```
3. The project id was build-time only for self-hosted custom domains:
```ts
// src/config/env.ts
? process.env.NEXT_PUBLIC_ADMIN_PROJECT_ID // <-- here: inlined at `next build`
```
Without it a self-hosted portal on `admin.customer.com` falls through to
`resolveProjectIdByHost`, which calls the MANAGED hub (hub.norbix.ai) from the
browser and gets 404 → "no project". Fixed with the runtime `PROJECT_ID`.
4. Server config is already runtime (read by the standalone Node server; all
routes are `force-dynamic`): `API_KEY`, `HUB_BASE_URL`, `API_BASE_URL`,
`HUB_VERSION`, `API_VERSION`, `ENV`.
5. Next loads `.env*` files at build and inlines `NEXT_PUBLIC_*` from them, so an
`.env.production` / `.env.local` in the context would pin every user of a
public image. `.dockerignore` now drops `.env` and `.env.*`.
6. `tsconfig.tsbuildinfo` is tracked in git and rewritten by every build (now
excluded from the Docker context; untracking it is a separate cleanup).

## Rejected
- An edition build arg (e.g. `NEXT_PUBLIC_ADMIN_RELEASE`): no code would read it;
the runtime /echo is the source of truth.
- Re-ordering the resolver so the meta tag beats the host label: not needed (a
self-hosted domain never matches `pr-<32 hex>`), and it would touch managed.
- Adding a `staging-sh-*` → community-admin path to staging.yml: devops has no
self-hosted staging target for admin yet (see Needs you).

## Needs you
- devops `k8s/deployments/self-hosted/admin.yaml` is stale for the Next image:
`containerPort: 8080` + `/healthz` probes (image listens on 3100, no /healthz
route — `/` works), image `norbix/admin:local` (should be
`norbix/community-admin:<tag>`).
- devops `k8s/config-maps/admin.yaml` uses Vite-era names
(`VITE_ADMIN_RELEASE`, `VITE_ADMIN_HUB_BASE_URL`, `VITE_ADMIN_API_BASE_URL`…)
that the Next server does not read. Self-hosted needs `HUB_BASE_URL`,
`API_BASE_URL`, `PROJECT_ID`, optional `ENV`, and `API_KEY` from a Secret.
- A publish job for `community-admin` (none exists in this repo).

## Open questions
- Should a self-hosted portal without `PROJECT_ID` skip the managed-hub
custom-domain lookup (browser call to hub.norbix.ai)? Today it still tries.
4 changes: 3 additions & 1 deletion src/config/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
// 2. The managed host label — pr-{32 lower-case hex}.admin.norbix.ai, the
// gateway's ProjectId.HostLabel (DNS-safe: no '_', one case). It is turned
// back into the pr_{base62} id (ProjectId.ViewId) the gateway reads.
// 3. A <meta name="norbix-project"> tag, if an edge/host injected one.
// 3. A <meta name="norbix-project"> tag — emitted by the root layout from
// the RUNTIME server env PROJECT_ID (self-hosted public image, see
// app/lib/runtimeProject.ts), or injected by an edge/host.
// 4. Custom domain (e.g. admin.laimingaspilvukas.lt): the host carries no
// pr- label, so ASK the managed service — GET hub.norbix.ai/{v}/admin-portal-id
// ?host=<host> → { projectId } or 404. Only the managed-service Hub answers
Expand Down
Loading