188 Commits
Author SHA1 Message Date
zeekay 89b1b538a5 carry the github copy of dev onto the forge
Docker / docker (push) Failing after 0s
Both hosts had commits on this branch. The mirror pushes every ref at
once, so one branch github is ahead on refuses the whole push, main
included. Merged rather than overwritten.
2026-08-20 22:08:22 -07:00
b32d515027 ci(docker): read version via sed not node (ARC runner lacks node → exit 127) (#18)
Co-authored-by: hanzo-dev <[email protected]>
2026-06-28 20:05:40 -07:00
9bb08c015c ci(docker): read version via sed not node (ARC runner lacks node → exit 127) (#18)
Co-authored-by: hanzo-dev <[email protected]>
2026-06-28 20:05:40 -07:00
c82f6ccdf3 ci(docker): SHA tag from $GITHUB_SHA not git binary (ARC runner lacks git -> exit 127) (#17)
Co-authored-by: hanzo-dev <[email protected]>
2026-06-28 20:01:25 -07:00
3423ec1233 ci(docker): SHA tag from $GITHUB_SHA not git binary (ARC runner lacks git -> exit 127) (#17)
Co-authored-by: hanzo-dev <[email protected]>
2026-06-28 20:01:25 -07:00
zeekay 87ef1ca540 ci(docker): route to ARC pool hanzo-build-linux-amd64 (not offline native runners) 2026-06-28 18:11:29 -07:00
zeekay 3cb0e9a369 ci(docker): route to ARC pool hanzo-build-linux-amd64 (not offline native runners) 2026-06-28 18:11:29 -07:00
zeekay 709bf43bb7 feat(auth): enforce forced TOTP MFA in the portal (RequiredMfa / NextMfa)
IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.

parseLoginResponse now branches on `data` BEFORE the /onboarding return:
  - RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
  - NextMfa     -> { mfaRequired, mfaStage: 'challenge', mfaTypes }

New AuthClient methods drive the flow against the canonical /v1/iam surface:
  getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.

Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.

UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.

Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.
2026-06-28 17:59:39 -07:00
zeekay 1b04366cc6 feat(auth): enforce forced TOTP MFA in the portal (RequiredMfa / NextMfa)
IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.

parseLoginResponse now branches on `data` BEFORE the /onboarding return:
  - RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
  - NextMfa     -> { mfaRequired, mfaStage: 'challenge', mfaTypes }

New AuthClient methods drive the flow against the canonical /v1/iam surface:
  getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.

Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.

UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.

Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.
2026-06-28 17:59:39 -07:00
zeekay 36e4d802ff Merge branch 'feat/sms-consent-disclosure' 2026-06-23 11:32:36 -07:00
zeekay e3edd9c548 Merge branch 'feat/sms-consent-disclosure' 2026-06-23 11:32:36 -07:00
zeekay ef4b59253c feat(auth): A2P SMS consent disclosure on the SMS verification surface
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.

- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
  <SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
  barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
  channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.

Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
2026-06-23 09:38:27 -07:00
zeekay 30714ca1d5 feat(auth): A2P SMS consent disclosure on the SMS verification surface
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.

- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
  <SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
  barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
  channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.

Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
2026-06-23 09:38:27 -07:00
zeekay bc1961c7a9 ci(docker): self-contained build on self-hosted runners
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
2026-06-22 23:30:19 -07:00
zeekay 5eac7f82f2 ci(docker): self-contained build on self-hosted runners
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
2026-06-22 23:30:19 -07:00
zeekay 27fbc58e38 fix(social): real Google endpoint + registered redirect_uri (iam.hanzo.ai/callback)
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.

- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
  (canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
  startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
  SocialButtons passes it so the hop returns to the provider's REGISTERED
  /callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
  completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.

bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
2026-06-22 23:26:35 -07:00
zeekay cf1f0a1aaa fix(social): real Google endpoint + registered redirect_uri (iam.hanzo.ai/callback)
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.

- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
  (canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
  startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
  SocialButtons passes it so the hop returns to the provider's REGISTERED
  /callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
  completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.

bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
2026-06-22 23:26:35 -07:00
zooqueen a73bfb1e16 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
  root should BE the login form when signed out, and the org's apps launcher
  when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
  cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
  to the beginning' after the last (wallet) step's Skip. It now lands on
  '/?signed_in=1' → the authed apps launcher. (The step machine was already
  correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
2026-06-22 22:05:14 -07:00
zooqueen 83a9d84236 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
  root should BE the login form when signed out, and the org's apps launcher
  when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
  cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
  to the beginning' after the last (wallet) step's Skip. It now lands on
  '/?signed_in=1' → the authed apps launcher. (The step machine was already
  correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
2026-06-22 22:05:14 -07:00
zeekay 9861695023 fix(auth): use feat's clean LoginForm/SignupForm (getAppLogin via SocialButtons; password+PKCE) — auto-merge had mixed main's appLogin/sendLoginCode API with feat's client 2026-06-21 19:43:15 -07:00
zeekay 6234fc25e3 fix(auth): use feat's clean LoginForm/SignupForm (getAppLogin via SocialButtons; password+PKCE) — auto-merge had mixed main's appLogin/sendLoginCode API with feat's client 2026-06-21 19:43:15 -07:00
zeekay 0f40882667 fix(docker): COPY pkgs/onboarding/package.json — merged workspace needs it for pnpm install 2026-06-21 19:37:00 -07:00
zeekay bc4f2008e1 fix(docker): COPY pkgs/onboarding/package.json — merged workspace needs it for pnpm install 2026-06-21 19:37:00 -07:00
zeekay e8792136a0 merge(id): integrate feat/full-auth-onboarding — PKCE password-login fix + composable auth UI + social hop into main
# Conflicts:
#	Dockerfile
#	apps/web/src/App.tsx
#	apps/web/src/app.css
#	pkgs/auth/src/client.ts
#	pkgs/auth/src/index.ts
#	pkgs/auth/src/ui/index.ts
#	pkgs/shared/src/brand.ts
#	pkgs/shared/src/tenant.ts
2026-06-21 19:35:05 -07:00
zeekay ffddd6f81c merge(id): integrate feat/full-auth-onboarding — PKCE password-login fix + composable auth UI + social hop into main
# Conflicts:
#	Dockerfile
#	apps/web/src/App.tsx
#	apps/web/src/app.css
#	pkgs/auth/src/client.ts
#	pkgs/auth/src/index.ts
#	pkgs/auth/src/ui/index.ts
#	pkgs/shared/src/brand.ts
#	pkgs/shared/src/tenant.ts
2026-06-21 19:35:05 -07:00
zeekay 47a38896fa feat(social): wire the provider hop + return exchange (gated until creds seeded)
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.

- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).

- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.

- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.

- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
2026-06-20 10:46:26 -07:00
zeekay 5ca4183b36 feat(social): wire the provider hop + return exchange (gated until creds seeded)
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.

- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).

- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.

- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.

- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
2026-06-20 10:46:26 -07:00
zeekay f1a9b57fec fix(login): deliver tenant catalog via /config.json; correct pars/osage/lux client wiring
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.

Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
2026-06-20 02:51:08 -07:00
zeekay 107af15fa7 fix(login): deliver tenant catalog via /config.json; correct pars/osage/lux client wiring
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.

Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
2026-06-20 02:51:08 -07:00
zeekay d2445934b0 fix(tenant): catalog-only hosts resolve their OWN brand; drop orphan divider
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).

Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
2026-06-20 02:38:31 -07:00
zeekay 834cea1f01 fix(tenant): catalog-only hosts resolve their OWN brand; drop orphan divider
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).

Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
2026-06-20 02:38:31 -07:00
zeekay 97610b3f24 fix(login): only show configured social providers; never show a broken brand logo
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):

1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.

2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.

Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
2026-06-20 02:21:35 -07:00
zeekay fad2d03bd2 fix(login): only show configured social providers; never show a broken brand logo
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):

1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.

2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.

Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
2026-06-20 02:21:35 -07:00
zeekay b81c95c76a fix(auth): forward PKCE code_challenge on password login
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).

IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
2026-06-20 01:36:59 -07:00
zeekay afe0b5a1dd fix(auth): forward PKCE code_challenge on password login
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).

IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
2026-06-20 01:36:59 -07:00
zeekay 8b7419c883 fix(docker): bake SPA-appropriate CSP into the image (HANZO_STATIC_CSP)
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
2026-06-20 00:17:30 -07:00
zeekay a5c168753d fix(docker): bake SPA-appropriate CSP into the image (HANZO_STATIC_CSP)
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
2026-06-20 00:17:30 -07:00
zeekay 46e161241a fix(brand): emit + fetch brand.json at flat encoding-safe /brand/<scope>.json
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:

1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
   require.resolve('@scope/brand/brand.json') threw ReferenceError
   (no global require), was swallowed by the silent catch, and NO
   brand.json was ever emitted into dist/. Fixed with
   createRequire(import.meta.url).

2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
   @ and an encoded %2F that the production static server cannot map to
   the on-disk file, so it falls through to the SPA catch-all and returns
   index.html — the SPA then parses HTML as JSON. Emit and fetch at a
   FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
   and loadBrand derive the slug identically (npm scope).

With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
2026-06-20 00:07:06 -07:00
zeekay cd170d67bb fix(brand): emit + fetch brand.json at flat encoding-safe /brand/<scope>.json
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:

1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
   require.resolve('@scope/brand/brand.json') threw ReferenceError
   (no global require), was swallowed by the silent catch, and NO
   brand.json was ever emitted into dist/. Fixed with
   createRequire(import.meta.url).

