feat: Hanzo binding packages — @hanzo/cms-plugin-whitelabel + @hanzo/cms-auth-iam

@hanzo/cms-plugin-whitelabel:
- brand-by-domain resolution (resolveBrand: longest-suffix match, '*' fallback)
- injects logo/icon/title/favicon/og/theme per brand; neutral when unmatched
- shared-infra white-label rule (never hardcodes a brand)

@hanzo/cms-auth-iam:
- hanzoIAMStrategy: verifies Hanzo IAM (Casdoor RS256) tokens via JWKS (jose, no shared secret)
- org == tenant: maps IAM 'owner' claim -> tenant, provisions tenant + user, sets payload-tenant cookie
- iamAuthFields helper (iamSub unique, iamOrg, username)
- IAM = sole identity authority (no separate CMS login)

tsconfig paths registered; removed dangling @hanzo/cms-figma enterprise path
This commit is contained in:
Hanzo AI
2026-07-01 18:03:43 -07:00
parent f2bee6e36b
commit edeaabe83a
19 changed files with 672 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json
+12
View File
@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2025 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@hanzo/cms-auth-iam",
"version": "1.0.0",
"description": "Hanzo IAM SSO auth strategy for Hanzo CMS. Verifies IAM (Casdoor) access tokens via JWKS; org == tenant. No separate CMS login.",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:debug": "pnpm build",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf -g {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@hanzo/cms": "workspace:*",
"jose": "5.10.0"
},
"devDependencies": {
"@hanzo/cms-eslint-config": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Field } from '@hanzo/cms'
export type { HanzoIAMStrategyConfig, IAMClaims } from './types.js'
export { hanzoIAMStrategy } from './strategy.js'
/**
* Fields the IAM strategy needs on the auth collection to map + dedupe users.
* Spread these into your users collection `fields`.
*
* fields: [...iamAuthFields, ...yourFields]
*/
export const iamAuthFields: Field[] = [
{
name: 'iamSub',
type: 'text',
admin: { readOnly: true, description: 'Hanzo IAM subject (user id).' },
index: true,
label: 'IAM Subject',
unique: true,
},
{
name: 'iamOrg',
type: 'text',
admin: { readOnly: true, description: 'Hanzo IAM org slug (== tenant).' },
index: true,
label: 'IAM Org',
},
{
name: 'username',
type: 'text',
label: 'Username',
},
]
+172
View File
@@ -0,0 +1,172 @@
import type { AuthStrategy, AuthStrategyFunctionArgs, AuthStrategyResult, Payload } from '@hanzo/cms'
import { createRemoteJWKSet, jwtVerify } from 'jose'
import type { HanzoIAMStrategyConfig, IAMClaims } from './types.js'
const DEFAULT_ISSUER = 'https://hanzo.id'
const DEFAULT_JWKS = 'https://hanzo.id/v1/iam/.well-known/jwks'
/** Lazily-built, per-JWKS-URL cached remote key set (jose caches internally). */
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
const getJWKS = (uri: string) => {
let set = jwksCache.get(uri)
if (!set) {
set = createRemoteJWKSet(new URL(uri))
jwksCache.set(uri, set)
}
return set
}
const getBearer = (headers: AuthStrategyFunctionArgs['headers']): null | string => {
const raw = headers.get('authorization') || headers.get('Authorization')
if (!raw) {
return null
}
const [scheme, token] = raw.split(' ')
if (!token || scheme.toLowerCase() !== 'bearer') {
return null
}
return token.trim()
}
/**
* Provision-or-refresh the tenant doc for an IAM org (org == tenant), returning
* its id. Idempotent: keyed on the org slug.
*/
const ensureTenant = async (args: {
claims: IAMClaims
payload: Payload
tenantsSlug: string
}): Promise<number | string | undefined> => {
const { claims, payload, tenantsSlug } = args
const slug = claims.owner
if (!slug) {
return undefined
}
// Only touch the collection if it actually exists in this config.
if (!payload.collections?.[tenantsSlug]) {
return undefined
}
const existing = await payload.find({
collection: tenantsSlug,
depth: 0,
limit: 1,
where: { slug: { equals: slug } },
})
if (existing.docs.length > 0) {
return existing.docs[0].id as number | string
}
const created = await payload.create({
collection: tenantsSlug,
data: { name: slug, slug },
})
return created.id as number | string
}
/**
* The Hanzo IAM SSO auth strategy.
*
* Validates a Bearer access token issued by Hanzo IAM (Casdoor, RS256) against
* the published JWKS — no shared secret, no per-request round-trip to IAM. Maps
* the verified claims to a Payload user (find-or-provision by IAM `sub`), links
* the user to the tenant derived from the IAM `owner` org (org == tenant), and
* sets the `payload-tenant` cookie so the multi-tenant plugin scopes every
* subsequent query to that org.
*
* IAM is the sole identity authority: there is no separate CMS login.
*/
export const hanzoIAMStrategy = (config: HanzoIAMStrategyConfig = {}): AuthStrategy => {
const name = config.name || 'hanzo-iam'
const authSlug = config.authSlug || 'users'
const tenantsSlug = config.tenantsSlug || 'tenants'
const tenantsArrayField = config.tenantsArrayField || 'tenants'
const issuer = config.issuer || process.env.HANZO_IAM_ISSUER || DEFAULT_ISSUER
const jwksUri = config.jwksUri || process.env.HANZO_IAM_JWKS_URI || DEFAULT_JWKS
return {
name,
authenticate: async ({
canSetHeaders,
headers,
payload,
}: AuthStrategyFunctionArgs): Promise<AuthStrategyResult> => {
const token = getBearer(headers)
if (!token) {
return { user: null }
}
let claims: IAMClaims
try {
const { payload: verified } = await jwtVerify(token, getJWKS(jwksUri), {
issuer,
})
claims = verified as IAMClaims
} catch {
// invalid / expired / wrong-issuer token → anonymous
return { user: null }
}
if (!claims.sub) {
return { user: null }
}
const tenantID = await ensureTenant({ claims, payload, tenantsSlug })
// find-or-provision the user by IAM subject
const found = await payload.find({
collection: authSlug,
depth: 0,
limit: 1,
where: { iamSub: { equals: claims.sub } },
})
const baseData = {
email: claims.email || `${claims.sub}@iam.local`,
iamOrg: claims.owner,
iamSub: claims.sub,
username: claims.name,
...(config.claimsToUser ? config.claimsToUser(claims) : {}),
...(tenantID !== undefined
? { [tenantsArrayField]: [{ tenant: tenantID }] }
: {}),
}
let userDoc
if (found.docs.length > 0) {
userDoc = await payload.update({
id: found.docs[0].id,
collection: authSlug,
data: baseData,
})
} else {
userDoc = await payload.create({
collection: authSlug,
data: baseData,
})
}
const responseHeaders = new Headers()
if (canSetHeaders && tenantID !== undefined) {
responseHeaders.append(
'Set-Cookie',
`payload-tenant=${encodeURIComponent(String(tenantID))}; Path=/; SameSite=Lax; HttpOnly`,
)
}
return {
responseHeaders: canSetHeaders ? responseHeaders : undefined,
user: {
...userDoc,
_strategy: name,
collection: authSlug,
},
}
},
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Claims we rely on from a Hanzo IAM (Casdoor) access token.
* `owner` is the org slug — in Hanzo, org == tenant. There is no separate CMS
* tenant concept; the IAM org IS the tenancy boundary.
*/
export type IAMClaims = {
aud?: string | string[]
email?: string
exp?: number
iss?: string
/** username */
name?: string
/** org slug — the tenant */
owner?: string
/** user id (uuid) */
sub?: string
}
export type HanzoIAMStrategyConfig = {
/**
* Slug of the Payload auth collection users are mapped into (e.g. 'users').
*/
authSlug?: string
/**
* Extra fields to set/refresh on the mapped user each login. Receives the
* verified claims; returns a partial user document.
*/
claimsToUser?: (claims: IAMClaims) => Record<string, unknown>
/**
* Expected token issuer. Defaults to HANZO_IAM_ISSUER or https://hanzo.id.
*/
issuer?: string
/**
* JWKS URL. Defaults to HANZO_IAM_JWKS_URI or
* https://hanzo.id/v1/iam/.well-known/jwks.
*/
jwksUri?: string
/**
* Strategy name surfaced to Payload. Defaults to 'hanzo-iam'.
*/
name?: string
/**
* Name of the tenant field on the user (link user -> tenant). When the
* multi-tenant plugin is wired, this is the array field it manages.
* Defaults to 'tenants'.
*/
tenantsArrayField?: string
/**
* Slug of the tenants collection (org == tenant). Defaults to 'tenants'.
* A tenant doc is provisioned per IAM org on first login.
*/
tenantsSlug?: string
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../payload" }],
"compilerOptions": {
// Do not include DOM and DOM.Iterable as this is a server-only package.
"lib": ["ES2022"],
}
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json
@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2025 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@hanzo/cms-plugin-whitelabel",
"version": "1.0.0",
"description": "Brand-neutral / white-label theming for Hanzo CMS. Resolves brand (logo, icon, name, colors) by domain; neutral by default.",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types": {
"import": "./src/types.ts",
"types": "./src/types.ts",
"default": "./src/types.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:debug": "pnpm build",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf -g {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@hanzo/cms": "workspace:*"
},
"devDependencies": {
"@hanzo/cms-eslint-config": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"import": "./dist/types.js",
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { Config } from '@hanzo/cms'
import type { WhiteLabelPluginConfig } from './types.js'
import { resolveBrand } from './resolveBrand.js'
export type { Brand, WhiteLabelPluginConfig } from './types.js'
export { brandThemeCSS, resolveBrand } from './resolveBrand.js'
/**
* White-label plugin.
*
* Resolves the active brand for THIS deployment (by `hostEnvVar`, defaulting to
* HANZO_CMS_HOST / VERCEL_URL) and injects the brand's logo, icon, name and
* meta into the admin config. When nothing resolves, the config is returned
* untouched — the fork's brand-neutral defaults apply.
*
* This is the shared-infra white-label rule (like KMS/IAM/Base): one product,
* many brands, selected by domain. Never hardcodes any single brand.
*
* For a SINGLE deployment serving MULTIPLE brands, point `admin.components.
* graphics.Logo/Icon` at your own dispatcher components and call `resolveBrand`
* inside them with the per-request host — this plugin's static injection then
* only supplies the fallback.
*/
export const whiteLabelPlugin =
(pluginConfig: WhiteLabelPluginConfig) =>
(incomingConfig: Config): Config => {
if (pluginConfig.enabled === false) {
return incomingConfig
}
const host =
(pluginConfig.hostEnvVar ? process.env[pluginConfig.hostEnvVar] : undefined) ??
process.env.HANZO_CMS_HOST ??
process.env.VERCEL_URL ??
undefined
const brand = resolveBrand(pluginConfig.brands, host)
// Nothing to apply → keep neutral defaults.
if (!brand) {
return incomingConfig
}
const config = incomingConfig
config.admin = config.admin || {}
config.admin.components = config.admin.components || {}
config.admin.components.graphics = config.admin.components.graphics || {}
config.admin.meta = config.admin.meta || {}
if (brand.logo) {
config.admin.components.graphics.Logo = brand.logo
}
if (brand.icon) {
config.admin.components.graphics.Icon = brand.icon
}
if (brand.name) {
config.admin.meta.titleSuffix = `${brand.name}`
}
if (brand.favicon) {
config.admin.meta.icons = [{ type: 'image/svg+xml', rel: 'icon', url: brand.favicon }]
}
if (brand.ogImage) {
config.admin.meta.openGraph = {
...(config.admin.meta.openGraph || {}),
images: [{ url: brand.ogImage }],
}
}
return config
}
@@ -0,0 +1,45 @@
import type { Brand } from './types.js'
/**
* Pick the brand for a given host. Longest matching hostname suffix wins;
* '*' is the fallback. Returns `undefined` when nothing matches and no '*'
* fallback exists — callers then use the brand-neutral defaults.
*/
export const resolveBrand = (brands: Brand[], host?: null | string): Brand | undefined => {
const normalized = (host || '').toLowerCase().split(':')[0].trim()
let best: Brand | undefined
let bestLen = -1
let fallback: Brand | undefined
for (const brand of brands) {
for (const hn of brand.hostnames) {
if (hn === '*') {
fallback = fallback ?? brand
continue
}
const h = hn.toLowerCase()
const matches = normalized === h || normalized.endsWith(`.${h}`)
if (matches && h.length > bestLen) {
best = brand
bestLen = h.length
}
}
}
return best ?? fallback
}
/**
* Serialize a brand's theme map into a CSS string for a <style> tag.
* Keys are CSS custom-property names without the leading `--`.
*/
export const brandThemeCSS = (brand?: Brand): string => {
if (!brand?.theme || Object.keys(brand.theme).length === 0) {
return ''
}
const decls = Object.entries(brand.theme)
.map(([k, v]) => ` --${k}: ${v};`)
.join('\n')
return `:root {\n${decls}\n}\n`
}
+45
View File
@@ -0,0 +1,45 @@
/**
* A single brand definition for the white-label registry.
*
* Everything is optional: unset fields fall through to the brand-neutral
* defaults that this fork already ships (no product name, abstract mark).
*/
export type Brand = {
/**
* Hostnames this brand owns. Matched against the request Host header
* (exact or suffix). e.g. ['maxpower.hanzo.cms', 'cms.maxpower.co'].
* The special value '*' makes this the fallback brand.
*/
hostnames: string[]
/** Import-map path to a Logo server component, e.g. '@myorg/brand/Logo'. */
logo?: string
/** Import-map path to an Icon server component. */
icon?: string
/** Human name, appended to admin page titles as "… — {name}". */
name?: string
/** Path to a favicon asset served by the app (e.g. '/brand/favicon.svg'). */
favicon?: string
/** Path to an OpenGraph share image. */
ogImage?: string
/**
* CSS custom properties applied to :root, keyed WITHOUT the leading `--`.
* e.g. { 'theme-elevation-1000': '#0b1220', 'color-success-500': '#12b76a' }.
* Injected as a <style> tag so a brand can theme colors with zero rebuild.
*/
theme?: Record<string, string>
}
export type WhiteLabelPluginConfig = {
/** Set false to disable without unwiring the plugin. */
enabled?: boolean
/**
* The brand registry. Order matters only for readability; resolution is by
* hostname specificity (longest matching suffix wins, then '*').
*/
brands: Brand[]
/**
* Env var holding the current request host when SSR can't read it directly.
* Defaults to reading the standard Host/x-forwarded-host at request time.
*/
hostEnvVar?: string
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../payload" }],
"compilerOptions": {
// Do not include DOM and DOM.Iterable as this is a server-only package.
"lib": ["ES2022"],
}
}
+3 -1
View File
@@ -31,8 +31,10 @@
}
],
"paths": {
"@hanzo/cms-figma": ["../enterprise-plugins/packages/figma/src/index.ts"],
"@payload-config": ["./test/_community/config.ts"],
"@hanzo/cms-auth-iam": ["./packages/auth-iam/src/index.ts"],
"@hanzo/cms-plugin-whitelabel": ["./packages/plugin-whitelabel/src/index.ts"],
"@hanzo/cms-plugin-whitelabel/types": ["./packages/plugin-whitelabel/src/types.ts"],
"@hanzo/cms-admin-bar": ["./packages/admin-bar/src"],
"@hanzo/cms-live-preview": ["./packages/live-preview/src"],
"@hanzo/cms-live-preview-react": ["./packages/live-preview-react/src/index.ts"],