Skip to content
Merged
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
95b30e7
chore: files changed vendor/openhuman
senamakel Sep 10, 2026
a9560ac
fix: restore vendor/openhuman pin from origin/main
senamakel Sep 10, 2026
5ffefdd
chore: files changed Cargo.lock
senamakel Sep 10, 2026
57fd5fc
chore: files changed frontend/src/views/RoomView.tsx,frontend/src/vie…
senamakel Sep 10, 2026
26de9ef
chore: files changed frontend/src/views/RoomView.tsx
senamakel Sep 10, 2026
f182670
chore: files changed frontend/src/views/room/channels.ts
senamakel Sep 10, 2026
5cb2c2b
chore: files changed frontend/src/views/RoomView.tsx,frontend/src/vie…
senamakel Sep 10, 2026
fb9a93d
chore: files changed frontend/src/views/RoomView.tsx
senamakel Sep 10, 2026
78b1af1
chore: files changed src/server/hub_account.rs
senamakel Sep 10, 2026
bbcc319
chore: files changed src/server/mod.rs
senamakel Sep 10, 2026
bead491
feat(config): add optional web_url setting for TinyHumans site
senamakel Sep 10, 2026
6578687
chore: files changed src/app/types.rs
senamakel Sep 10, 2026
29406fe
chore: files changed src/app/types.rs
senamakel Sep 10, 2026
16e782d
chore: files changed src/bin/opencompany.rs
senamakel Sep 10, 2026
e1d7512
chore: files changed src/server/ops/company_key.rs
senamakel Sep 10, 2026
6bf31a0
chore: files changed frontend/src/views/connections/HubAccountLinks.tsx
senamakel Sep 10, 2026
6d81549
chore: files changed frontend/src/api/credential.ts
senamakel Sep 10, 2026
9187e0a
chore: files changed frontend/src/views/connections/CompanyCredential…
senamakel Sep 10, 2026
6e2b324
chore: files changed frontend/src/views/connections/InferenceSection.tsx
senamakel Sep 10, 2026
1aa8573
chore: files changed src/server/hub_account.rs
senamakel Sep 10, 2026
cd36906
chore: files changed src/server/graphql/mod.rs
senamakel Sep 10, 2026
726ed23
chore: files changed docs/spec/runtime/credentials.md
senamakel Sep 10, 2026
7dec139
chore: files changed src/server/hub_account.rs
senamakel Sep 10, 2026
ccbc9f3
chore: files changed src/server/hub_identity.rs
senamakel Sep 10, 2026
85ed69f
chore: files changed src/server/ops/company_key.rs
senamakel Sep 10, 2026
43078c9
chore: files changed docs/spec/runtime/credentials.md,src/server/hub_…
senamakel Sep 10, 2026
0d638d8
chore: files changed src/server/hub_identity.rs
senamakel Sep 10, 2026
c15c4fa
chore: files changed src/server/hub_identity.rs
senamakel Sep 10, 2026
355a626
chore: files changed src/server/hub_identity.rs
senamakel Sep 10, 2026
f5d11e2
chore: files changed src/server/ops/company_key.rs
senamakel Sep 10, 2026
52a0c03
chore: files changed frontend/src/api/credential.ts
senamakel Sep 10, 2026
c5582f3
chore: files changed frontend/src/views/connections/ApiKeyView.tsx
senamakel Sep 10, 2026
fc46032
chore: files changed frontend/src/views/connection-pages.ts,frontend/…
senamakel Sep 10, 2026
694c57d
chore: files changed frontend/src/views/connections/ApiKeyView.tsx
senamakel Sep 10, 2026
bf0cacd
chore: files changed frontend/src/views/connections/ApiKeyView.tsx,fr…
senamakel Sep 10, 2026
daaf690
feat(connections): add api key and company credential views
senamakel Sep 10, 2026
bd9de72
fix(ui): update alert component to use semantic role attribute
senamakel Sep 10, 2026
e09a30f
fix(styleguide): update button label from "Click me" to "Submit
senamakel Sep 10, 2026
6f47bd4
fix(styleguide): correct color token for primary button hover state
senamakel Sep 10, 2026
9337114
fix(company-key): use the browser origin for local callbacks
senamakel Sep 10, 2026
545cb6d
test(company-key): cover callback origin resolution
senamakel Sep 10, 2026
e4890c6
fix(company-key): handle bracketed IPv6 loopback origins
senamakel Sep 10, 2026
e79dc81
fix(runtime): align channel errors with automation terminology
senamakel Sep 10, 2026
9efaca3
chore: update workflow orchestration and tests
senamakel Sep 10, 2026
6efbf63
fix(auth): distinguish magic links from key grants
senamakel Sep 10, 2026
efc7f2d
revert: leave the workflow/automation channel wording alone
senamakel Sep 10, 2026
d57323c
chore: update delivery channel error wording
senamakel Sep 10, 2026
d92f3cb
chore(connections): update API key view
senamakel Sep 10, 2026
9f8185c
chore: update routed view test support
senamakel Sep 10, 2026
edadfda
test(connections-navigation): update navigation tests
senamakel Sep 10, 2026
b8f099e
test: update section rail layout tests
senamakel Sep 10, 2026
a351615
fix(navigation): preserve connection page section layout
senamakel Sep 10, 2026
55075bd
test: cover chat, thread key, and referral crossing cases
senamakel Sep 10, 2026
6feefa5
test(chat): add read-only composer coverage
senamakel Sep 10, 2026
2249f69
Merge remote-tracking branch 'origin/main' into ui-polish
senamakel Sep 10, 2026
8c0a2d9
chore: files changed Cargo.lock
senamakel Sep 10, 2026
493e50d
chore: files changed src/server/graphql/mod.rs
senamakel Sep 10, 2026
75bf511
chore: files changed src/server/ops/company_key.rs
senamakel Sep 10, 2026
5932e86
chore: files changed src/server/ops/company_key.rs
senamakel Sep 10, 2026
2ad34b2
chore: files changed src/server/ops/company_key/test.rs
senamakel Sep 10, 2026
d28de21
chore: files changed frontend/src/views/connections/ApiKeyView.tsx
senamakel Sep 10, 2026
2bbcfaa
chore: files changed frontend/src/views/connections/ApiKeyView.tsx
senamakel Sep 10, 2026
4c06de8
chore: files changed frontend/src/views/connections/ApiKeyView.tsx
senamakel Sep 10, 2026
6331029
chore: files changed frontend/src/views/connections/ApiKeyView.tsx
senamakel Sep 10, 2026
d3797ad
chore: files changed frontend/src/views/connections/ConnectionsSectio…
senamakel Sep 10, 2026
3f7067c
chore: files changed frontend/src/views/connections/InferenceSection.tsx
senamakel Sep 10, 2026
6864654
chore: files changed frontend/src/views/RoomView.tsx
senamakel Sep 10, 2026
32da0f2
chore: files changed src/app/config.rs
senamakel Sep 10, 2026
6c66f28
chore: files changed src/bin/opencompany.rs
senamakel Sep 10, 2026
b6e58cb
chore: files changed docs/spec/runtime/credentials.md
senamakel Sep 10, 2026
e4084de
style(company_key): reformat mock hub identity builder call
senamakel Sep 10, 2026
f9fbc55
chore: files changed frontend/test/unit/api-key-view.test.ts
senamakel Sep 10, 2026
0be4b0d
chore: files changed frontend/test/unit/inference-hub-account-links.t…
senamakel Sep 10, 2026
093acf0
chore: files changed frontend/src/components/ui/alert.tsx
senamakel Sep 10, 2026
6c15da8
test(auth): cover scoped access to credential billing
senamakel Sep 10, 2026
b58d731
test: add credential billing authorization matrix snapshots
senamakel Sep 10, 2026
4521afd
test(auth_matrix): update route count assertions after adding a new o…
senamakel Sep 10, 2026
ddf3653
chore: files changed tests/auth_matrix.rs
senamakel Sep 10, 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
65 changes: 64 additions & 1 deletion docs/spec/runtime/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@ it back on a clipboard.
(`server::hub_link`), and answers with the hub URL to navigate to — carrying
only `base64url(sha256(verifier))` and an opaque `state`.
2. **The hub.** The person signs in with their provider and approves a consent
screen naming the requesting origin and the scopes. The hub decides those
screen naming the requesting origin and the scopes.

