Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
0473854
feat(hub): persist identity records and credentials
Copilot Oct 26, 2026
41943d5
merge: reconcile latest Hub baseline
Oct 27, 2026
8762ec5
feat(hub): establish provider-neutral identity persistence
Oct 27, 2026
9e734c9
fix(hub): harden authentication state handling
Oct 27, 2026
d6a6ebf
fix(hub): evaluate attempt expiry after lock
Oct 27, 2026
506611c
feat(hub): add GitHub user authentication
Oct 27, 2026
a324854
fix(hub): harden auth request boundaries
Oct 27, 2026
e611260
fix(hub): omit auth payloads before parsing
Oct 27, 2026
b4b9597
fix(hub): align session revocation freshness
Oct 28, 2026
d8e9c63
feat(hub): add installation membership administration
Oct 27, 2026
c3e12ce
fix(hub): audit enrollment denials
Oct 27, 2026
4742702
feat(hub): add prototype authorization
Oct 27, 2026
7d2d642
fix(hub): serialize prototype administration
Oct 27, 2026
6ef4732
fix(hub): isolate prototype list access
Oct 28, 2026
ec19177
fix(hub): defer baseline authorization
Oct 28, 2026
c5b75af
fix(hub): address authorization review
Oct 28, 2026
530a1d5
fix(hub): bound authorization pagination
Oct 28, 2026
1127b27
test(hub): cover authorization boundaries
Oct 29, 2026
028966a
fix(hub): scope authorization cursors
Oct 29, 2026
5000059
feat(hub): synchronize GitHub authorization
Oct 27, 2026
9456999
fix(hub): reject stale GitHub sync results
Oct 27, 2026
19b4ffe
fix(hub): preserve synchronized access boundaries
Oct 28, 2026
4272385
fix(hub): close synchronized authorization gaps
Oct 28, 2026
dceca17
fix(hub): invalidate disproved GitHub grants
Oct 28, 2026
9a7ea50
fix(hub): isolate GitHub admission attempts
Oct 29, 2026
8cf7064
fix(hub): order synchronized permission updates
Oct 29, 2026
4bc4b93
fix(hub): preserve reconciliation ordering
Oct 29, 2026
125e8e8
fix(hub): close synchronized revocation races
Oct 29, 2026
0bb8f67
feat(hub): add provider-neutral artifact ingestion
Oct 27, 2026
2a834df
fix(hub): harden artifact publishing boundaries
Oct 27, 2026
4e7b84a
fix(hub): recheck publisher authorization under lock
Oct 27, 2026
02eedd0
fix(hub): address artifact ingestion review
Oct 28, 2026
1cfba71
test(hub): preserve baseline transaction coverage
Oct 28, 2026
265bf23
fix(hub): integrate final authorization parent
Oct 28, 2026
e36923e
fix(hub): harden artifact durability and recovery
Oct 28, 2026
90ba909
test(hub): align artifact tests with final parent
Oct 29, 2026
ef9d710
fix(hub): close artifact credential and storage gaps
Oct 29, 2026
5fea929
fix(hub): normalize artifact publishing inputs
Oct 29, 2026
1a3457d
feat(hub): add production OIDC authentication
Oct 27, 2026
0b5d1cb
fix(hub): normalize OIDC dependency source
Oct 27, 2026
b445e22
fix(hub): harden OIDC configuration lifecycle
Oct 28, 2026
a078d7d
fix(hub): accept long OIDC callback codes
Oct 28, 2026
890393f
docs(hub): note Entra setup pilot status
Oct 28, 2026
0c35887
test(hub): align OIDC fixtures with identity store
Oct 28, 2026
7bbd3ac
fix(hub): address OIDC review feedback
Oct 28, 2026
23e6423
test(hub): configure runtime provider fixture
Oct 28, 2026
e80bd6c
fix(hub): close OIDC review gaps
Oct 28, 2026
962bf53
fix(hub): preserve OIDC role ownership
Oct 28, 2026
bb704dc
fix(hub): defer OIDC revision persistence
Oct 29, 2026
1f0e211
fix(hub): harden OIDC invitation roles
Oct 29, 2026
7f2840a
fix(hub): allow devcontainer OIDC override
Oct 29, 2026
55620fb
fix(hub): preserve Entra identity continuity
Oct 29, 2026
e127a30
fix(hub): require fresh OIDC verification
Oct 29, 2026
31174e3
fix(hub): validate Entra authentication time
Oct 29, 2026
bdc01d6
fix(hub): require OIDC verification freshness
Oct 29, 2026
2167593
fix(hub): start OIDC admin verification
Oct 29, 2026
12b0b5e
fix(hub): recover from OIDC role changes
Oct 29, 2026
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
12 changes: 11 additions & 1 deletion .github/workflows/hub-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ jobs:
fail-fast: false
matrix:
node: [24, 26]
command: [lint, typecheck, test, build, storybook:build, "db:validate", "test:database"]
command:
[
lint,
typecheck,
test,
"test:entra-setup",
build,
storybook:build,
"db:validate",
"test:database",
]
services:
postgres:
image: ${{ matrix.command == 'test:database' && 'postgres:18.6-bookworm' || '' }}
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ npm run lint
npm run db:validate
npm run typecheck
npm test
npm run test:entra-setup
npm run build
npm run storybook:build
```
Expand Down
2 changes: 1 addition & 1 deletion docs/testing-and-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ When a command gains a dependency or build prerequisite, update its local setup

All CI workflows run on every pull request and push to `main`. Do not skip checks based on changed paths or add jobs that only combine other jobs' results. Configure branch protection to require the individual checks.

Hub reports lint, types, unit tests, database tests, the frontend/API build, Storybook, and Controls/extension checks separately. Only the database test command starts PostgreSQL. The new Hub has no production-image or release jobs.
Hub reports lint, types, unit tests, Entra setup-script tests, database tests, the frontend/API build, Storybook, and Controls/extension checks separately. Only the database test command starts PostgreSQL. The new Hub has no production-image or release jobs.

Maintainers must update branch rules when required checks change. Editing a workflow does not update those rules.

Expand Down
4 changes: 4 additions & 0 deletions hub/.devcontainer/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ services:
working_dir: /workspaces/replica/hub
volumes:
- ../..:/workspaces/replica:cached
env_file:
- path: default.env
- path: ../.env
required: false
environment:
DATABASE_URL: postgresql://replica:replica@db:5432/replica
TEST_DATABASE_URL: postgresql://replica:replica@db:5432/postgres
Expand Down
1 change: 1 addition & 0 deletions hub/.devcontainer/default.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
HUB_AUTH_PROVIDER=github
57 changes: 57 additions & 0 deletions hub/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,65 @@ PORT=3001
# API logging threshold: trace, debug, info, warn, error, fatal, or silent.
LOG_LEVEL=info

# Public browser origin. Production requires HTTPS; local HTTP must use loopback.
HUB_ORIGIN=http://127.0.0.1:5173

# Select exactly one user sign-in provider. Hub fails closed when this is absent.
HUB_AUTH_PROVIDER=github

# Standards-based OIDC. The first documented profile is single-tenant Microsoft Entra ID.
# The credential reference names another environment variable; Hub never stores the secret.
# HUB_OIDC_ENABLED=true
# HUB_OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
# HUB_OIDC_CLIENT_ID=
# HUB_OIDC_CALLBACK_URL=https://hub.example.com/api/auth/oidc/callback
# HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE=HUB_OIDC_CLIENT_SECRET
# HUB_OIDC_CLIENT_SECRET=
# HUB_OIDC_MEMBER_ROLE=Hub.User
# HUB_OIDC_ADMINISTRATOR_ROLE=Hub.Administrator
# HUB_OIDC_SCOPES=openid profile email
# HUB_OIDC_MEMBERSHIP_POLICY=jit-assignment
# HUB_OIDC_PROVIDER_REVISION=1

# Keep 0 unless the API is reachable only through this exact number of trusted proxies.
HUB_TRUSTED_PROXY_HOPS=0

# Local development only. The devcontainer supplies its own database hostname.
DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica

# Only database-enabled tests require this URL and CREATEDB permission.
TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres

# Local reference artifact storage. The default is ~/.replica/artifacts, outside the checkout.
# Production deployments should mount durable storage here.
# HUB_ARTIFACT_STORAGE_PATH=/var/lib/replica/artifacts

# GitHub user authorization is optional until both values are configured.
# These credentials establish user identity and are separate from source integration.
# HUB_GITHUB_AUTH_CLIENT_ID=
# HUB_GITHUB_AUTH_CLIENT_SECRET=

# GitHub proves identity by default. github-synchronized admits users through explicitly
# linked repository sources. legacy-repository preserves the earlier compatibility behavior.
HUB_ADMISSION_POLICY=hub-managed

# Generate separate host-held values. Bootstrap can enroll the first administrator once.
# Recovery works only when no active administrator remains and never reopens bootstrap.
# HUB_BOOTSTRAP_SECRET=<openssl rand -hex 32>
# HUB_ADMIN_RECOVERY_SECRET=<openssl rand -hex 32>

# Select the GitHub source-integration credential provider. See docs/identity.md.
HUB_GITHUB_CREDENTIAL_PROVIDER=database

# Generate separate values outside the database. See docs/identity.md.
# HUB_GITHUB_CREDENTIAL_KEY=<openssl rand -hex 32>

# The environment provider is read-only and requires every source-integration setting below.
# GITHUB_APP_ID=
# GITHUB_INSTALLATION_ID=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# GITHUB_PRIVATE_KEY=

# Required only for /api/github/webhooks. Keep it separate from user-auth credentials.
# HUB_GITHUB_WEBHOOK_SECRET=<GitHub App webhook secret>
1 change: 1 addition & 0 deletions hub/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/log
.local
.npm
.devcontainer/default.env
.storybook/instances
.storybook/settings.json
node_modules
Expand Down
4 changes: 2 additions & 2 deletions hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub.

The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API.
The API exposes its health check, GitHub or production OIDC authentication, sessions, installation membership administration, prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend provides configured-provider sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs.

Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, saved data, live updates, background jobs, and deployment are not implemented.
Contributors can use PostgreSQL and Prisma locally or in the devcontainer. One configured GitHub or OIDC provider proves user identity. Hub-owned admission, optional GitHub-synchronized authorization with Hub overrides, invitations, membership roles and lifecycle, administrator recovery, session revocation, prototype metadata, and artifact ingestion are implemented. Previews, reviews, SCIM, live updates, general background-job infrastructure, and deployment are not implemented.

Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior.

Expand Down
175 changes: 175 additions & 0 deletions hub/app/javascript/application/authentication/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { ThemeProvider } from "@primer/react-brand"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { AuthenticationApplication } from "."

describe("AuthenticationApplication()", () => {
it("offers provider-role recovery through the configured sign-in flow", async () => {
window.history.replaceState({}, "", "/?authentication=failed&reason=provider_role_changed")
const authorizationUrl = "#provider-role-recovery"
const session = {
authenticated: true,
configured: true,
provider: "oidc",
providerLabel: "Company SSO",
csrfToken: "session-csrf-token",
user: { id: "administrator", displayName: "Hub administrator" },
membership: { id: "administrator-membership", role: "administrator", status: "active" },
}
const administration = { memberships: [], invitations: [] }
const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (input === "/api/session" && init?.method === undefined) {
return Promise.resolve(Response.json(session))
}
if (input === "/api/administration" && init?.method === undefined) {
return Promise.resolve(Response.json(administration))
}
if (input === "/api/auth/oidc") {
return Promise.resolve(Response.json({ authorizationUrl }))
}
return Promise.reject(new Error("Unexpected request"))
})
vi.stubGlobal("fetch", fetch)
render(
<ThemeProvider>
<AuthenticationApplication />
</ThemeProvider>,
)

expect(await screen.findByRole("alert")).toHaveTextContent(
"Your provider role changed. Sign in again to update your Hub access.",
)
fireEvent.click(screen.getByRole("button", { name: "Sign in again" }))

await waitFor(() => expect(window.location.hash).toBe(authorizationUrl))
expect(fetch).toHaveBeenCalledWith("/api/auth/oidc", {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRF-Token": "session-csrf-token" },
})
})

it("starts fresh verification instead of presenting a failed administration mutation as success", async () => {
const authorizationUrl = "#provider-authorization"
const session = {
authenticated: true,
configured: true,
provider: "github",
providerLabel: "GitHub",
csrfToken: "session-csrf-token",
user: { id: "administrator", displayName: "Hub administrator" },
membership: { id: "administrator-membership", role: "administrator", status: "active" },
}
const administration = {
memberships: [
{
id: "member-membership",
status: "active",
role: "member",
user: {
id: "member",
displayName: "Hub member",
externalIdentities: [],
},
},
],
invitations: [],
}
const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (input === "/api/session" && init?.method === undefined) {
return Promise.resolve(Response.json(session))
}
if (input === "/api/administration" && init?.method === undefined) {
return Promise.resolve(Response.json(administration))
}
if (input === "/api/administration/memberships/member-membership") {
return Promise.resolve(
Response.json(
{ error: "fresh_authorization_required", message: "Verify before continuing." },
{ status: 403 },
),
)
}
if (input === "/api/session/verify") {
return Promise.resolve(Response.json({ authorizationUrl }))
}
return Promise.reject(new Error("Unexpected request"))
})
vi.stubGlobal("fetch", fetch)
render(
<ThemeProvider>
<AuthenticationApplication />
</ThemeProvider>,
)
const member = (await screen.findByText("Hub member")).closest("li")!

fireEvent.click(screen.getByRole("button", { name: "Suspend" }))

await waitFor(() => expect(window.location.hash).toBe(authorizationUrl))
expect(fetch).toHaveBeenCalledWith("/api/session/verify", {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRF-Token": "session-csrf-token" },
})
expect(member).toHaveTextContent("member · active")
expect(fetch.mock.calls.filter(([input]) => input === "/api/session")).toHaveLength(1)
})

it("shows a useful error when fresh verification cannot start", async () => {
const session = {
authenticated: true,
configured: true,
provider: "github",
providerLabel: "GitHub",
csrfToken: "session-csrf-token",
user: { id: "administrator", displayName: "Hub administrator" },
membership: { id: "administrator-membership", role: "administrator", status: "active" },
}
const administration = {
memberships: [
{
id: "member-membership",
status: "active",
role: "member",
user: {
id: "member",
displayName: "Hub member",
externalIdentities: [],
},
},
],
invitations: [],
}
const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (input === "/api/session" && init?.method === undefined) {
return Promise.resolve(Response.json(session))
}
if (input === "/api/administration" && init?.method === undefined) {
return Promise.resolve(Response.json(administration))
}
if (input === "/api/administration/memberships/member-membership") {
return Promise.resolve(
Response.json({ error: "fresh_authorization_required" }, { status: 403 }),
)
}
if (input === "/api/session/verify") {
return Promise.resolve(Response.json({ error: "provider_unavailable" }, { status: 503 }))
}
return Promise.reject(new Error("Unexpected request"))
})
vi.stubGlobal("fetch", fetch)
render(
<ThemeProvider>
<AuthenticationApplication />
</ThemeProvider>,
)
await screen.findByText("Hub member")
const currentUrl = window.location.href

fireEvent.click(screen.getByRole("button", { name: "Suspend" }))

expect(await screen.findByRole("alert")).toHaveTextContent("Hub could not start verification.")
expect(window.location.href).toBe(currentUrl)
})
})
Loading
Loading