2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
   @ and an encoded %2F that the production static server cannot map to
   the on-disk file, so it falls through to the SPA catch-all and returns
   index.html — the SPA then parses HTML as JSON. Emit and fetch at a
   FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
   and loadBrand derive the slug identically (npm scope).

With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
2026-06-20 00:07:06 -07:00
zeekay b5f8a58aba fix(brand): never blank the login form on a transient brand.json failure
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
2026-06-19 23:54:11 -07:00
zeekay 28616a8cc2 fix(brand): never blank the login form on a transient brand.json failure
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
2026-06-19 23:54:11 -07:00
zeekay cf1d3be7b9 fix(docker): serve SPA from /public with --spa for hanzoai/static contract
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).

Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
2026-06-19 23:48:00 -07:00
zeekay d3337ffc77 fix(docker): serve SPA from /public with --spa for hanzoai/static contract
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).

Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
2026-06-19 23:48:00 -07:00
zeekay e16ef2159e fix(docker): COPY pkgs/onboarding/package.json into install stage
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.

Local builds masked this because node_modules was already
populated from a prior full install.
2026-06-19 23:40:16 -07:00
zeekay eebd524872 fix(docker): COPY pkgs/onboarding/package.json into install stage
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.

Local builds masked this because node_modules was already
populated from a prior full install.
2026-06-19 23:40:16 -07:00
zeekay c9bd76a38c test(onboarding): unit tests for step machine + IAM wire contracts; docs
9 tests via the Node built-in runner (--experimental-strip-types, no
test-framework dependency): step machine (org→project→wallet→done),
listOrgs mapping + error-resilience, createOrg request shape + error
surfacing, linkWallet address validation + get-account→update-user
(web3onboard, column-scoped) + fail-closed. tc excludes *.test.ts so the
build gate stays type-only.

LLM.md: corrected issuer to per-brand *.id host (not iam.hanzo.ai),
documented the full method set + get-app-login source-of-truth, the
onboarding pkg + its IAM routes + admin-gating reality, and the /v1/iam
OIDC paths.
2026-06-19 15:34:37 -07:00
zeekay d2954861ca test(onboarding): unit tests for step machine + IAM wire contracts; docs
9 tests via the Node built-in runner (--experimental-strip-types, no
test-framework dependency): step machine (org→project→wallet→done),
listOrgs mapping + error-resilience, createOrg request shape + error
surfacing, linkWallet address validation + get-account→update-user
(web3onboard, column-scoped) + fail-closed. tc excludes *.test.ts so the
build gate stays type-only.

LLM.md: corrected issuer to per-brand *.id host (not iam.hanzo.ai),
documented the full method set + get-app-login source-of-truth, the
onboarding pkg + its IAM routes + admin-gating reality, and the /v1/iam
OIDC paths.
2026-06-19 15:34:37 -07:00
zeekay cab91a89df fix(auth): social/Web3 sign-in honors downstream redirect_uri
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
2026-06-19 15:31:21 -07:00
zeekay 5b4910e83a fix(auth): social/Web3 sign-in honors downstream redirect_uri
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
2026-06-19 15:31:21 -07:00
zeekay 68c6ebd470 fix(onboarding): correct IAM contracts — get-account+web3onboard wallet, authz-aware org/project
linkWallet now resolves the signed-in user via /v1/iam/get-account (owner/name)
and writes the lowercase `web3onboard` column scoped via ?columns= so the rest
of the user row is untouched — IAM's update-user is keyed by owner/name, not a
self alias.

Org/project creation is admin-gated in IAM authz (add-organization needs the
admin role; add-project default-denies non-admins). The common path — pick the
org you signed up into, listed via the *-allowed get-organizations — works for
everyone; create-new surfaces a plain permission message and the step stays
skippable so onboarding never hard-blocks. ProjectStep tolerates an org-less
flow (continue-only).
2026-06-19 15:29:42 -07:00