The URL is the **site's** `/connect` page where a site is derivable, and the
API's `GET /auth/key` where it is not. `/auth/key` defaults to
`provider=google` and redirects there immediately: an admin who pressed a
button in their own console arrived at a Google account picker naming nobody,
with no way to use the account they actually sign in here with. `/connect`
names the instance asking, says what will be created, offers the same three
providers the sign-in screen does, and hands off to `/auth/key?provider=…`
with every grant parameter passed through — `server::hub_identity::key_grant_query`
builds them once, so the two pages cannot disagree about the challenge. The hub decides those
scopes from the callback origin: a **provisioned tenant origin** may receive
`connections`; a loopback console receives what a human could mint by hand.
3. **Finish.** The browser returns with a single-use `code`. The host looks up
Expand All @@ -62,6 +72,25 @@ is the reason the exchange is server-side rather than done in the page: whatever
redeems the code receives the key, and a `connections` key passing through a tab
is a credential in a place nobody can account for.

**Where the browser comes back to.** The callback is
`{host_base_url}/?company=…&key=link&state=…`, and `host_base_url` is
`OPENCOMPANY_PUBLIC_URL` — the console's own origin, because the console is what
holds the session that may call `finish` and what redeems the code. A host that
advertises an origin serving no console answers the return leg with a 404 and
the grant dies holding a spent code. In a hosted tenant the console is served
from that origin already; locally, either set `OPENCOMPANY_CONSOLE_DIR` to a
built `frontend/dist` so the host origin serves it, or point
`OPENCOMPANY_PUBLIC_URL` at the dev server (`http://localhost:5173`).
Comment thread
senamakel marked this conversation as resolved.
Outdated

