12 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 4e18e1ec11 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers: strings.Cut and range over int. omitzero is not applied, since
it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 18:52:46 -07:00
zeekayandHanzo Dev 7d81383019 Move workflows to .github/workflows
github.com collects workflows only from .github/workflows and its runners are
the only ones this repo has, so nothing here ever registered and no push
produced a run.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 21:46:12 -07:00
zeekayandHanzo Dev 27b51d5caa fix: call the pipeline at the address it actually lives
hanzoai/ci keeps the reusable at .github/workflows/build.yml — the one path
both planes resolve, since github.com reads a reusable only from there and the
forge reads it too. The v1 tag moved with it, so a caller still naming
.hanzo/workflows/build.yml asks for a blob that is not in the tree:

  InsertRun: read hanzoai/ci@v1:.hanzo/workflows/build.yml: object does not exist

The caller itself stays at .hanzo/workflows/cicd.yml on purpose: github.com has
no runner for these labels, so a caller placed there could never be scheduled.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 01:14:10 -07:00
zeekayandHanzo Dev c2639c71f9 being a program is not a grant, so it stops deciding who pays
Credential carried a Machine flag, derived by each caller from User.Type, and
Payer read it one step above the rule that governs the signup org. So a
machine-typed row took Org(SignupOrg) — the platform's own balance — without
ever reaching that rule.

Outside the signup org the flag decided nothing: machine and person both pool.
Its only effect was to turn "is a program", a profile column, into "may spend
the platform's money", an authority. The fallback exists to answer for
credentials that carry no claim, and a credential carrying no claim carries no
evidence of authority either — it must resolve, not grant.

So the field is gone rather than merely unread: the defect is now unexpressible
at the type level, and every caller that asserted a class is a compile error
rather than a silent wrong answer. Machine-ness still answers the questions a
column can answer (IsMachine — which column a usage record fills, whether there
is a person behind the credential); who pays is stated by IAM at the identity
boundary and carried in the signed billing_account claim.

First-party service credentials are untouched: both doors state that claim, and
the API-key door does not transmit User.Type at all.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.3
2026-08-20 10:15:11 -07:00
zeekayandHanzo Dev 8a345611a2 a credential that names nobody pays nothing, and a bare name is a member
The signup org is the one org where the NAME decides who pays: its members are
strangers who each hold their own wallet, and the account beside them is the
platform's own balance. Payer read a missing name as "not a person" and answered
with that account, so a credential that had FAILED to resolve a person spent the
platform pool.

It was reachable self-serve and it renewed itself. IAM returns a nameless
identity when a token row's (owner, name) key no longer resolves, and a refresh
token freezes that key at establishment, so re-keying the identity underneath it
— which onboarding a personal org does — makes every later rotation mint one.
Deleting a user did it too, which meant banning someone upgraded their billing.

Refuse instead, and refuse INSIDE the signup-org branch: a blank name is also how
a caller addresses an org account (a member-less grant crediting a tenant's pool,
a bare key naming a ledger), and every other org answers alike for a named member
and a nameless one, so requiring a name estate-wide would break funding a
customer to close a hole that only exists where the name is read.

PayerOf had the same defect from the other side. A slash-less key with an
explicit org had its key discarded and answered with the org account, while the
spend gate re-qualifies that same bare name to <org>/<name> before checking the
balance — so a legacy chat/TTS turn was gated against the person's wallet and
then charged to the pool. One request, two addresses. It is the member.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.2
2026-08-14 18:29:33 -07:00
zeekayandHanzo Dev 3a29955a00 account: a service account is a machine, so it spends the pool
IAM spells a machine two ways and money read only one. "application" is the
OIDC client shape; "service-account" is the identity class IAM's own
provisioning mints -- internal/oidc/provision.go writes it for a tenant's
pk-/sk- credential, /v1/iam/service-accounts creates rows of it, and SCIM
declares userType readOnly because it is that discriminator.

IsMachine matched only the first, so every service account provisioned into
the signup org fell to the personal branch and addressed a wallet no funding
path can name. Measured on hanzo/guest, the anonymous chat free tier: it
reads $0 while the org pool beside it holds ~$149k, and every priced call
answers 402 insufficient_balance -- text, speech and transcription alike.

This is the API-key half of the defect IAM already fixed for tokens (a
machine credential names its payer): there, the cure was a signed claim; on
the sk- path there is no claim to read, so the shape rule has to know both
spellings.