**The key never reaches the browser.** The return leg carries `state` and a
one-time `code`, and nothing else — the console posts both to its own host,
which redeems them and stores the key. There is deliberately no screen anywhere
in this flow that displays the key: whatever holds the code and the verifier can
mint it, and a `connections`-scoped credential rendered into a page is one that
has passed through a tab, its history, and any extension reading either. A key
somebody wants to see with their own eyes is minted by hand on the dashboard's
API-keys page instead.

**One grant arms two credentials.** The minted key is stored as both
`tinyhumans/key` and `inference/key`, and the company's inference provider is
declared `managed`. An admin who had to run the flow once per page — once for
Expand All @@ -72,6 +101,40 @@ The paste field stays. A host with no hub wired reports `hubLink: false` on
`GET …/credential`, the console renders no button, and the screen is exactly
what it was before this existed.

### Which hub, and the two pages the console does not reimplement

Everything above happens against whichever hub `TINYHUMANS_API_URL` names — the
production one by default, `https://staging-api.tinyhumans.ai` for a console
working against staging. Nothing else has to be set to move the flow: the
authorize URL is built from that value (`server::hub_identity::key_grant_url`),
and so is the callback, from `OPENCOMPANY_PUBLIC_URL`.

Two things the grant deliberately cannot do are **revoke** the key it minted and
**pay** for what that key spends. Both end an errand somewhere this console has
no business being — one withdraws an instance's access, the other moves money —
so both are links out to the hub's own dashboard, behind that person's own
sign-in:

| Page | Path |
|---|---|
| Choose a provider and approve a grant | `{site}/connect?…` |
| Manage API keys — see, name, revoke | `{site}/dashboard?tab=api-keys` |
| Top up the balance those keys spend | `{site}/dashboard?tab=billing` |

`GET …/credential` carries them as `account.manageKeysUrl` and
`account.topUpUrl`, resolved on the **host**. The console never assembles them,
because only the host knows which hub it was pointed at: a link built in the
browser would send an operator working on staging to production's billing page,
where the top-up would arrive in the wrong account and look like it had simply
not arrived.

`{site}` is derived from `api_url` by the ecosystem's naming convention
(`server::hub_account`): `api.tinyhumans.ai` → `tinyhumans.ai`,
`staging-api.tinyhumans.ai` → `staging.tinyhumans.ai`. A backend the convention
does not describe — self-hosted, loopback — derives nothing, `account` is absent,
and the console renders no link rather than one pointing at a host that need not
exist. `TINYHUMANS_WEB_URL` states the site outright where that is wrong.

## Where a connection lives