Writing Type needs org-admin, SuperAdmin, or an app holding CapUserAdmin, so
neither spelling is reachable by a plain member -- adding the second widens
nothing the first did not already admit.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.1
2026-08-08 06:53:54 -07:00
zeekayandHanzo Dev dfee0253b2 account: an explicit org selection succeeds or refuses, never redirects the bill
EffectiveOrg returned the home org for an ask outside the membership set and
called that "fail closed". It is not. This function selects the PAYER, so
falling back does not fail — it SUCCEEDS against a different economic principal.
The model runs, the tokens meter, the ledger writes, and the wrong account is
charged with no error anywhere. An obvious failure would have been safer.

Measured, which is how this surfaced: org `hanzo` holds 14,986,317 and user
`hanzo/z` holds 0. A request selecting `hanzo` that the subject could not bill
resolved silently to `hanzo/z` and answered "insufficient balance" — a true
statement about an account nobody meant to charge.

The contract is now (string, error):

  requested == ""          -> owner, nil
  requested == owner       -> owner, nil
  requested in memberships -> requested, nil
  otherwise                -> "", ErrOrgForbidden

Unauthorized and nonexistent are the SAME refusal so the ask cannot be used to
enumerate orgs; callers surface both as 403.

An empty owner now refuses an explicit ask too. It previously took the first
branch and answered ("", nil) — success naming no payer, the same shape as the
fallback being removed, one level down.

Breaking on purpose, no EffectiveOrgStrict alongside it: leaving the permissive
signature reachable invites the next caller to pick it by accident, and this is
exactly the API where "accidentally permissive" spends someone else's money.

LedgerOrg is untouched. Its sudo->home rule is already correct: an admin acting
inside a customer's org must never spend the customer's balance.

21 table cases become refusals; restoring the fallback fails all 21. Six call
sites in the suite migrate to the two-value form.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.0
2026-08-08 06:07:56 -07:00
zeekayandHanzo Dev 6b2e7c5cf5 ci: canonical pair — hanzo.yml + forge caller
github.com has no runner for hanzo-build-linux-amd64, so a caller under
.github/workflows is a gate that can never be scheduled. This is the ~7-line
caller on the plane that can (git.hanzo.ai git-runner fleet), pinned @v1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 02:34:06 -07:00
zeekayandGitHub 6b4d2fc6c9 legal: merge legal/dual-mit-apache into main (HIP-0137) 2026-08-04 14:17:01 -07:00
zeekayandHanzo Dev 48bd14d358 legal: relicense BSD-3-Clause -> MIT OR Apache-2.0 (HIP-0137)
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:13:23 -07:00
zeekayandHanzo Dev a6d114156c main is the Go module now — the Worker moved to hanzoai/id
This repo held two unrelated codebases under one name. `main` was the account
portal (a Cloudflare Worker serving account.hanzo.id / account.lux.id), while the
Go module github.com/hanzoai/account — Account, Payer, Subject, the rule that
decides who pays for anything — survived only on a `go` branch and its tags.

A Go module whose default branch has no go.mod cannot resolve for anything but an
explicit tag. That is why the package every service needs was pinned-and-private
instead of importable: cloud imports it in 5 files including the trust boundary,
commerce in 2, and it has zero dependencies of its own.

The Worker now lives in hanzoai/id/apps/account, where the rest of the identity
UI is — same brands, same IAM, same login redirect. Its history stays on this
repo's other branches; nothing is lost.

So main becomes what the module path already claimed it was. The repo is public
now too, so consumers no longer need GOPRIVATE and credentials to build.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 13:06:39 -07:00
zeekay 12c93cb97b auth: migrate account-portal worker IAM calls to canonical /v1/iam/*
account.hanzo.id / account.lux.id portal worker now uses the canonical
/v1/iam/* IAM surface (HIP-0111), off legacy /api/* and bare /oauth/*.

- getUserInfo  /api/userinfo            -> /v1/iam/userinfo
- getUser      /api/get-user            -> /v1/iam/get-user
- exchangeCode /api/login/oauth/access_token -> /v1/iam/oauth/token
- buildLoginUrl: drop bare /oauth/authorize suffix; point at the brand
  /login page with OAuth params (branded two-pane login, no host leak)
- add-provider Google/GitHub links -> /v1/iam/oauth/authorize?provider=
- client-side set-password + update-user (x3): now same-origin relative
  /v1/iam/{set-password,update-user} through the worproxy, which injects
  the access token server-side from the HttpOnly cookie (removes the
  cross-origin iam.hanzo.ai hop and the Bearer-null page-JS path)
- worker proxy guard: /api/ -> /v1/iam/

Flagged for follow-up (untouched): the iam.hanzo.ai/account console links
for MFA/account-deletion (first-party IAM admin UI). Push-blocked: the
hanzoai/account remote does not yet exist on GitHub; committed locally.
2026-06-24 19:27:09 -07:00