On the backend, keyed by the account the bearer resolves to — under this model,
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,30 @@ import { cn } from "@/lib/utils";
* dropping the code and the company. Clearing is a side effect, so it lives in
* an effect: see `clearMagicLinkFromUrl`.
*/
function readMagicLink(): { company: string | null; code: string } | null {
const params = new URLSearchParams(window.location.search);
export function readMagicLinkFrom(search: string): { company: string | null; code: string } | null {
const params = new URLSearchParams(search);
const code = params.get("code");
if (!code) return null;
// `?code=` alone is not enough to say what kind of code it is. The key-grant
// return leg lands here as `?company=…&key=link&state=…&code=…`, and this
// read predates that marker: it took the grant code for a magic link and
// posted it to `/auth/verify`, which answers 409 on a company with no
// sign-in and "that sign-in didn't complete" on every other kind — for a
// round trip that had in fact succeeded, whose real code was sitting in
// `pending-key-link` waiting for the card that asked for it.
//
// So a marked landing belongs to whoever marked it. `key=auth` is the hub's
// sign-in return (`readHubToken`) and `key=link` is this console's own grant
// return (`readKeyLink`); a magic link carries no marker at all.
const marker = params.get("key");
if (marker === "link" || marker === "auth") return null;
return { company: params.get("company"), code };
}

function readMagicLink(): { company: string | null; code: string } | null {
return readMagicLinkFrom(window.location.search);
}

/**
* Reads `?token=&key=auth` off a hub sign-in landing.
*
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/api/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ export interface CompanyCredentialStatus {
* direction.
*/
hubLink?: boolean;
/**
* Where this person looks after the account behind the key — the hub
* dashboard's key list, and its top-up page.
*
* Resolved by the **host**, because only the host knows which hub it was
* pointed at: a console talking to staging must link to the staging
* dashboard, and a link assembled in the browser would send an operator to
* production's billing page. Absent on a host whose backend the naming
* convention does not describe (self-hosted, loopback), where there is no
* dashboard to link to — the console then renders no link rather than a
* guess.
*/
account?: HubAccountLinks;
}

/** The two hub pages the console links out to. */
export interface HubAccountLinks {
/** The dashboard's API-key list — where a minted key is seen and revoked. */
manageKeysUrl: string;
/** The dashboard's balance and top-up page. */
topUpUrl: string;
}

/** A mutating response: the resulting status plus a plain-language note. */
Expand Down Expand Up @@ -129,3 +150,47 @@ export function finishCredentialLink(
{ state, code },
);
}

/** The account's money, as the API Key page draws it. */
export interface BillingSummary {
/** Everything spendable — promotional credit and top-up together, in USD. */
balanceUsd: number;
/** The plan slug (`free`, `pro`, …). */
plan: string;
/** Whether a paid subscription is live right now. */
activeSubscription: boolean;
/** When the plan lapses, if it does. */
planExpiry?: string;
/** Where a person tops up, on the hub that issued the key. */
topUpUrl?: string;
/** Where a person changes the plan. */
manageUrl?: string;
}

/**
* The billing panel's whole state, including its two empty cases.
*
* `configured: false` is "no key, so nothing to ask about" — the page shows the
* pitch. `unavailable` is "there is a key but the hub would not answer", which
* is deliberately not the same as a zero balance: they look identical on a card
* and mean opposite things, one "top up" and one "try again".
*/
export interface CompanyBilling {
configured: boolean;
summary?: BillingSummary;
unavailable?: string;
}

/**
* What the account behind this company's key has left to spend.
*
* Read through the **host**, which presents the key it holds — the console
* never sees the credential, so it could not ask the hub itself. A read and
* only a read: topping up and changing plans happen signed in on the hub.
*/
export function getCompanyBilling(
client: OpenCompanyClient,
company: string | null,
): Promise<CompanyBilling> {
return client.get<CompanyBilling>(`${client.scopeFor(company)}/credential/billing`);
}
23 changes: 21 additions & 2 deletions frontend/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,29 @@ const alertVariants = cva(
* "blocked" everywhere else (`--status-blocked`), tinted rather than
* filled so it still reads as a notice on the page and not an error
* dialog.
*
* The text used to be `--foreground` with a `--muted-foreground`
* description: neutral grey on an amber wash, which reads as two
* components stacked rather than one notice — the tint says "warning"
* and the words say "body copy". Both now take `--status-blocked-text`,
* which is the amber the icon already used, so the whole alert is one
* colour family. It stays legible at both ends: 4.80:1 in light
* (`--amber-text` on a 16%-amber wash over the page ground) and
* `--amber-bright` in dark, the same pairing every other blocked
* surface in the console uses.
*/
warning:
"border-status-blocked/40 bg-status-blocked-soft text-foreground " +
"*:data-[slot=alert-description]:text-muted-foreground *:[svg]:text-status-blocked-text",
"border-status-blocked/40 bg-status-blocked-soft text-status-blocked-text " +
"*:data-[slot=alert-title]:text-status-blocked-text " +
"*:data-[slot=alert-description]:text-status-blocked-text " +
"*:[svg]:text-status-blocked-text " +
// Inline `code` inherits the alert's colour instead of keeping the
// page's neutral chip. A grey chip on an amber wash reads as a
// fragment of some other component that landed here by accident —
// which is exactly what a feature name in a warning must not look
// like, since it is the part the reader has to act on.
"[&_code]:bg-status-blocked/15 [&_code]:text-status-blocked-text " +
"[&_code]:rounded [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
Expand Down
Loading
Loading