Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a37a8fd92 | ||
|
|
29c98ddf5b | ||
|
|
f64de84a4e | ||
|
|
ecb9be3a8b | ||
|
|
400a206d80 | ||
|
|
628e6fb469 | ||
|
|
c1a7d48113 | ||
|
|
e5f510b994 | ||
|
|
0a883aefaf | ||
|
|
0c097129d8 | ||
|
|
3749f2669f | ||
|
|
06d0681081 | ||
|
|
cdb6f9d091 | ||
|
|
744164c4c5 | ||
|
|
454636c4e2 | ||
|
|
8bdbc9116a | ||
|
|
599f6f3a31 | ||
|
|
f26c8fbcca | ||
|
|
447825ef6d | ||
|
|
9915705d10 | ||
|
|
b10c5ce18f | ||
|
|
d93e9148b2 | ||
|
|
6b881b2a8e | ||
|
|
f85b08e44d | ||
|
|
daef1ea32c | ||
|
|
7a722f0a3c | ||
|
|
58ff94c159 | ||
|
|
b489ab0fdb | ||
|
|
13c5b30cd6 | ||
|
|
108977e0ca | ||
|
|
137432ad42 | ||
|
|
9bd12d7c28 | ||
|
|
bc93b371f6 | ||
|
|
b55eb201d6 | ||
|
|
e7b36af216 | ||
|
|
9d6190a783 |
@@ -21,5 +21,5 @@ on:
|
||||
pull_request:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Sync from GitHub
|
||||
# git.hanzo.ai is canonical; development also lands on
|
||||
# github.com/hanzoai/console. ONE deterministic direction: an in-cluster PULL.
|
||||
# The runner reaches both ends (GitHub outbound, this forge via the instance URL
|
||||
# actions/checkout already uses), so the sync has no ingress dependency.
|
||||
#
|
||||
# Fast-forward ONLY: a divergence fails loudly here instead of force-pushing
|
||||
# either side.
|
||||
#
|
||||
# Inert until hanzoai/console stops being a pull mirror — while it is one,
|
||||
# the forge overwrites main from GitHub on its own timer and rejects the push
|
||||
# below. That conversion is also what turns Actions on here (measured today:
|
||||
# mirror: true, has_actions: false ⇒ zero native runs).
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
concurrency:
|
||||
group: sync-from-github
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ff-main:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
- name: Fast-forward main from github.com/hanzoai/console
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" main
|
||||
LOCAL="$(git rev-parse HEAD)"; REMOTE="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then echo "in sync at $LOCAL"; exit 0; fi
|
||||
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
|
||||
echo "fast-forwarding $LOCAL -> $REMOTE"
|
||||
git push origin "$REMOTE:refs/heads/main"
|
||||
# A push made with the workflow token does not trigger workflows, so
|
||||
# synced commits would never build. Dispatch CI explicitly.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
|
||||
-d '{"ref":"main"}' \
|
||||
&& echo "CI dispatched" || echo "CI dispatch failed (non-fatal — next direct push builds)"
|
||||
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
|
||||
echo "canonical is AHEAD of GitHub — nothing to pull (never force from here)."
|
||||
else
|
||||
echo "::error::main DIVERGED between GitHub ($REMOTE) and canonical ($LOCAL) — refusing to force. Reconcile manually."
|
||||
exit 1
|
||||
fi
|
||||
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
|
||||
|
||||
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
|
||||
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
|
||||
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
|
||||
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
|
||||
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
|
||||
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
|
||||
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
|
||||
|
||||
@@ -290,11 +291,18 @@ Findings + fixes (all in console2; honest states everywhere, no fakes):
|
||||
separately) → honest "not available on this deployment" (was a scary error).
|
||||
HUSD balance/top-up already honest "coming" (token unconfigured).
|
||||
- **Providers was broken** — `ProviderListView`/`ProviderEditView` imported the
|
||||
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
|
||||
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
|
||||
the module showed "Failed to load providers". Switched both back to the working
|
||||
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
|
||||
until `/zap` is bound. Providers now shows real/empty over REST like every module.
|
||||
ZAP twin (`~/lib/zap`), and at the time the cloud `/zap` WS face was not served
|
||||
(the edge returned SPA HTML, 200 rather than a WS upgrade), so the module showed
|
||||
"Failed to load providers". Switched both back to the working REST `~/lib/api`
|
||||
(identical surface). Providers now shows real/empty over REST like every module.
|
||||
|
||||
**STALE AS OF 2026-07-28 — `/zap` IS served.** Measured on all three hosts
|
||||
(api.hanzo.ai, platform.hanzo.ai, cloud.hanzo.ai): a WebSocket upgrade handshake
|
||||
returns **401**, not SPA HTML and not 200. A route that refuses an
|
||||
unauthenticated upgrade is a route that exists. The reason this section gives
|
||||
for preferring REST no longer holds, and read as current it says ZAP is
|
||||
unavailable when it is merely gated. The REST path is still correct and still
|
||||
shipping — this is a stale rationale, not a bug. Re-measure before acting on it.
|
||||
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
|
||||
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
|
||||
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
|
||||
@@ -1400,7 +1408,7 @@ of session-only reads (get-account, get-cloud-usages) — so it is KEPT, not rep
|
||||
The fix is **additive, one session manager, zero regression** (worst case === v8.4.28):
|
||||
- **`src/lib/server/session.ts`** — THE token manager (server-only by construction:
|
||||
`node:crypto` + `next/server`). Sealed AES-256-GCM (key = HKDF(`IAM_MINT_CLIENT_SECRET`);
|
||||
no-secret → per-process random key, never a constant). Casdoor tokens are ~3.6 KB
|
||||
no-secret → per-process random key, never a constant). IAM tokens are ~3.6 KB
|
||||
full-user JWTs (86 claims incl. password hash / TOTP secret) — the ACCESS token and
|
||||
the REFRESH token are BOTH that big — so a single cookie is impossible (browser ~4 KB
|
||||
per-cookie cap; a real browser silently REJECTS an oversized cookie — a bug curl never
|
||||
@@ -1463,7 +1471,7 @@ for wrong application (client_id)":
|
||||
`/v1/iam/signin`, which the ingress routes to the CLOUD backend (casibase); casibase
|
||||
redeems with ITS confidential `hanzo-cloud` client → mismatch. Now the console redeems
|
||||
the code ITSELF: on an admin host `iam-login.ts` authorizes with **PKCE** (S256
|
||||
`codeChallenge` in the login body — casdoor stores it with the code), and
|
||||
`codeChallenge` in the login body — IAM stores it with the code), and
|
||||
`completeSignIn` posts `{code, codeVerifier}` to the new BFF **`app/auth/signin`**, which
|
||||
runs `pkceCodeGrant(client_id=admin-console, code, code_verifier)` with **NO client secret**.
|
||||
Verified in IAM source (`object/token_oauth.go` GetAuthorizationCodeToken 880-896): an
|
||||
@@ -1475,7 +1483,7 @@ for wrong application (client_id)":
|
||||
- **`durableSessionClientId(host)`** (session.ts) is the ONE host→client decision:
|
||||
admin host → `admin-console` (public — pkceCodeGrant + secretless refreshGrant), else null
|
||||
→ the confidential `hanzo-console` path. `/auth/refresh` uses it so an admin session
|
||||
refreshes with admin-console (casdoor skips the secret check when it's empty, token.go 469).
|
||||
refreshes with admin-console (IAM skips the secret check when it's empty, token.go 469).
|
||||
- The admin session rests on `hz_session` (the code grant returns access + refresh, minted
|
||||
at authorize time), which `resolveUser`/`getAdminGate` read FIRST — so the admin console
|
||||
works without the casibase cookie. `accountOf` + `applyCookies` extracted to session.ts
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
|
||||
/**
|
||||
* POST — the GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
|
||||
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
|
||||
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
|
||||
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
|
||||
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
|
||||
@@ -116,16 +116,16 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
|
||||
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
|
||||
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
|
||||
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
|
||||
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
|
||||
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
|
||||
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
|
||||
* mutating verbs; only an allow-listed head/sub-path is ever reached.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
|
||||
*
|
||||
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
|
||||
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
|
||||
* custom hanzo.id login worker doesn't establish an IAM account session, so a
|
||||
* user who signed in through it lands on an account page that can't manage MFA
|
||||
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
|
||||
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
|
||||
@@ -20,7 +20,7 @@ import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const TOTP = 'app' // Casdoor TotpType
|
||||
const TOTP = 'app' // IAM TotpType
|
||||
|
||||
/**
|
||||
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* the FIRST path segment, so the tab slugs fall through to the SPA.
|
||||
*
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
|
||||
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
|
||||
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
|
||||
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
|
||||
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
|
||||
|
||||
+3
-2
@@ -2,8 +2,9 @@
|
||||
|
||||
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
|
||||
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
|
||||
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
|
||||
is the total row count on list endpoints).
|
||||
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
|
||||
is the row count on list endpoints; the legacy `data2` count is still accepted
|
||||
as a fallback until every emitter finishes the rename).
|
||||
|
||||
Client modules live in `src/lib/api/`.
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
|
||||
'/v1/billing/invoices',
|
||||
'/v1/billing/usage',
|
||||
'/v1/billing/payment-methods',
|
||||
'/v1/billing/spend-alerts',
|
||||
'/v1/billing/alerts',
|
||||
'/v1/usage/summary',
|
||||
'/v1/get-cloud-usages',
|
||||
'/v1/o11y/observations',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
|
||||
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
|
||||
* mounts, `/v1/billing/alerts` → real-shaped budget rows (org default + project
|
||||
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
|
||||
* envelope.
|
||||
*
|
||||
@@ -39,7 +39,7 @@ const ACCOUNT = {
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
|
||||
/** Real-shaped `/v1/billing/alerts` rows — one per verdict/scope (threshold = cents). */
|
||||
const BUDGETS = [
|
||||
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
|
||||
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
|
||||
@@ -61,8 +61,8 @@ async function mock(route: Route) {
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The page under test — the real spend-alerts contract.
|
||||
if (path === '/v1/billing/spend-alerts') {
|
||||
// The page under test — the real alerts contract.
|
||||
if (path === '/v1/billing/alerts') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
|
||||
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
|
||||
}
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
|
||||
+10
-153
@@ -1,154 +1,11 @@
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
/**
|
||||
* The console's GUI config IS the shared one. The type/radius/space scale used to be
|
||||
* declared here; it now ships with the components it scales (`@hanzo/ui/gui-config`),
|
||||
* because the dedicated Hanzo Social app renders the same @hanzo/ui/product set and a
|
||||
* second copy of the ladder would fork silently — same components, different sizes.
|
||||
*
|
||||
* Kept as a file so `~/gui.config` stays the console's one import path.
|
||||
*/
|
||||
export { config, default } from '@hanzo/ui/gui-config'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THE ONE SCALE.
|
||||
//
|
||||
// Three scales used to disagree. `app/design/typography.css` declared the
|
||||
// intended compact register (11/13/14/15/17/21/26 — the linear.app density);
|
||||
// @hanzo/gui's Tamagui `$N` ladder is what components actually TYPE (thousands
|
||||
// of `fontSize="$N"` call sites); and the rendered result was TEN distinct
|
||||
// sizes, including 217 nodes at the retired 16px base and 30 at 10px, while the
|
||||
// intended 11px label size rendered NOWHERE.
|
||||
//
|
||||
// The ladder is exactly why we do not edit thousands of call sites: REMAP IT
|
||||
// ONCE and every surface lands on the design scale. So this file is the single
|
||||
// place the console's type, radius and spacing scales are defined —
|
||||
// `app/design/*.css` declares them for CSS consumers, this maps the `$N` tokens
|
||||
// onto the same numbers for the component layer. Change a value here, the whole
|
||||
// product moves. Adding a fourth spelling of a size is the thing to refuse.
|
||||
//
|
||||
// Canonical face: Geist Sans for UI, Geist Mono for anything numeric/code/id
|
||||
// (`.hz-mono`, set in app/globals.css). Both self-hosted in app/fonts.css.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
|
||||
|
||||
/** Type — SIX sizes in the app, matching `--text-*` in app/design/typography.css.
|
||||
* $1 label · $2 nav + dense body · $3 base · $4/$5 emphasis · $6 section head ·
|
||||
* $7 page title · $8+ display. `$5` collapses onto 15 to retire the 16px base
|
||||
* (217 stray nodes); `$9` collapses onto 26 so a page title has ONE size. */
|
||||
const FONT_SIZE = {
|
||||
1: 11,
|
||||
2: 13,
|
||||
3: 14,
|
||||
4: 15,
|
||||
5: 15,
|
||||
6: 17,
|
||||
7: 21,
|
||||
8: 26,
|
||||
9: 26,
|
||||
10: 32,
|
||||
11: 40,
|
||||
12: 48,
|
||||
13: 56,
|
||||
14: 64,
|
||||
15: 80,
|
||||
16: 96,
|
||||
true: 14,
|
||||
} as const
|
||||
|
||||
/** Leading, paired 1:1 with the sizes above. A size token carries a line-height
|
||||
* tuned for ONE line, so these track the type scale rather than the inherited
|
||||
* ladder, which left an 11px label sitting in an 18px box. */
|
||||
const LINE_HEIGHT = {
|
||||
1: 16,
|
||||
2: 18,
|
||||
3: 20,
|
||||
4: 22,
|
||||
5: 22,
|
||||
6: 24,
|
||||
7: 28,
|
||||
8: 32,
|
||||
9: 32,
|
||||
10: 38,
|
||||
11: 46,
|
||||
12: 54,
|
||||
13: 62,
|
||||
14: 70,
|
||||
15: 86,
|
||||
16: 102,
|
||||
true: 20,
|
||||
} as const
|
||||
|
||||
/** Radius — FOUR values, no more. 6 control · 8 input/row · 12 panel · pill.
|
||||
* The inherited ladder had thirteen spellings rendering ten values, including
|
||||
* three different spellings of "pill" (`{999}`, `{99}`, `$10`). `$10`+ IS the
|
||||
* pill, so the 115 `rounded="$10"` call sites finally mean one thing. */
|
||||
const RADIUS = {
|
||||
0: 0,
|
||||
1: 6,
|
||||
2: 6,
|
||||
3: 8,
|
||||
4: 8,
|
||||
5: 12,
|
||||
6: 12,
|
||||
7: 12,
|
||||
8: 12,
|
||||
9: 12,
|
||||
10: 9999,
|
||||
11: 9999,
|
||||
12: 9999,
|
||||
true: 8,
|
||||
} as const
|
||||
|
||||
/** Spacing — the 4px ramp, and only the 4px ramp. The inherited ladder landed on
|
||||
* odd pixels belonging to no scale: `$2`=7, `$3`=13, `$4`=18 were the three
|
||||
* most-rendered paddings in the whole app. Mirrored into negative steps because
|
||||
* Tamagui resolves `-$3` from this same map. */
|
||||
const STEP: Record<string, number> = {
|
||||
'0': 0,
|
||||
'0.25': 1,
|
||||
'0.5': 2,
|
||||
'0.75': 3,
|
||||
'1': 4,
|
||||
'1.5': 6,
|
||||
'2': 8,
|
||||
'2.5': 10,
|
||||
'3': 12,
|
||||
'3.5': 14,
|
||||
'4': 16,
|
||||
'4.5': 20,
|
||||
'5': 24,
|
||||
'6': 32,
|
||||
'7': 40,
|
||||
'8': 48,
|
||||
'9': 56,
|
||||
'10': 64,
|
||||
'11': 80,
|
||||
'12': 96,
|
||||
'13': 112,
|
||||
'14': 128,
|
||||
'15': 144,
|
||||
'16': 160,
|
||||
'17': 160,
|
||||
'18': 176,
|
||||
'19': 192,
|
||||
'20': 208,
|
||||
}
|
||||
|
||||
const space: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(STEP)) {
|
||||
space[`$${k}`] = v
|
||||
space[`-${k}`] = -v
|
||||
}
|
||||
space.$true = STEP['4']
|
||||
space['-true'] = -STEP['4']
|
||||
|
||||
export const config = createGui({
|
||||
...defaultConfig,
|
||||
tokens: {
|
||||
...defaultConfig.tokens,
|
||||
radius: RADIUS,
|
||||
space,
|
||||
},
|
||||
fonts: {
|
||||
...defaultConfig.fonts,
|
||||
body: { ...defaultConfig.fonts.body, family: GEIST, size: FONT_SIZE, lineHeight: LINE_HEIGHT },
|
||||
heading: { ...defaultConfig.fonts.heading, family: GEIST, size: FONT_SIZE, lineHeight: LINE_HEIGHT },
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
export type Conf = typeof import('@hanzo/ui/gui-config').config
|
||||
|
||||
+2
-2
@@ -97,10 +97,10 @@ const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images'
|
||||
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
|
||||
// which is method-agnostic (Next matches on the URL), so POST is covered without a
|
||||
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
|
||||
/**
|
||||
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
|
||||
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
|
||||
* reviews/users) to a real cloud backend so `npm run dev` renders the
|
||||
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
|
||||
* the built image), so production is unchanged — there the console host's edge routes
|
||||
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
|
||||
|
||||
+20
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.32",
|
||||
"version": "8.5.35",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -18,21 +18,22 @@
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.0",
|
||||
"@hanzo/canvas": "^0.1.0",
|
||||
"@hanzo/dash": "0.3.0",
|
||||
"@hanzo/data": "^1.2.0",
|
||||
"@hanzo/event": "^0.3.4",
|
||||
"@hanzo/finance-ui": "0.1.1",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam": "^0.21.1",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "^8.0.11",
|
||||
"@hanzo/brand": "^1.4.5",
|
||||
"@hanzo/canvas": "^0.2.1",
|
||||
"@hanzo/dash": "^0.3.0",
|
||||
"@hanzo/data": "^1.2.2",
|
||||
"@hanzo/event": "^0.3.8",
|
||||
"@hanzo/finance-ui": "~0.1.1",
|
||||
"@hanzo/gui": "^8.0.0",
|
||||
"@hanzo/iam": "^0.21.2",
|
||||
"@hanzo/logo": "^1.0.14",
|
||||
"@hanzo/ui": "^8.0.38",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@hanzogui/config": "^8.0.0",
|
||||
"@hanzogui/core": "^8.0.0",
|
||||
"@hanzogui/lucide-icons-2": "^8.0.0",
|
||||
"@hanzogui/next-theme": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.0.1",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -53,17 +54,16 @@
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-web": "0.21.2",
|
||||
"superjson": "2.2.2",
|
||||
"@hanzogui/shell": "^7.6.3"
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "22.20.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4",
|
||||
"patch-package": "^8.0.0"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
diff --git a/node_modules/@hanzo/iam/dist/browser.cjs b/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
index fe3a04e..41c367e 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
@@ -785,6 +785,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/browser.js b/node_modules/@hanzo/iam/dist/browser.js
|
||||
index 4228603..1b9db27 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/browser.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/browser.js
|
||||
@@ -783,6 +783,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/index.cjs b/node_modules/@hanzo/iam/dist/index.cjs
|
||||
index d49c5d4..81cfb85 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/index.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/index.cjs
|
||||
@@ -1172,6 +1172,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/index.js b/node_modules/@hanzo/iam/dist/index.js
|
||||
index 48c5699..7a85d23 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/index.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/index.js
|
||||
@@ -1170,6 +1170,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/react.cjs b/node_modules/@hanzo/iam/dist/react.cjs
|
||||
index 8642b04..66da7d3 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/react.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/react.cjs
|
||||
@@ -719,6 +719,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/react.js b/node_modules/@hanzo/iam/dist/react.js
|
||||
index 8f4927a..81bef42 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/react.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/react.js
|
||||
@@ -717,6 +717,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
Generated
+1511
-4483
File diff suppressed because it is too large
Load Diff
@@ -7,16 +7,47 @@
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
|
||||
* the API client already uses (`setCurrentActor`), never the email — once the
|
||||
* session resolves. The org tenant is stamped server-side from the session, so
|
||||
* we send the user id only. Anonymous placeholder sessions are skipped.
|
||||
* - `identify` binds the user to their Hanzo IAM user id (`account.userId`, the
|
||||
* OIDC `sub`) once the session resolves, AND carries the attributes that make
|
||||
* that id legible. Anonymous placeholder sessions are skipped.
|
||||
*
|
||||
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
|
||||
* That is an org-relative REFERENCE, not a user id: it is a different id space
|
||||
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
|
||||
* user counted twice the moment they used two Hanzo surfaces — and every
|
||||
* cross-property funnel, retention curve and path silently measured nothing. It
|
||||
* also moves when an org or a login handle is renamed, which rewrites history.
|
||||
*
|
||||
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
|
||||
* warehouse held a population of opaque subjects: every funnel could count users
|
||||
* but no one could say WHICH user, and answering "who hit this error" meant a
|
||||
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
|
||||
* users — they arrive in the same IAM claims this file already decodes to get the
|
||||
* id, and were simply dropped on the floor. Sent as traits they are exactly as
|
||||
* sensitive as they were in the token, and the id stays the join key.
|
||||
*
|
||||
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
|
||||
* validated bearer, so org-level cohorts are already queryable — and a tenant the
|
||||
* client can name is a tenant the client can get wrong. Traits describe the user,
|
||||
* never the scope they are trusted with.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/event/react'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
/** The user attributes worth carrying alongside the id, from the IAM claims the
|
||||
* session already decoded. A key is OMITTED rather than sent undefined, so an
|
||||
* absent claim never overwrites a trait a prior identify established. */
|
||||
export function identityTraits(account: Account): Record<string, unknown> {
|
||||
const traits: Record<string, unknown> = {}
|
||||
if (account.email) traits.email = account.email
|
||||
const name = account.displayName ?? account.name
|
||||
if (name) traits.name = name
|
||||
return traits
|
||||
}
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
@@ -25,11 +56,13 @@ export function AnalyticsBridge() {
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
analytics.identify(personId)
|
||||
if (!account || account.type === 'anonymous-user') return
|
||||
// No IAM subject means no IAM user — leave the visitor anonymous rather than
|
||||
// inventing an id for them. Identity flows from the token's own claims.
|
||||
const userId = account.userId
|
||||
if (!userId || identified.current === userId) return
|
||||
identified.current = userId
|
||||
analytics.identify(userId, identityTraits(account))
|
||||
}, [account, analytics])
|
||||
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { identityTraits } from './Analytics'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
const account = (over: Partial<Account> = {}): Account => ({
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
userId: 'sub-1',
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('identityTraits', () => {
|
||||
it('carries the email and the human name off the IAM claims', () => {
|
||||
expect(
|
||||
identityTraits(account({ email: 'z@hanzo.ai', displayName: 'Z Hanzo' })),
|
||||
).toEqual({ email: 'z@hanzo.ai', name: 'Z Hanzo' })
|
||||
})
|
||||
|
||||
it('falls back to the login handle when no display name was claimed', () => {
|
||||
expect(identityTraits(account({ email: 'z@hanzo.ai' }))).toEqual({
|
||||
email: 'z@hanzo.ai',
|
||||
name: 'z',
|
||||
})
|
||||
})
|
||||
|
||||
// An absent claim must be ABSENT, not `undefined`: a trait sent as undefined
|
||||
// is a trait written, and it would blank a value an earlier identify had set.
|
||||
it('omits a key it has no claim for rather than sending undefined', () => {
|
||||
const traits = identityTraits(account({ displayName: 'Z Hanzo' }))
|
||||
expect(traits).toEqual({ name: 'Z Hanzo' })
|
||||
expect('email' in traits).toBe(false)
|
||||
})
|
||||
|
||||
// The tenant is stamped server-side from the validated bearer. A tenant the
|
||||
// client can name is a tenant the client can get wrong.
|
||||
it('never sends the org', () => {
|
||||
const traits = identityTraits(
|
||||
account({ email: 'z@hanzo.ai', organization: 'hanzo', owner: 'hanzo' }),
|
||||
)
|
||||
expect(traits).not.toHaveProperty('org')
|
||||
expect(traits).not.toHaveProperty('organization')
|
||||
expect(traits).not.toHaveProperty('owner')
|
||||
})
|
||||
})
|
||||
@@ -107,7 +107,7 @@ type Tone = { tone: 'ok' | 'err'; text: string }
|
||||
/**
|
||||
* Users with full CRUD — create, promote/demote admin, and delete — over the
|
||||
* ready IamAdminApi mutations (add/update/delete-user) through the server-gated
|
||||
* /admin/iam proxy, scoped to `owner`. This is the casdoor user surface, in
|
||||
* /admin/iam proxy, scoped to `owner`. This is the IAM user surface, in
|
||||
* console: no link-out for the common lifecycle. Honest states throughout.
|
||||
*/
|
||||
function UsersAdminView({ owner }: { owner: string }) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Annotation Queues — list of review queues (HIP-0106), native on @hanzo/gui.
|
||||
*
|
||||
* An annotation queue is a named work queue of traces/observations to review and
|
||||
* score against a set of score configs. Reads the REAL `/v1/o11y/annotation-queues`
|
||||
* score against a set of rubrics. Reads the REAL `/v1/o11y/reviews`
|
||||
* surface; when the runtime is not initialized (503) or unrouted (404) it shows an
|
||||
* honest RuntimeNotice — never fabricated queues. Read-only list here; items are
|
||||
* worked in the annotation flow.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Reports — cost breakdown by service (model/provider), filterable + charts.
|
||||
* Accounts — which account pays and in what order: attach an account to the
|
||||
* org or to one project, and reorder the chain commerce resolves.
|
||||
* Budgets — create/list spend budgets (real `/v1/billing/spend-alerts`).
|
||||
* Budgets — create/list spend budgets (real `/v1/billing/alerts`).
|
||||
* Invoices — invoice history + download (`/v1/billing/invoices`).
|
||||
* Subscriptions — the org's plans/status/renewal (reuses `SubscriptionsModule`).
|
||||
* Payment — saved, masked payment methods (reuses `PaymentMethodsModule`).
|
||||
|
||||
@@ -528,7 +528,7 @@ export function CapTableModule({ params }: { params: Record<string, string> }) {
|
||||
) : null}
|
||||
|
||||
{active === 'classes' ? (
|
||||
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/share-classes" />
|
||||
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/classes" />
|
||||
: classes.phase === 'ready' && classes.data.length === 0 ? (
|
||||
<EmptyState icon={Layers} title="No share classes yet" description="Create a share class (Common, Preferred) — certificates and rounds reference it." primary={{ label: 'New share class', onPress: () => setDialog({ kind: 'class' }) }} />
|
||||
) : <DataTable<ShareClass> columns={classCols} rows={classList} loading={classes.phase === 'loading'} rowKey={(c) => c.id} empty="No share classes yet." />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* feature). Wired to the REAL cloud `/v1/evals/*` facade, which proxies the
|
||||
* console's public dataset API:
|
||||
* - POST /v1/evals/datasets create a dataset
|
||||
* - POST /v1/evals/dataset-items add an item (input + expected output)
|
||||
* - POST /v1/evals/datasets/:name/items add an item (input + expected output)
|
||||
*
|
||||
* The gateway does not mount a dataset LIST route yet, so the list area attempts
|
||||
* the forward-compatible GET and renders an honest "not available here yet" card
|
||||
@@ -122,8 +122,7 @@ export function DatasetsModule(_props: { params: Record<string, string> }) {
|
||||
setAdding(true)
|
||||
setAddMsg(null)
|
||||
try {
|
||||
await EvalsApi.createDatasetItem({
|
||||
datasetName: itemDataset.trim(),
|
||||
await EvalsApi.createDatasetItem(itemDataset.trim(), {
|
||||
input: parseMaybeJson(input),
|
||||
expectedOutput: parseMaybeJson(expected),
|
||||
})
|
||||
@@ -265,7 +264,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
|
||||
|
||||
const load = useCallback(() => {
|
||||
setList({ phase: 'loading' })
|
||||
// Native dataset-items require a datasetName; fetch the org's datasets, then
|
||||
// A dataset item only exists inside a set; fetch the org's datasets, then
|
||||
// their items in parallel, and flatten — real rows, never fabricated.
|
||||
EvalsApi.listDatasets()
|
||||
.then((datasets) =>
|
||||
@@ -307,7 +306,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
|
||||
}
|
||||
/>
|
||||
{list.phase === 'error' ? (
|
||||
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/dataset-items" />
|
||||
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/datasets/:name/items" />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={itemColumns}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
*
|
||||
* FULL CRUD over the unified cloud binary via the same-origin user-bearer `/v1`
|
||||
* proxy, org resolved from the Bearer owner:
|
||||
* - GET /v1/load-balancers list
|
||||
* - POST /v1/load-balancers create (name + type + region)
|
||||
* - DELETE /v1/load-balancers/:id delete
|
||||
* - GET /v1/balancers list
|
||||
* - POST /v1/balancers create (name + type + region)
|
||||
* - DELETE /v1/balancers/:id delete
|
||||
*
|
||||
* When the backend doesn't serve the surface the list load fails and the honest
|
||||
* not-configured / unavailable card renders instead of an empty grid; create/delete
|
||||
@@ -66,7 +66,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await restGet<unknown>(cloudProxyV1Url('load-balancers'))
|
||||
const r = await restGet<unknown>(cloudProxyV1Url('balancers'))
|
||||
setRows(lbsOf(r))
|
||||
setLoadError(null)
|
||||
} catch (e) {
|
||||
@@ -89,7 +89,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
|
||||
setCreating(true)
|
||||
setActionMsg(null)
|
||||
try {
|
||||
await restPost(cloudProxyV1Url('load-balancers'), { name: name.trim(), type, region })
|
||||
await restPost(cloudProxyV1Url('balancers'), { name: name.trim(), type, region })
|
||||
setActionMsg({ tone: 'ok', text: `Created load balancer "${name.trim()}".` })
|
||||
setName('')
|
||||
await load()
|
||||
@@ -104,7 +104,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete load balancer "${lb.name || lb.id}"? This cannot be undone.`)) return
|
||||
setActionMsg(null)
|
||||
try {
|
||||
await restDelete(cloudProxyV1Url(`load-balancers/${enc(lb.id)}`))
|
||||
await restDelete(cloudProxyV1Url(`balancers/${enc(lb.id)}`))
|
||||
setActionMsg({ tone: 'ok', text: `Deleted load balancer "${lb.name || lb.id}".` })
|
||||
await load()
|
||||
} catch (e) {
|
||||
|
||||
@@ -118,7 +118,7 @@ function ReferralsAdminReady({ data }: { data: AdminReferralsView }) {
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<MetricCard icon={<Users size={16} color={toneColor('muted')} />} label="Referrals" value={String(s.total)} caption={`${s.signedUp} signed up`} />
|
||||
<MetricCard icon={<Users size={16} color={toneColor('muted')} />} label="Referrals" value={String(s.total)} caption={`${s.signup} signed up`} />
|
||||
<MetricCard icon={<Gift size={16} color="$color11" />} label="Qualified" value={String(s.qualified + s.credited)} caption={`${s.credited} credited`} />
|
||||
<MetricCard icon={<Coins size={16} color={toneColor('warning')} />} label="Credit granted" value={usd(s.grantedCents)} caption="both sides, promo credit" />
|
||||
</XStack>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Score Configs — list of score definitions (HIP-0106), native on @hanzo/gui.
|
||||
*
|
||||
* A score config defines a score's data type and its valid range (numeric) or
|
||||
* categories (categorical/boolean). Reads the REAL `/v1/o11y/score-configs`
|
||||
* categories (categorical/boolean). Reads the REAL `/v1/evals/rubrics`
|
||||
* surface; when the runtime is not initialized (503) or unrouted (404) it shows
|
||||
* an honest RuntimeNotice — never fabricated configs. Read-only here; configs are
|
||||
* authored where scores are recorded.
|
||||
|
||||
@@ -1,612 +1,23 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Social — the ONE native social surface, rendered IN-CONSOLE over cloud
|
||||
* `/v1/social/*` (`hanzoai/cloud` clients/social: a native-Go per-org accounts + posts
|
||||
* store on Base/SQLite, the in-process fold of the live social stack
|
||||
* github.com/hanzoai/social, twin of clients/crm) through the `/v1` user-bearer proxy.
|
||||
* NO link-out, one surface.
|
||||
* Social — the console's mount of the ONE social surface. The whole product (compose,
|
||||
* schedule, list + calendar, publish, connect, honest states) is `SocialResource` in
|
||||
* `@hanzo/ui/product/social`; this is the console half of the seam, and all it does is hand
|
||||
* that component the console's bound `/v1/social` client.
|
||||
*
|
||||
* This is the console half of the `/v1/social` domain seam — the host→mode twin of
|
||||
* Billing: on social.hanzo.ai (config.socialOnly) the console boots straight into THIS
|
||||
* product. Parity with the live social-frontend: compose + schedule, a list AND a
|
||||
* calendar (agenda) view, a real Publish action (POST /v1/social/posts/:id/publish),
|
||||
* and a connect flow that reflects each network's LIVE publish-readiness
|
||||
* (GET /v1/social/providers) — honest about which OAuth-app credentials a deployment
|
||||
* still needs, never a fabricated "connected".
|
||||
* It used to be a 600-line copy of the product. Nothing but the transport differs
|
||||
* between hosts, so the copy was pure drift risk: the dedicated social.hanzo.ai app
|
||||
* renders the SAME component with its own binding.
|
||||
*
|
||||
* Every read/write is org-scoped by the Bearer owner claim SERVER-SIDE. States are
|
||||
* honest: loading, a BackendStateCard on a `/v1` failure, and real empty states.
|
||||
* This is the host→mode twin of Billing: on social.hanzo.ai (config.socialOnly) the
|
||||
* console boots straight into this product. Every read/write is org-scoped by the
|
||||
* Bearer owner claim SERVER-SIDE.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Share2, Send, Link2, Plus, RefreshCw, Calendar, List, CheckCircle2, AlertTriangle } from '@hanzogui/lucide-icons-2'
|
||||
import { SocialResource } from '@hanzo/ui/product/social'
|
||||
|
||||
import {
|
||||
PROVIDERS,
|
||||
SocialApi,
|
||||
type Account,
|
||||
type Post,
|
||||
type ProviderCapability,
|
||||
type Summary,
|
||||
} from '~/lib/api/social'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldRow, FieldText, FieldTextArea, FieldSelect } from '~/components/ui/Field'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
|
||||
/** Compose intents → the (status, scheduleAt) the backend stores. */
|
||||
const COMPOSE_MODES = ['draft', 'schedule', 'now'] as const
|
||||
type ComposeMode = (typeof COMPOSE_MODES)[number]
|
||||
const COMPOSE_LABEL: Record<ComposeMode, string> = { draft: 'Save draft', schedule: 'Schedule', now: 'Publish now' }
|
||||
|
||||
/** Unix seconds → a short local timestamp, or '—' when unset (0). */
|
||||
function when(unix: number): string {
|
||||
if (!unix) return '—'
|
||||
return new Date(unix * 1000).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Unix seconds → a day bucket key + label for the agenda (calendar) view. */
|
||||
function dayOf(unix: number): { key: string; label: string } {
|
||||
const d = new Date(unix * 1000)
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
const label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
return { key, label }
|
||||
}
|
||||
|
||||
/** Truncate a post body for a table cell / agenda card. */
|
||||
function preview(s: string): string {
|
||||
const t = s.trim()
|
||||
return t.length > 72 ? `${t.slice(0, 72)}…` : t || '—'
|
||||
}
|
||||
|
||||
/** Parse a user-typed datetime (ISO or local) into unix seconds; invalid → 0. */
|
||||
function toUnix(dt: string): number {
|
||||
const ms = Date.parse(dt.trim())
|
||||
return Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
|
||||
}
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; data: T }
|
||||
|
||||
type Data = { summary: Summary; posts: Post[]; accounts: Account[]; providers: ProviderCapability[] }
|
||||
type View = 'list' | 'calendar'
|
||||
|
||||
const POST_COLUMNS: Column<Post>[] = [
|
||||
{ key: 'content', header: 'Post', render: (p) => preview(p.content) },
|
||||
{ key: 'channel', header: 'Channel', render: (p) => p.channel || '—' },
|
||||
{ key: 'status', header: 'Status', render: (p) => <StatusTag status={p.status} /> },
|
||||
{ key: 'scheduleAt', header: 'Scheduled', render: (p) => when(p.scheduleAt) },
|
||||
]
|
||||
|
||||
const ACCOUNT_COLUMNS: Column<Account>[] = [
|
||||
{ key: 'handle', header: 'Account', render: (a) => a.handle || '—' },
|
||||
{ key: 'provider', header: 'Network', render: (a) => a.provider || '—' },
|
||||
{ key: 'status', header: 'Status', render: (a) => <StatusTag status={a.status} /> },
|
||||
]
|
||||
|
||||
/** The per-org summary bar (real `/v1/social/summary` counts). */
|
||||
function SummaryBar({ summary }: { summary: Summary }) {
|
||||
const cells: { label: string; value: string | number }[] = [
|
||||
{ label: 'Posts', value: summary.posts },
|
||||
{ label: 'Scheduled', value: summary.scheduled },
|
||||
{ label: 'Published', value: summary.published },
|
||||
{ label: 'Accounts', value: summary.accounts },
|
||||
]
|
||||
return (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{cells.map((c) => (
|
||||
<YStack key={c.label} gap="$1" borderWidth={1} borderColor="$borderColor" rounded="$4" px="$4" py="$3" minW={140}>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{c.label}
|
||||
</Text>
|
||||
<Text fontSize="$6" fontWeight="500" className="hz-tnum">
|
||||
{c.value}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** List / Calendar view toggle (a small inline segmented control). */
|
||||
function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) {
|
||||
const opts: { id: View; label: string; icon: typeof List }[] = [
|
||||
{ id: 'list', label: 'List', icon: List },
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar },
|
||||
]
|
||||
return (
|
||||
<XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
|
||||
{opts.map((o) => {
|
||||
const Icon = o.icon
|
||||
const active = view === o.id
|
||||
return (
|
||||
<XStack
|
||||
key={o.id}
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
py="$2"
|
||||
cursor="pointer"
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
hoverStyle={{ bg: active ? '$color4' : '$color2' }}
|
||||
onPress={() => onChange(o.id)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<Text fontSize="$2" fontWeight={active ? '500' : '400'}>
|
||||
{o.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Calendar (agenda) view: scheduled/published posts with a time, grouped by day. */
|
||||
function PostAgenda({ posts, onOpen }: { posts: Post[]; onOpen: (p: Post) => void }) {
|
||||
const days = useMemo(() => {
|
||||
const timed = posts.filter((p) => p.scheduleAt > 0).sort((a, b) => a.scheduleAt - b.scheduleAt)
|
||||
const groups: { label: string; items: Post[] }[] = []
|
||||
const index = new Map<string, number>()
|
||||
for (const p of timed) {
|
||||
const { key, label } = dayOf(p.scheduleAt)
|
||||
let i = index.get(key)
|
||||
if (i === undefined) {
|
||||
i = groups.length
|
||||
index.set(key, i)
|
||||
groups.push({ label, items: [] })
|
||||
}
|
||||
groups[i].items.push(p)
|
||||
}
|
||||
return groups
|
||||
}, [posts])
|
||||
|
||||
if (days.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="Nothing on the calendar"
|
||||
description="Scheduled and timed posts appear here, grouped by day. Compose a post and pick Schedule to plan ahead."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
{days.map((d) => (
|
||||
<YStack key={d.label} gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$color11">
|
||||
{d.label}
|
||||
</Text>
|
||||
{d.items.map((p) => (
|
||||
<XStack
|
||||
key={p.id}
|
||||
items="center"
|
||||
justify="space-between"
|
||||
gap="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
px="$4"
|
||||
py="$3"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: '$color2' }}
|
||||
onPress={() => onOpen(p)}
|
||||
>
|
||||
<YStack gap="$1" flex={1}>
|
||||
<Text fontSize="$3">{preview(p.content)}</Text>
|
||||
<XStack gap="$2" items="center">
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{p.channel}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
· {when(p.scheduleAt)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<StatusTag status={p.status} />
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The post detail drawer — full content + publish results + a real Publish action. */
|
||||
function PostDetail({ post, onChanged }: { post: Post; onChanged: () => void }) {
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const canPublish = post.status === 'draft' || post.status === 'scheduled' || post.status === 'failed'
|
||||
|
||||
const publish = async () => {
|
||||
setPublishing(true)
|
||||
setError(null)
|
||||
try {
|
||||
await SocialApi.posts.publish(post.id)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
// 503 (not configured) carries the exact missing credentials — show it verbatim.
|
||||
setError(classifyBackend(e).message || 'Publish failed.')
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$3" p="$4">
|
||||
<FieldRow label="Content">
|
||||
<Text fontSize="$3">{post.content || '—'}</Text>
|
||||
</FieldRow>
|
||||
<FieldRow label="Channel">
|
||||
<Text fontSize="$3">{post.channel}</Text>
|
||||
</FieldRow>
|
||||
<FieldRow label="Status">
|
||||
<StatusTag status={post.status} />
|
||||
</FieldRow>
|
||||
{post.scheduleAt > 0 ? (
|
||||
<FieldRow label="Scheduled">
|
||||
<Text fontSize="$3">{when(post.scheduleAt)}</Text>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
{post.media.length > 0 ? (
|
||||
<FieldRow label="Media">
|
||||
<YStack gap="$1">
|
||||
{post.media.map((m) => (
|
||||
<Text key={m} fontSize="$2" color="$color11">
|
||||
{m}
|
||||
</Text>
|
||||
))}
|
||||
</YStack>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
{post.externalId ? (
|
||||
<FieldRow label="External id">
|
||||
<Text fontSize="$3" className="hz-tnum">
|
||||
{post.externalId}
|
||||
</Text>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
{post.error ? (
|
||||
<FieldRow label="Last error">
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{post.error}
|
||||
</Text>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
{canPublish ? (
|
||||
<PrimaryButton onPress={publish} disabled={publishing} icon={<Send size={16} />}>
|
||||
{publishing ? 'Publishing…' : 'Publish now'}
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The compose panel — real POST /v1/social/posts, with draft/schedule/publish-now. */
|
||||
function CreatePostPanel({
|
||||
providers,
|
||||
onCreated,
|
||||
}: {
|
||||
providers: ProviderCapability[]
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [content, setContent] = useState('')
|
||||
const [channel, setChannel] = useState<string>('x')
|
||||
const [mode, setMode] = useState<ComposeMode>('draft')
|
||||
const [scheduleAt, setScheduleAt] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const cap = providers.find((p) => p.provider === channel)
|
||||
const unconfigured = (mode === 'now' || mode === 'schedule') && cap && !cap.credentialsConfigured
|
||||
|
||||
const submit = async () => {
|
||||
if (!content.trim()) {
|
||||
setError('Content is required.')
|
||||
return
|
||||
}
|
||||
let status = 'draft'
|
||||
let at = 0
|
||||
if (mode === 'schedule') {
|
||||
at = toUnix(scheduleAt)
|
||||
if (at <= Math.floor(Date.now() / 1000)) {
|
||||
setError('Pick a future date and time to schedule.')
|
||||
return
|
||||
}
|
||||
status = 'scheduled'
|
||||
} else if (mode === 'now') {
|
||||
status = 'scheduled' // scheduled + scheduleAt 0 ⇒ the backend publishes on create
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const created = await SocialApi.posts.create({ content: content.trim(), channel, status, scheduleAt: at })
|
||||
// Honest publish-now feedback: if the fan-out failed (e.g. not configured), keep the
|
||||
// panel open and show why — the post exists and is marked failed.
|
||||
if (mode === 'now' && created.status === 'failed') {
|
||||
setError(created.error || 'Publish failed.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
setError(classifyBackend(e).message || 'Failed to create post.')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$3" p="$4">
|
||||
<FieldRow label="Content">
|
||||
<FieldTextArea value={content} onChange={setContent} disabled={saving} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Channel">
|
||||
<FieldSelect value={channel} options={[...PROVIDERS]} onChange={setChannel} disabled={saving} />
|
||||
</FieldRow>
|
||||
<FieldRow label="When">
|
||||
<FieldSelect
|
||||
value={mode}
|
||||
options={[...COMPOSE_MODES]}
|
||||
onChange={(v) => setMode(v as ComposeMode)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</FieldRow>
|
||||
{mode === 'schedule' ? (
|
||||
<FieldRow label="Schedule at">
|
||||
<FieldText value={scheduleAt} onChange={setScheduleAt} placeholder="2026-07-15 09:00" disabled={saving} />
|
||||
</FieldRow>
|
||||
) : null}
|
||||
{unconfigured ? (
|
||||
<XStack items="flex-start" gap="$2">
|
||||
<AlertTriangle size={14} color="var(--yellow10)" />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{channel} isn’t configured to publish yet — needs {cap?.missingCredentials.join(', ')}. The post is saved and
|
||||
marked failed on publish until credentials are supplied.
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<PrimaryButton onPress={submit} disabled={saving}>
|
||||
{saving ? 'Working…' : COMPOSE_LABEL[mode]}
|
||||
</PrimaryButton>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The connect panel — LIVE per-network readiness + a real account add. */
|
||||
function ConnectAccountPanel({
|
||||
providers,
|
||||
onCreated,
|
||||
}: {
|
||||
providers: ProviderCapability[]
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [provider, setProvider] = useState<string>('x')
|
||||
const [handle, setHandle] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await SocialApi.accounts.create({ provider, handle: handle.trim(), status: 'connected' })
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
setError(classifyBackend(e).message || 'Failed to connect account.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$4" p="$4">
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Network publish-readiness
|
||||
</Text>
|
||||
{providers.map((p) => (
|
||||
<XStack key={p.provider} items="center" justify="space-between" gap="$3" py="$1">
|
||||
<XStack items="center" gap="$2">
|
||||
{p.credentialsConfigured ? (
|
||||
<CheckCircle2 size={14} color="var(--green10)" />
|
||||
) : (
|
||||
<AlertTriangle size={14} color="var(--yellow10)" />
|
||||
)}
|
||||
<Text fontSize="$2">{p.provider}</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{p.credentialsConfigured ? 'Ready' : `needs ${p.missingCredentials.join(', ')}`}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$3">
|
||||
<FieldRow label="Network">
|
||||
<FieldSelect value={provider} options={[...PROVIDERS]} onChange={setProvider} disabled={saving} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Handle">
|
||||
<FieldText value={handle} onChange={setHandle} placeholder="@hanzo" disabled={saving} />
|
||||
</FieldRow>
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<PrimaryButton onPress={submit} disabled={saving}>
|
||||
{saving ? 'Connecting…' : 'Connect account'}
|
||||
</PrimaryButton>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
import { SocialApi } from '~/lib/api/social'
|
||||
|
||||
export function SocialModule(_props: { params: Record<string, string> }) {
|
||||
const [state, setState] = useState<Async<Data>>({ phase: 'loading' })
|
||||
const [view, setView] = useState<View>('list')
|
||||
const [creatingPost, setCreatingPost] = useState(false)
|
||||
const [creatingAccount, setCreatingAccount] = useState(false)
|
||||
const [openPost, setOpenPost] = useState<Post | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const [summary, posts, accounts, providers] = await Promise.all([
|
||||
SocialApi.summary(),
|
||||
SocialApi.posts.list(),
|
||||
SocialApi.accounts.list(),
|
||||
SocialApi.providers(),
|
||||
])
|
||||
setState({ phase: 'ready', data: { summary, posts, accounts, providers } })
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const providers = state.phase === 'ready' ? state.data.providers : []
|
||||
const posts = state.phase === 'ready' ? state.data.posts : []
|
||||
const onCreatedPost = () => {
|
||||
setCreatingPost(false)
|
||||
void load()
|
||||
}
|
||||
const onCreatedAccount = () => {
|
||||
setCreatingAccount(false)
|
||||
void load()
|
||||
}
|
||||
const onPostChanged = () => {
|
||||
setOpenPost(null)
|
||||
void load()
|
||||
}
|
||||
|
||||
const empty = state.phase === 'ready' && state.data.posts.length === 0 && state.data.accounts.length === 0
|
||||
|
||||
return (
|
||||
<YStack gap="$4" p="$4">
|
||||
<PageHeader
|
||||
title="Publish"
|
||||
subtitle="Compose, schedule and publish your content across networks — per org, over the native /v1/social engine."
|
||||
actions={
|
||||
<>
|
||||
<PrimaryButton onPress={() => setCreatingPost(true)} icon={<Plus size={16} />}>
|
||||
New post
|
||||
</PrimaryButton>
|
||||
<PrimaryButton onPress={() => setCreatingAccount(true)} icon={<Link2 size={16} />}>
|
||||
Connect account
|
||||
</PrimaryButton>
|
||||
<PrimaryButton onPress={() => void load()} icon={<RefreshCw size={16} />}>
|
||||
Refresh
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
<BackendStateCard state={state.error} onRetry={() => void load()} />
|
||||
) : empty ? (
|
||||
<YStack gap="$4">
|
||||
{state.phase === 'ready' ? <SummaryBar summary={state.data.summary} /> : null}
|
||||
<EmptyState
|
||||
icon={Share2}
|
||||
title="No posts or accounts yet"
|
||||
description="Connect a social account, then compose, schedule and publish across X, Instagram, LinkedIn, TikTok and more."
|
||||
primary={{ label: 'New post', onPress: () => setCreatingPost(true) }}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$5">
|
||||
{state.phase === 'ready' ? <SummaryBar summary={state.data.summary} /> : null}
|
||||
|
||||
<YStack gap="$3">
|
||||
<XStack items="center" justify="space-between" gap="$2" flexWrap="wrap">
|
||||
<XStack items="center" gap="$2">
|
||||
<Send size={16} />
|
||||
<Text fontSize="$5" fontWeight="500">
|
||||
Posts
|
||||
</Text>
|
||||
</XStack>
|
||||
<ViewToggle view={view} onChange={setView} />
|
||||
</XStack>
|
||||
|
||||
{view === 'list' ? (
|
||||
<DataTable<Post>
|
||||
columns={POST_COLUMNS}
|
||||
rows={posts}
|
||||
loading={state.phase === 'loading'}
|
||||
empty="No posts yet."
|
||||
rowKey={(p) => p.id}
|
||||
onRowPress={(p) => setOpenPost(p)}
|
||||
/>
|
||||
) : (
|
||||
<PostAgenda posts={posts} onOpen={(p) => setOpenPost(p)} />
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<XStack items="center" gap="$2">
|
||||
<Link2 size={16} />
|
||||
<Text fontSize="$5" fontWeight="500">
|
||||
Accounts
|
||||
</Text>
|
||||
</XStack>
|
||||
<DataTable<Account>
|
||||
columns={ACCOUNT_COLUMNS}
|
||||
rows={state.phase === 'ready' ? state.data.accounts : []}
|
||||
loading={state.phase === 'loading'}
|
||||
empty="No accounts connected yet."
|
||||
rowKey={(a) => a.id}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<SlideOver open={creatingPost} onClose={() => setCreatingPost(false)} title="New post" icon={Send} ariaLabel="New post">
|
||||
<CreatePostPanel providers={providers} onCreated={onCreatedPost} />
|
||||
</SlideOver>
|
||||
<SlideOver
|
||||
open={creatingAccount}
|
||||
onClose={() => setCreatingAccount(false)}
|
||||
title="Connect account"
|
||||
icon={Link2}
|
||||
ariaLabel="Connect account"
|
||||
>
|
||||
<ConnectAccountPanel providers={providers} onCreated={onCreatedAccount} />
|
||||
</SlideOver>
|
||||
<SlideOver
|
||||
open={openPost !== null}
|
||||
onClose={() => setOpenPost(null)}
|
||||
title="Post"
|
||||
icon={Send}
|
||||
ariaLabel="Post detail"
|
||||
>
|
||||
{openPost ? <PostDetail post={openPost} onChanged={onPostChanged} /> : null}
|
||||
</SlideOver>
|
||||
</YStack>
|
||||
)
|
||||
return <SocialResource api={SocialApi} />
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* `up == 0` is a real "down". Nothing is fabricated — there are no fake green dots,
|
||||
* only what the TSDB actually reports.
|
||||
*
|
||||
* Why not `/paas/apps` (the old source): the platform apps inventory reports ZERO
|
||||
* Why not `/v1/platform/fleet` (the old source): the fleet inventory reports ZERO
|
||||
* apps on this deployment, so that board was empty for admins and "managed by Hanzo"
|
||||
* for customers — it showed no health at all. VictoriaMetrics is where the live
|
||||
* signal is, and platform status is a status-page concern appropriate for any
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - POST /v1/webhooks create → the row incl. `secret` (reveal-once)
|
||||
* - PATCH /v1/webhooks/:id enable/disable + edit
|
||||
* - DELETE /v1/webhooks/:id remove
|
||||
* - POST /v1/webhooks/:id/rotate-secret → { secret } (reveal-once)
|
||||
* - POST /v1/webhooks/:id/secret → { secret } (reveal-once)
|
||||
* - POST /v1/webhooks/:id/test → { delivered, httpStatus, durationMs, error? }
|
||||
* - GET /v1/webhooks/:id/deliveries → recent attempts (newest-first)
|
||||
*
|
||||
@@ -436,7 +436,7 @@ export function WebhooksModule({ params }: { params: Record<string, string> }) {
|
||||
setBusyId(w.id)
|
||||
setActionMsg(null)
|
||||
try {
|
||||
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/rotate-secret`))
|
||||
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/secret`))
|
||||
const secret = str(res?.secret)
|
||||
if (secret) setRevealed({ title: 'New signing secret', url: w.url, secret })
|
||||
setActionMsg({ tone: 'ok', text: 'Signing secret rotated.' })
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 2. CAPS — oversight + override of ANY org's usage caps: pick a target org, list its
|
||||
* spend caps (real threshold / period-spend meter / hard-cap vs alert / rate limit /
|
||||
* when it resets), and create / edit / delete a cap on that org's behalf. Backed by
|
||||
* `GET/POST/PATCH/DELETE /v1/admin/spend-caps?org=<slug>`.
|
||||
* `GET/POST/PATCH/DELETE /v1/admin/caps?org=<slug>`.
|
||||
*
|
||||
* All reads/writes terminate at the GLOBAL-ADMIN-GATED `app/admin/aggregate` proxy
|
||||
* (`getAdminGate`, fail-closed 403, then a minted user bearer + same-origin CSRF), and
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { AdminPromosApi, type PlatformPromo } from '~/lib/api/admin-promos'
|
||||
import { AdminSpendCapsApi, type AdminSpendCap } from '~/lib/api/admin-spend-caps'
|
||||
import { AdminCapsApi, type AdminCap } from '~/lib/api/admin-caps'
|
||||
import { fmtInt, fmtUsd } from '~/lib/api/functions'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
@@ -341,7 +341,7 @@ function CapFields({ form, setForm, disabled }: { form: BudgetForm; setForm: (f:
|
||||
)
|
||||
}
|
||||
|
||||
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onChanged: () => void }) {
|
||||
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminCap; onChanged: () => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState<BudgetForm>(() => formForAlert(cap))
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -367,7 +367,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await AdminSpendCapsApi.update(org, cap.id, {
|
||||
await AdminCapsApi.update(org, cap.id, {
|
||||
title: v.title,
|
||||
thresholdCents: v.thresholdCents,
|
||||
project: v.project,
|
||||
@@ -391,7 +391,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
|
||||
setRemoving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await AdminSpendCapsApi.remove(org, cap.id)
|
||||
await AdminCapsApi.remove(org, cap.id)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
const a = asApiError(e)
|
||||
@@ -499,7 +499,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await AdminSpendCapsApi.create(org, {
|
||||
await AdminCapsApi.create(org, {
|
||||
title: v.title,
|
||||
thresholdCents: v.thresholdCents,
|
||||
project: v.project,
|
||||
@@ -542,7 +542,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
|
||||
function CapsTab() {
|
||||
const [orgInput, setOrgInput] = useState('')
|
||||
const [org, setOrg] = useState('')
|
||||
const [state, setState] = useState<Async<AdminSpendCap[]> | null>(null)
|
||||
const [state, setState] = useState<Async<AdminCap[]> | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const load = useCallback((slug: string) => {
|
||||
@@ -551,7 +551,7 @@ function CapsTab() {
|
||||
setOrg(s)
|
||||
setAdding(false)
|
||||
setState({ phase: 'loading' })
|
||||
AdminSpendCapsApi.list(s)
|
||||
AdminCapsApi.list(s)
|
||||
.then((data) => setState({ phase: 'ready', data }))
|
||||
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
|
||||
}, [])
|
||||
|
||||
@@ -154,7 +154,7 @@ export function InfraModule({ params }: { params: Record<string, string> }) {
|
||||
if (tab === 'clusters') return <ClustersTab data={data} loading={loading} />
|
||||
if (tab === 'nodes') return <NodesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
|
||||
if (tab === 'volumes') return <VolumesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
|
||||
if (tab === 'load-balancers') return <LoadBalancersTab data={data} loading={loading} />
|
||||
if (tab === 'balancers') return <LoadBalancersTab data={data} loading={loading} />
|
||||
if (tab === 'audit') return <AuditTab data={data} loading={loading} />
|
||||
return <OverviewTab data={data} loading={loading} />
|
||||
})()
|
||||
@@ -448,7 +448,7 @@ const TONE_COLOR = { green: '$green11', yellow: '$yellow11', red: '$red11', neut
|
||||
|
||||
// fillOf joins the block-storage read onto an inventory volume. The two backends
|
||||
// answer different questions about the SAME object — /v1/admin/infra knows whether a
|
||||
// volume is referenced (and therefore safe to delete), /v1/admin/block-storage knows
|
||||
// volume is referenced (and therefore safe to delete), /v1/admin/volumes knows
|
||||
// how full it is — so the board reads both and shows one row. Two boards for one
|
||||
// noun is what this replaces; two READS for one row is fine, and each degrades on
|
||||
// its own (no fill data → an honest em-dash, never a fabricated 0%).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Budgets & limits — view and set per-scope SPEND CAPS and RATE LIMITS over the REAL
|
||||
* commerce spend-alerts API (`GET/POST/PATCH/DELETE /v1/billing/spend-alerts`, the
|
||||
* commerce alerts API (`GET/POST/PATCH/DELETE /v1/billing/alerts`, the
|
||||
* user-group endpoints billing.hanzo.ai itself uses), scoped to the caller's OWN
|
||||
* subject by the `/billing` proxy (server-pinned — a caller only ever sees/edits their
|
||||
* own budgets).
|
||||
@@ -586,7 +586,7 @@ export function BillingBudgets(_props: { params: Record<string, string> }) {
|
||||
/>
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/spend-alerts" />
|
||||
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/alerts" />
|
||||
) : state.phase === 'loading' ? (
|
||||
<LoadingCards />
|
||||
) : (
|
||||
|
||||
@@ -148,7 +148,7 @@ export type OverviewContext = {
|
||||
allOrgs?: boolean
|
||||
/**
|
||||
* True when the viewer is a global (cross-tenant) admin. Loaders use this to
|
||||
* SKIP admin-only aggregates (e.g. `/v1/admin/overview`, `/paas/apps`) that a
|
||||
* SKIP admin-only aggregates (e.g. `/v1/admin/overview`, `/v1/platform/fleet`) that a
|
||||
* tenant user's browser would only ever get a 403 from, going straight to the
|
||||
* org-scoped source instead — the board renders the same, minus the console noise.
|
||||
*/
|
||||
|
||||
@@ -51,7 +51,7 @@ const fnRange = (r: OverviewRange): '24H' | '7D' | '30D' => (r === '24h' ? '24H'
|
||||
* renders. NEVER throws into the caller.
|
||||
*/
|
||||
async function withHealth(data: OverviewData, probeApps = true): Promise<OverviewData> {
|
||||
// The apps inventory (`/paas/apps`) is admin-gated on this deployment, so a
|
||||
// The fleet inventory (`/v1/platform/fleet`) is admin-gated on this deployment, so a
|
||||
// tenant user's probe only 403s — skip it and leave health in its honest empty
|
||||
// state instead of firing a request we know will be rejected (keeps the browser
|
||||
// console clean). Super admins (probeApps=true, the default) still enrich health.
|
||||
|
||||
@@ -13,22 +13,22 @@ describe('referrals logic — money/label/tone formatting', () => {
|
||||
})
|
||||
|
||||
it('labels each status', () => {
|
||||
expect(statusLabel('signed_up')).toBe('Signed up')
|
||||
expect(statusLabel('signup')).toBe('Signed up')
|
||||
expect(statusLabel('qualified')).toBe('Qualified')
|
||||
expect(statusLabel('credited')).toBe('Credited')
|
||||
expect(statusLabel('weird' as never)).toBe('weird')
|
||||
})
|
||||
|
||||
it('tones by status (credited positive, qualified warning, signed_up muted)', () => {
|
||||
it('tones by status (credited positive, qualified warning, signup muted)', () => {
|
||||
expect(statusTone('credited')).toBe('positive')
|
||||
expect(statusTone('qualified')).toBe('warning')
|
||||
expect(statusTone('signed_up')).toBe('muted')
|
||||
expect(statusTone('signup')).toBe('muted')
|
||||
})
|
||||
|
||||
it('colors a status from the one greyscale map', () => {
|
||||
for (const s of ['credited', 'qualified', 'signed_up'] as const)
|
||||
for (const s of ['credited', 'qualified', 'signup'] as const)
|
||||
expect(statusColor(s)).toMatch(/^\$color(9|10|11|12)$/)
|
||||
expect(statusColor('signed_up')).toBe('$color9')
|
||||
expect(statusColor('signup')).toBe('$color9')
|
||||
})
|
||||
|
||||
it('short-dates a unix second, em-dash for unset', () => {
|
||||
@@ -43,7 +43,7 @@ describe('referrals logic — money/label/tone formatting', () => {
|
||||
expect(progressCaption({ id: 'a', referee: 'x', status: 'qualified', creditsCents: 0, createdAt: 1, qualifiedAt: 1, creditedAt: 0 })).toBe(
|
||||
'Qualified — bonus landing',
|
||||
)
|
||||
expect(progressCaption({ id: 'a', referee: 'x', status: 'signed_up', creditsCents: 0, createdAt: 1, qualifiedAt: 0, creditedAt: 0 })).toBe(
|
||||
expect(progressCaption({ id: 'a', referee: 'x', status: 'signup', creditsCents: 0, createdAt: 1, qualifiedAt: 0, creditedAt: 0 })).toBe(
|
||||
'Signed up — earns when they use Hanzo',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ export function usd(cents: number | null | undefined): string {
|
||||
/** Human label for a referral status. */
|
||||
export function statusLabel(status: ReferralStatus): string {
|
||||
switch (status) {
|
||||
case 'signed_up':
|
||||
case 'signup':
|
||||
return 'Signed up'
|
||||
case 'qualified':
|
||||
return 'Qualified'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Workbench — the persistent Developers dock at the FOOT of the dashboard (the
|
||||
* Stripe-Workbench pattern): a slim always-there bar ("Developers" + a `$` prompt
|
||||
* developer-workbench pattern): a slim always-there bar ("Developers" + a `$` prompt
|
||||
* + quick icons) that expands into a bottom drawer of developer tabs, available on
|
||||
* EVERY page without leaving it. Desktop-only (lg+ — a pointer-density concern);
|
||||
* open state persists per-user via the ONE preferences store.
|
||||
|
||||
@@ -66,6 +66,20 @@ describe('white-label cloud tenants (7stars / yotoda)', () => {
|
||||
expect(brandFromHost('cloud.yotoda.tech')).toBe('yotoda')
|
||||
})
|
||||
|
||||
it('resolves the brand under a padded / ported / trailing-dot host (admin-gate boundary)', () => {
|
||||
// A mis-resolve here would swap the admin-gate adminDomain onto the default
|
||||
// brand. Normalization must strip a trailing port even when whitespace-padded
|
||||
// (trim-before-port) and a trailing FQDN root dot.
|
||||
expect(brandFromHost(' lux.network:8443 ')).toBe('lux')
|
||||
expect(brandFromHost('\tcloud.7stars.dev:443\n')).toBe('7stars')
|
||||
expect(brandFromHost('lux.network.')).toBe('lux')
|
||||
expect(brandFromHost('cloud.7stars.dev.')).toBe('7stars')
|
||||
expect(brandFromHost('LUX.NETWORK.:443')).toBe('lux')
|
||||
// A lookalike must still fall to the default even with a trailing dot / port.
|
||||
expect(brandFromHost('evil7stars.dev.')).toBe('hanzo')
|
||||
expect(brandFromHost('lux.network.evil.com:443')).toBe('hanzo')
|
||||
})
|
||||
|
||||
it('authenticates against the hanzo.id issuer (no own .id) with the per-brand org + app', () => {
|
||||
const s = resolveConfig('cloud.7stars.dev')
|
||||
expect(s.brand).toBe('7stars')
|
||||
|
||||
+12
-4
@@ -217,9 +217,12 @@ const HOST_BRANDS: ReadonlyArray<{ suffix: string; brand: BrandId }> = [
|
||||
{ suffix: 'yotoda.tech', brand: 'yotoda' },
|
||||
]
|
||||
|
||||
/** Resolve the brand id from a hostname (port/case-insensitive). Defaults to hanzo. */
|
||||
/** Resolve the brand id from a hostname (port/case/trailing-dot-insensitive).
|
||||
* Defaults to hanzo. Normalization goes through the one `normHost` helper so
|
||||
* the suffix match can never be softened by a padded/ported/FQDN-dot host
|
||||
* (a mis-resolve here would swap the admin-gate `adminDomain`). */
|
||||
export function brandFromHost(host?: string | null): BrandId {
|
||||
const hostname = (host ?? '').toLowerCase().replace(/:\d+$/, '').trim()
|
||||
const hostname = normHost(host)
|
||||
if (hostname) {
|
||||
for (const e of HOST_BRANDS) {
|
||||
if (hostname === e.suffix || hostname.endsWith('.' + e.suffix)) return e.brand
|
||||
@@ -234,9 +237,14 @@ function currentHost(): string {
|
||||
return process.env.NEXT_PUBLIC_DEFAULT_HOST ?? 'cloud.hanzo.ai'
|
||||
}
|
||||
|
||||
/** Normalize a host for keying/matching (lowercase, strip port). */
|
||||
/** Normalize a host for keying/matching: trim, lowercase, strip trailing port,
|
||||
* strip the FQDN root dot(s). Order is load-bearing — trim BEFORE the port
|
||||
* strip so a padded `"host:port "` still loses its port (the `:\d+$` anchor
|
||||
* would otherwise miss past trailing space); strip the port before the trailing
|
||||
* dot so an FQDN-with-port collapses to the bare host. Mirrors
|
||||
* `@hanzo/brand`'s `normalizeHost` (the fleet's one host normalizer). */
|
||||
function normHost(host?: string | null): string {
|
||||
return (host ?? '').toLowerCase().replace(/:\d+$/, '').trim()
|
||||
return (host ?? '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// The IAM seam. `session()` reads a valid access token and projects its claims onto
|
||||
// the console `Account`; mocking the token lets the projection be asserted directly.
|
||||
const token = vi.hoisted(() => ({ value: null as string | null }))
|
||||
|
||||
vi.mock('~/lib/auth/iam', () => ({
|
||||
iamValidAccessToken: async () => token.value,
|
||||
iamUserInfo: async () => null,
|
||||
iamExpiresInSeconds: () => 3600,
|
||||
iamSignOut: () => {},
|
||||
}))
|
||||
|
||||
const { AccountApi } = await import('~/lib/api/account')
|
||||
|
||||
/** Build an unsigned JWT carrying `claims` — only the payload is ever decoded. */
|
||||
function jwt(claims: Record<string, unknown>): string {
|
||||
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
||||
return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.`
|
||||
}
|
||||
|
||||
/** The claim set hanzo.id actually issues (trimmed to what this projection reads). */
|
||||
const IAM_CLAIMS = {
|
||||
iss: 'https://hanzo.id',
|
||||
sub: '2d4d67ab-30f1-474e-b81f-f60461852259',
|
||||
owner: 'hanzo',
|
||||
organization: 'hanzo',
|
||||
email: 'z@hanzo.ai',
|
||||
preferred_username: 'z',
|
||||
name: 'Zach Kelling',
|
||||
}
|
||||
|
||||
describe('account identity from IAM claims', () => {
|
||||
beforeEach(() => {
|
||||
token.value = null
|
||||
})
|
||||
|
||||
it('carries the IAM user id (the `sub` claim) onto the account', async () => {
|
||||
token.value = jwt(IAM_CLAIMS)
|
||||
|
||||
const account = await AccountApi.current()
|
||||
|
||||
// This is the value every Hanzo property identifies this user by. It was
|
||||
// previously decoded, used only to back-derive owner/name, and dropped — so
|
||||
// nothing downstream could join this user to their hanzo.ai or hanzo.chat
|
||||
// activity.
|
||||
expect(account?.userId).toBe('2d4d67ab-30f1-474e-b81f-f60461852259')
|
||||
})
|
||||
|
||||
it('keeps the user id distinct from the org-relative owner/name reference', async () => {
|
||||
token.value = jwt(IAM_CLAIMS)
|
||||
|
||||
const account = await AccountApi.current()
|
||||
|
||||
// owner/name still resolve (display + org-scoped API paths depend on them) —
|
||||
// but they are a DIFFERENT id space, and identifying by them is what counted
|
||||
// one user twice across surfaces.
|
||||
expect(account?.owner).toBe('hanzo')
|
||||
expect(account?.name).toBe('Zach Kelling')
|
||||
expect(account?.userId).not.toBe(`${account?.owner}/${account?.name}`)
|
||||
})
|
||||
|
||||
it('leaves the user id absent rather than substituting owner/name', async () => {
|
||||
// A token with no `sub` has no IAM user id. Filling it with the actor ref would
|
||||
// silently reintroduce the cross-surface split this field exists to fix.
|
||||
const { sub: _dropped, ...noSub } = IAM_CLAIMS
|
||||
token.value = jwt(noSub)
|
||||
|
||||
const account = await AccountApi.current()
|
||||
|
||||
expect(account).not.toBeNull()
|
||||
expect(account?.userId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has no account at all when signed out', async () => {
|
||||
token.value = null
|
||||
expect(await AccountApi.current()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,13 @@ function accountFromClaims(claims: Record<string, unknown>): Account | null {
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
// The IAM user id, carried through rather than discarded. It was already
|
||||
// decoded here and used only to BACK-DERIVE owner/name; the stable id itself
|
||||
// was dropped, so nothing downstream could identify this user as the same
|
||||
// one hanzo.ai and hanzo.chat see. Empty on a token that carries no `sub`,
|
||||
// and absent is left absent — never substituted with owner/name, which is a
|
||||
// different id space.
|
||||
userId: sub || undefined,
|
||||
type: str('type') ?? 'normal-user',
|
||||
displayName: str('displayName') ?? str('display_name'),
|
||||
email: str('email'),
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('./client', () => ({
|
||||
}))
|
||||
|
||||
import { originGet, originPost, originPatch, originDelete } from './client'
|
||||
import { AdminSpendCapsApi, normalizeAdminCap } from './admin-spend-caps'
|
||||
import { AdminCapsApi, normalizeAdminCap } from './admin-caps'
|
||||
|
||||
const mGet = originGet as unknown as ReturnType<typeof vi.fn>
|
||||
const mPost = originPost as unknown as ReturnType<typeof vi.fn>
|
||||
@@ -76,7 +76,7 @@ describe('normalizeAdminCap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminSpendCapsApi', () => {
|
||||
describe('AdminCapsApi', () => {
|
||||
beforeEach(() => {
|
||||
mGet.mockReset()
|
||||
mPost.mockReset()
|
||||
@@ -89,20 +89,20 @@ describe('AdminSpendCapsApi', () => {
|
||||
{ id: 'a1', threshold: 100 },
|
||||
{ id: 'a2', threshold: 200 },
|
||||
])
|
||||
const rows = await AdminSpendCapsApi.list('acme')
|
||||
expect(mGet).toHaveBeenCalledWith('admin/spend-caps', { org: 'acme' })
|
||||
const rows = await AdminCapsApi.list('acme')
|
||||
expect(mGet).toHaveBeenCalledWith('admin/caps', { org: 'acme' })
|
||||
expect(rows.map((r) => r.id)).toEqual(['a1', 'a2'])
|
||||
expect(rows[0].thresholdCents).toBe(100)
|
||||
})
|
||||
|
||||
it('list tolerates a non-array payload → honest empty', async () => {
|
||||
mGet.mockResolvedValueOnce(null)
|
||||
expect(await AdminSpendCapsApi.list('acme')).toEqual([])
|
||||
expect(await AdminCapsApi.list('acme')).toEqual([])
|
||||
})
|
||||
|
||||
it('create POSTs the wire body (threshold cents), org-scoped, omitting absent currency', async () => {
|
||||
mPost.mockResolvedValueOnce({ id: 'new', threshold: 50000 })
|
||||
await AdminSpendCapsApi.create('acme', {
|
||||
await AdminCapsApi.create('acme', {
|
||||
title: 'Cap',
|
||||
thresholdCents: 50000,
|
||||
project: '',
|
||||
@@ -112,7 +112,7 @@ describe('AdminSpendCapsApi', () => {
|
||||
rateLimitRpm: 0,
|
||||
})
|
||||
expect(mPost).toHaveBeenCalledWith(
|
||||
'admin/spend-caps',
|
||||
'admin/caps',
|
||||
{ title: 'Cap', threshold: 50000, project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0 },
|
||||
{ org: 'acme' },
|
||||
)
|
||||
@@ -120,13 +120,13 @@ describe('AdminSpendCapsApi', () => {
|
||||
|
||||
it('update PATCHes only the provided fields (partial), org-scoped', async () => {
|
||||
mPatch.mockResolvedValueOnce({ id: 'a1', threshold: 100 })
|
||||
await AdminSpendCapsApi.update('acme', 'a1', { thresholdCents: 100 })
|
||||
expect(mPatch).toHaveBeenCalledWith('admin/spend-caps/a1', { threshold: 100 }, { org: 'acme' })
|
||||
await AdminCapsApi.update('acme', 'a1', { thresholdCents: 100 })
|
||||
expect(mPatch).toHaveBeenCalledWith('admin/caps/a1', { threshold: 100 }, { org: 'acme' })
|
||||
})
|
||||
|
||||
it('remove DELETEs the :id sub-path, org-scoped, and url-encodes the id', async () => {
|
||||
mDelete.mockResolvedValueOnce(undefined)
|
||||
await AdminSpendCapsApi.remove('acme', 'a/b')
|
||||
expect(mDelete).toHaveBeenCalledWith('admin/spend-caps/a%2Fb', { org: 'acme' })
|
||||
await AdminCapsApi.remove('acme', 'a/b')
|
||||
expect(mDelete).toHaveBeenCalledWith('admin/caps/a%2Fb', { org: 'acme' })
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* (the same spend-alert / budget primitive a tenant manages under Billing → Budgets,
|
||||
* but cross-tenant). GLOBAL-ADMIN only.
|
||||
*
|
||||
* Reads/writes the cloud `/v1/admin/spend-caps` surface (casibase `{status,msg,data}`
|
||||
* Reads/writes the cloud `/v1/admin/caps` surface (casibase `{status,msg,data}`
|
||||
* envelope) through `originGet`/`originPost`/`originPatch`/`originDelete` — same-origin,
|
||||
* so they terminate at the GLOBAL-ADMIN-GATED `app/admin/aggregate` proxy (`getAdminGate`,
|
||||
* fail-closed 403, then a minted user bearer + same-origin CSRF). The target org travels
|
||||
@@ -21,7 +21,7 @@ import { originGet, originPost, originPatch, originDelete } from './client'
|
||||
import type { SpendAlert } from './billing'
|
||||
|
||||
/** A tenant spend cap as seen by the operator — the `SpendAlert` + oversight fields. */
|
||||
export type AdminSpendCap = SpendAlert & {
|
||||
export type AdminCap = SpendAlert & {
|
||||
/** The IAM user (`<owner>/<name>`) the cap belongs to, if the backend reports it. */
|
||||
userId: string
|
||||
/** The billing period the meter covers, `YYYY-MM`. */
|
||||
@@ -51,8 +51,8 @@ const num = (v: unknown): number | undefined => (typeof v === 'number' && Number
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
const bool = (v: unknown): boolean => v === true || v === 'true' || v === 1
|
||||
|
||||
/** Roll one backend spend-cap row into the display `AdminSpendCap`; snake_case tolerant. */
|
||||
export function normalizeAdminCap(raw: unknown): AdminSpendCap {
|
||||
/** Roll one backend spend-cap row into the display `AdminCap`; snake_case tolerant. */
|
||||
export function normalizeAdminCap(raw: unknown): AdminCap {
|
||||
const r = asRecord(raw)
|
||||
return {
|
||||
id: str(r.id) || str(r.alertId),
|
||||
@@ -90,20 +90,20 @@ function capBody(input: Partial<CapInput>): Record<string, unknown> {
|
||||
return b
|
||||
}
|
||||
|
||||
export const AdminSpendCapsApi = {
|
||||
/** Every spend cap for `org` (`GET /v1/admin/spend-caps?org=<slug>`); honest empty on none. */
|
||||
list: async (org: string): Promise<AdminSpendCap[]> =>
|
||||
arr(await originGet<unknown>('admin/spend-caps', { org })).map(normalizeAdminCap),
|
||||
export const AdminCapsApi = {
|
||||
/** Every spend cap for `org` (`GET /v1/admin/caps?org=<slug>`); honest empty on none. */
|
||||
list: async (org: string): Promise<AdminCap[]> =>
|
||||
arr(await originGet<unknown>('admin/caps', { org })).map(normalizeAdminCap),
|
||||
|
||||
/** Create a cap for `org` (`POST /v1/admin/spend-caps?org=<slug>`). */
|
||||
create: async (org: string, input: CapInput): Promise<AdminSpendCap> =>
|
||||
normalizeAdminCap(await originPost<unknown>('admin/spend-caps', capBody(input), { org })),
|
||||
/** Create a cap for `org` (`POST /v1/admin/caps?org=<slug>`). */
|
||||
create: async (org: string, input: CapInput): Promise<AdminCap> =>
|
||||
normalizeAdminCap(await originPost<unknown>('admin/caps', capBody(input), { org })),
|
||||
|
||||
/** Edit a cap (`PATCH /v1/admin/spend-caps/:id?org=<slug>`); a partial of the fields. */
|
||||
update: async (org: string, id: string, patch: Partial<CapInput>): Promise<AdminSpendCap> =>
|
||||
normalizeAdminCap(await originPatch<unknown>(`admin/spend-caps/${encodeURIComponent(id)}`, capBody(patch), { org })),
|
||||
/** Edit a cap (`PATCH /v1/admin/caps/:id?org=<slug>`); a partial of the fields. */
|
||||
update: async (org: string, id: string, patch: Partial<CapInput>): Promise<AdminCap> =>
|
||||
normalizeAdminCap(await originPatch<unknown>(`admin/caps/${encodeURIComponent(id)}`, capBody(patch), { org })),
|
||||
|
||||
/** Remove a cap (`DELETE /v1/admin/spend-caps/:id?org=<slug>`). */
|
||||
/** Remove a cap (`DELETE /v1/admin/caps/:id?org=<slug>`). */
|
||||
remove: (org: string, id: string): Promise<void> =>
|
||||
originDelete(`admin/spend-caps/${encodeURIComponent(id)}`, { org }),
|
||||
originDelete(`admin/caps/${encodeURIComponent(id)}`, { org }),
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export type AdminReferral = {
|
||||
|
||||
export type AdminReferralSummary = {
|
||||
total: number
|
||||
signedUp: number
|
||||
signup: number
|
||||
qualified: number
|
||||
credited: number
|
||||
grantedCents: number
|
||||
@@ -69,7 +69,7 @@ function normalizeAdminReferral(v: unknown): AdminReferral {
|
||||
referrerOrg: str(r.referrerOrg),
|
||||
refereeOrg: str(r.refereeOrg),
|
||||
code: str(r.code),
|
||||
status: str(r.status) || 'signed_up',
|
||||
status: str(r.status) || 'signup',
|
||||
referrerGrantCents: int(r.referrerGrantCents),
|
||||
refereeGrantCents: int(r.refereeGrantCents),
|
||||
referrerTxn: str(r.referrerTxn),
|
||||
@@ -84,7 +84,7 @@ function normalizeSummary(v: unknown): AdminReferralSummary {
|
||||
const r = asRecord(v)
|
||||
return {
|
||||
total: int(r.total),
|
||||
signedUp: int(r.signedUp),
|
||||
signup: int(r.signup),
|
||||
qualified: int(r.qualified),
|
||||
credited: int(r.credited),
|
||||
grantedCents: int(r.grantedCents),
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* `/admin/iam/*` or `/admin/kms/*`, sending only the first-party session cookie;
|
||||
* the server route (`app/admin/{iam,kms}/[...path]/route.ts`) enforces the GLOBAL
|
||||
* admin gate and forwards to IAM / KMS as the user. IAM speaks the
|
||||
* `{status,msg,data,data2}` envelope; KMS speaks plain JSON.
|
||||
* `{status,msg,data,total}` envelope (legacy `data2` count accepted); KMS speaks
|
||||
* plain JSON.
|
||||
*
|
||||
* Cross-tenant (any org) is global-admin only — a customer managing their OWN org
|
||||
* uses `TeamApi` (the `/org/iam` proxy) instead. Both share the ONE envelope
|
||||
@@ -20,7 +21,7 @@ import { makeIamClient, DEFAULT_PAGE_SIZE, qs, type Query, type Paged } from './
|
||||
export type { Paged }
|
||||
|
||||
/** An IAM organization's theme (`themeData`) — the accent + surface style the org
|
||||
* brands with. Mirrors the Casdoor/IAM `ThemeData` shape. */
|
||||
* brands with. Mirrors the IAM `ThemeData` shape. */
|
||||
export type ThemeData = {
|
||||
themeType?: string
|
||||
/** Primary accent color (hex), e.g. `#D4D4D4`. */
|
||||
|
||||
@@ -71,6 +71,13 @@ describe('AuditApi.list — org-scoped, filtered, paginated', () => {
|
||||
expect(page.total).toBe(7)
|
||||
})
|
||||
|
||||
it('reads the named total first; legacy data2 is only a fallback', async () => {
|
||||
stubJson({ status: 'ok', data: [{ seq: 1, action: 'a' }], total: 9, data2: 7 })
|
||||
expect((await AuditApi.list()).total).toBe(9)
|
||||
stubJson({ status: 'ok', data: [{ seq: 1, action: 'a' }], total: 9 })
|
||||
expect((await AuditApi.list()).total).toBe(9)
|
||||
})
|
||||
|
||||
it('page 1 and unfiltered omit the p/ filter params (clean URL)', async () => {
|
||||
const cap = stubJson({ status: 'ok', data: [], data2: 0 })
|
||||
await AuditApi.list({ page: 1 })
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
*
|
||||
* This is the customer-facing twin of the admin god-view (`/v1/admin/audit`): the
|
||||
* SAME tamper-evident, hash-chained store, scoped to the caller. The response is the
|
||||
* `{status,data,data2}` envelope (data = rows, data2 = total for pagination), so we
|
||||
* read it defensively via `restGet` and never throw on a shape drift.
|
||||
* `{status,data,total}` envelope (data = rows, total for pagination — legacy
|
||||
* emitters send it as `data2`), so we read it defensively via `restGet` and never
|
||||
* throw on a shape drift.
|
||||
*
|
||||
* Field names mirror the Go `audit.Wire` struct exactly.
|
||||
*/
|
||||
import { restGet, originV1Url } from './client'
|
||||
import { restGet, originV1Url, envelopeTotal } from './client'
|
||||
|
||||
// ── Defensive coercion ───────────────────────────────────────────────────────
|
||||
const num = (v: unknown): number => {
|
||||
@@ -111,15 +112,15 @@ const buildUrl = (query: AuditQuery): string => {
|
||||
|
||||
/**
|
||||
* AuditApi.list — the caller's OWN org audit trail, newest first. Reads the
|
||||
* `{status,data,data2}` envelope: data = the rows, data2 = the total matching the
|
||||
* filter (ignoring pagination) so the UI can page. Org is server-pinned.
|
||||
* `{status,data,total}` envelope (legacy `data2` accepted): data = the rows,
|
||||
* total = the count matching the filter (ignoring pagination) so the UI can
|
||||
* page. Org is server-pinned.
|
||||
*/
|
||||
export const AuditApi = {
|
||||
list: (query: AuditQuery = {}): Promise<AuditPage> =>
|
||||
restGet<unknown>(buildUrl(query)).then((raw) => {
|
||||
const env = asRecord(raw)
|
||||
const rows = arrayOf(env.data).map(normalizeEvent)
|
||||
const total = typeof env.data2 === 'number' ? env.data2 : rows.length
|
||||
return { rows, total }
|
||||
return { rows, total: envelopeTotal(env, rows) }
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -125,14 +125,14 @@ describe('BillingApi.usage — real ledger mapping (Cost page)', () => {
|
||||
|
||||
/**
|
||||
* Subscriptions — read-only over the same per-tenant `/billing/*` proxy. Commerce
|
||||
* is Stripe-shaped, so the normalizer must reach the plan name + price from a flat
|
||||
* is a flat nested vendor shape, so the normalizer must reach the plan name + price from a flat
|
||||
* `plan`, a nested `plan.nickname`, or the first `items.data[].price`, and must
|
||||
* treat `current_period_end` as either a Unix epoch (seconds) or an ISO string.
|
||||
*/
|
||||
describe('BillingApi.subscriptions — Stripe-shaped commerce mapping', () => {
|
||||
describe('BillingApi.subscriptions — nested commerce mapping', () => {
|
||||
afterEach(teardown)
|
||||
|
||||
it('reads plan/status/quantity/price from a nested Stripe subscription', async () => {
|
||||
it('reads plan/status/quantity/price from a nested subscription', async () => {
|
||||
stubJson({
|
||||
subscriptions: [
|
||||
{
|
||||
@@ -190,13 +190,13 @@ describe('BillingApi.subscriptions — Stripe-shaped commerce mapping', () => {
|
||||
/**
|
||||
* Payment methods — read-only + MASKED. The normalizer must surface ONLY the
|
||||
* non-sensitive descriptor (brand + last4 + expiry) commerce returns, from either
|
||||
* camelCase or Stripe snake_case, and must never produce a PAN/CVV/token (none is
|
||||
* camelCase or snake_case, and must never produce a PAN/CVV/token (none is
|
||||
* present in the payload).
|
||||
*/
|
||||
describe('BillingApi.paymentMethods — masked descriptor mapping', () => {
|
||||
afterEach(teardown)
|
||||
|
||||
it('reads brand/last4/expiry/default from a nested Stripe card (snake_case)', async () => {
|
||||
it('reads brand/last4/expiry/default from a nested card (snake_case)', async () => {
|
||||
stubJson({
|
||||
payment_methods: [
|
||||
{ id: 'pm_1', type: 'card', is_default: true, card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2030 } },
|
||||
@@ -381,7 +381,7 @@ describe('BillingApi.reactivateSubscription — clears the scheduled cancel', ()
|
||||
describe('normalizeSubscriptions — cancel state (cancelAtPeriodEnd / canceledAt)', () => {
|
||||
afterEach(teardown)
|
||||
|
||||
it('reads Stripe snake_case cancel_at_period_end + canceled_at', async () => {
|
||||
it('reads snake_case cancel_at_period_end + canceled_at', async () => {
|
||||
stubJson({
|
||||
subscriptions: [
|
||||
{ id: 's1', status: 'active', cancel_at_period_end: true, current_period_end: 1893456000 },
|
||||
|
||||
+16
-16
@@ -12,7 +12,7 @@
|
||||
* Read-only here: WRITES (paying, adding/removing a payment method, changing a
|
||||
* subscription) stay in the brand billing portal (`config.billingUrl`), which the
|
||||
* pages link to — the console reads and displays, it does not mutate money state.
|
||||
* Shapes are intentionally permissive: commerce's Stripe-shaped payloads are
|
||||
* Shapes are intentionally permissive: commerce's payloads are
|
||||
* normalized defensively so a field rename upstream degrades a cell to "—" rather
|
||||
* than throwing, and card data is masked to brand + last4 (never a PAN/CVV/token).
|
||||
* On a 404/501/401 the caller renders the shared `BackendStateCard` — never
|
||||
@@ -71,7 +71,7 @@ export type Subscription = {
|
||||
id: string
|
||||
/** Plan / product name (from the plan nickname or the first subscription item). */
|
||||
plan: string
|
||||
/** Commerce/Stripe status — active, trialing, past_due, canceled, etc. */
|
||||
/** Commerce status — active, trialing, past_due, canceled, etc. */
|
||||
status?: string
|
||||
/** Seats / units, if the subscription reports a quantity. */
|
||||
quantity?: number
|
||||
@@ -113,7 +113,7 @@ export type PaymentMethod = {
|
||||
/**
|
||||
* One spend alert / budget — a threshold (USD cents) that trips when the org's
|
||||
* spend crosses it (commerce `spendalert`). This is the ONE real budgets surface:
|
||||
* commerce serves `GET/POST /v1/billing/spend-alerts` under the user group (the
|
||||
* commerce serves `GET/POST /v1/billing/alerts` under the user group (the
|
||||
* same one billing.hanzo.ai calls), so the console reads + creates real budgets —
|
||||
* never a fake form. `triggeredAt` is set by commerce when the threshold trips.
|
||||
*/
|
||||
@@ -129,7 +129,7 @@ export type SpendAlert = {
|
||||
triggeredAt?: string
|
||||
/** ISO creation time. */
|
||||
createdAt?: string
|
||||
// ── Scope + enforcement (the spend-alerts extended fields) ──────────────────
|
||||
// ── Scope + enforcement (the alerts extended fields) ──────────────────
|
||||
// Forward-compatible: a legacy soft-alert row that predates these lights up with
|
||||
// sensible defaults (org-wide, alert-only, softPct 80, no rate limit, zero spent)
|
||||
// and the meter/enforce/rate-limit surface activates the moment the backend emits
|
||||
@@ -236,7 +236,7 @@ const objAt = (r: Record<string, unknown>, key: string): Record<string, unknown>
|
||||
|
||||
/**
|
||||
* An ISO date from a field that may be an ISO string OR a Unix epoch in SECONDS
|
||||
* (Stripe's `current_period_end`) or MILLISECONDS. Returns undefined when absent
|
||||
* (a `current_period_end`-style seconds stamp) or MILLISECONDS. Returns undefined when absent
|
||||
* or unparseable — the caller renders "—", never a fabricated date.
|
||||
*/
|
||||
const isoDate = (v: unknown): string | undefined => {
|
||||
@@ -244,14 +244,14 @@ const isoDate = (v: unknown): string | undefined => {
|
||||
if (s) return s
|
||||
const n = num(v)
|
||||
if (n === undefined || n <= 0) return undefined
|
||||
// < 1e12 ⇒ seconds (Stripe); otherwise already milliseconds.
|
||||
// < 1e12 ⇒ seconds; otherwise already milliseconds.
|
||||
const ms = n < 1e12 ? n * 1000 : n
|
||||
const d = new Date(ms)
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a commerce/Stripe subscription record into the display shape. The plan name
|
||||
* Roll a commerce subscription record into the display shape. The plan name
|
||||
* and price live in different places across shapes: a flat `plan`, a nested
|
||||
* `plan.nickname`/`plan.amount`, or the first `items.data[].price` — read every
|
||||
* known location, degrade to "—"/undefined, never invent.
|
||||
@@ -306,7 +306,7 @@ function oneRecord(payload: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a commerce/Stripe payment-method record into the masked display shape. Reads
|
||||
* Roll a commerce payment-method record into the masked display shape. Reads
|
||||
* ONLY the non-sensitive descriptor (brand + last4 + expiry) from `card`/`data` —
|
||||
* there is no PAN/CVV/token in this shape and none is ever produced here.
|
||||
*/
|
||||
@@ -473,15 +473,15 @@ export const BillingApi = {
|
||||
restDelete(billingProxyV1Url(`payment-methods/${encodeURIComponent(id)}`)),
|
||||
|
||||
/**
|
||||
* The org's spend alerts / budgets (`GET /v1/billing/spend-alerts`). The proxy
|
||||
* The org's spend alerts / budgets (`GET /v1/billing/alerts`). The proxy
|
||||
* scopes the read to the caller's OWN subject (pins `?user=`), so this returns
|
||||
* only the caller's budgets.
|
||||
*/
|
||||
spendAlerts: (): Promise<SpendAlert[]> =>
|
||||
restGet<unknown>(billingProxyV1Url('spend-alerts')).then(normalizeSpendAlerts),
|
||||
restGet<unknown>(billingProxyV1Url('alerts')).then(normalizeSpendAlerts),
|
||||
|
||||
/**
|
||||
* Create a spend alert / budget (`POST /v1/billing/spend-alerts`). The subject is
|
||||
* Create a spend alert / budget (`POST /v1/billing/alerts`). The subject is
|
||||
* pinned server-side by the proxy (`scopedBillingBody`) — the browser sends only
|
||||
* the title + threshold (USD cents) + currency, and cannot create a budget for
|
||||
* another tenant. Returns the created alert.
|
||||
@@ -496,7 +496,7 @@ export const BillingApi = {
|
||||
softPct?: number
|
||||
rateLimitRpm?: number
|
||||
}): Promise<SpendAlert> =>
|
||||
restPost<unknown>(billingProxyV1Url('spend-alerts'), {
|
||||
restPost<unknown>(billingProxyV1Url('alerts'), {
|
||||
title: input.title,
|
||||
threshold: Math.round(input.thresholdCents),
|
||||
currency: (input.currency ?? 'usd').toLowerCase(),
|
||||
@@ -508,7 +508,7 @@ export const BillingApi = {
|
||||
}).then((r) => normalizeSpendAlerts([r])[0]),
|
||||
|
||||
/**
|
||||
* Update a budget's cap / scope / enforcement (`PATCH /v1/billing/spend-alerts/:id`).
|
||||
* Update a budget's cap / scope / enforcement (`PATCH /v1/billing/alerts/:id`).
|
||||
* Only the provided fields are sent; the subject is pinned server-side by the proxy
|
||||
* (a caller can only edit their OWN budgets). Returns the updated alert.
|
||||
*/
|
||||
@@ -532,14 +532,14 @@ export const BillingApi = {
|
||||
if (patch.enforce !== undefined) body.enforce = patch.enforce
|
||||
if (patch.softPct !== undefined) body.softPct = patch.softPct
|
||||
if (patch.rateLimitRpm !== undefined) body.rateLimitRpm = patch.rateLimitRpm
|
||||
return restPatch<unknown>(billingProxyV1Url(`spend-alerts/${encodeURIComponent(id)}`), body).then(
|
||||
return restPatch<unknown>(billingProxyV1Url(`alerts/${encodeURIComponent(id)}`), body).then(
|
||||
(r) => normalizeSpendAlerts([r])[0],
|
||||
)
|
||||
},
|
||||
|
||||
/** Remove a budget (`DELETE /v1/billing/spend-alerts/:id`); subject pinned server-side. */
|
||||
/** Remove a budget (`DELETE /v1/billing/alerts/:id`); subject pinned server-side. */
|
||||
deleteSpendAlert: (id: string): Promise<void> =>
|
||||
restDelete(billingProxyV1Url(`spend-alerts/${encodeURIComponent(id)}`)),
|
||||
restDelete(billingProxyV1Url(`alerts/${encodeURIComponent(id)}`)),
|
||||
|
||||
/**
|
||||
* PUBLIC Square Web Payments config for THIS org (`GET /v1/billing/payment-config`):
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './captable'
|
||||
|
||||
describe('rows — tolerates the inconsistent list shapes', () => {
|
||||
it('reads a BARE array (stakeholders, share-classes)', () => {
|
||||
it('reads a BARE array (stakeholders, classes)', () => {
|
||||
expect(rows([{ id: '1' }, { id: '2' }])).toHaveLength(2)
|
||||
})
|
||||
it('reads a {data} envelope (shares, safes, rounds, …)', () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* in `proxy-allow.ts` CLOUD_HEADS.
|
||||
*
|
||||
* Transport is PLAIN REST (raw JSON, real HTTP status) — the goja bundle's contract.
|
||||
* The list shapes are INCONSISTENT by design (stakeholders + share-classes return a
|
||||
* The list shapes are INCONSISTENT by design (stakeholders + classes return a
|
||||
* BARE array; shares/options/safes/convertibles/rounds/investments wrap in `{ data }`),
|
||||
* so the ONE `rows()` unwrapper tolerates both. The cap-table MATH lives in the backend
|
||||
* bundle (`captable.ts`); this client mirrors the wire shapes + derives ONLY the
|
||||
@@ -520,12 +520,12 @@ export const CapTableApi = {
|
||||
remove: (id: string): Promise<void> => restDelete(url(`stakeholders/${enc(id)}`)),
|
||||
},
|
||||
shareClasses: {
|
||||
list: (): Promise<ShareClass[]> => restGet<unknown>(url('share-classes')).then((p) => rows(p).map(normShareClass)),
|
||||
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('share-classes'), body).then(() => undefined),
|
||||
list: (): Promise<ShareClass[]> => restGet<unknown>(url('classes')).then((p) => rows(p).map(normShareClass)),
|
||||
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('classes'), body).then(() => undefined),
|
||||
},
|
||||
equityPlans: {
|
||||
list: (): Promise<EquityPlan[]> => restGet<unknown>(url('equity-plans')).then((p) => rows(p).map(normEquityPlan)),
|
||||
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('equity-plans'), body).then(() => undefined),
|
||||
list: (): Promise<EquityPlan[]> => restGet<unknown>(url('plans')).then((p) => rows(p).map(normEquityPlan)),
|
||||
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('plans'), body).then(() => undefined),
|
||||
},
|
||||
shares: {
|
||||
list: (): Promise<Share[]> => restGet<unknown>(url('shares')).then((p) => rows(p).map(normShare)),
|
||||
|
||||
+25
-12
@@ -5,9 +5,10 @@
|
||||
* always included (the backend sets a session cookie at `/v1/iam/signin`), and the
|
||||
* Accept-Language header is forwarded so server-side messages localize.
|
||||
*
|
||||
* The backend wraps every response as `{ status, msg, data, data2 }`. We unwrap
|
||||
* it here and throw `ApiError` on `status !== "ok"`, so callers get the payload
|
||||
* directly or a typed failure — never a half-checked envelope.
|
||||
* The backend wraps every response as `{ status, msg, data, total }` (legacy
|
||||
* emitters still send the count as `data2`; we read the named field first). We
|
||||
* unwrap it here and throw `ApiError` on `status !== "ok"`, so callers get the
|
||||
* payload directly or a typed failure — never a half-checked envelope.
|
||||
*/
|
||||
import { activeApiBase } from '~/lib/network'
|
||||
import { IS_EMBED } from '~/lib/embed'
|
||||
@@ -22,10 +23,23 @@ export type ApiResponse<T> = {
|
||||
status: 'ok' | 'error'
|
||||
msg: string
|
||||
data: T
|
||||
/** Secondary payload — total row count for list endpoints. */
|
||||
/** Total row count for list endpoints (the named field; absent means absent). */
|
||||
total?: number
|
||||
/** Legacy Casdoor second slot — superseded by `total`; read only as fallback. */
|
||||
data2?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Count from an envelope: the named `total` first, legacy `data2` second, else
|
||||
* the rows themselves. The data2 fallback lives ONLY here and dies with the
|
||||
* legacy emitters.
|
||||
*/
|
||||
export const envelopeTotal = (env: { total?: unknown; data2?: unknown }, rows: unknown): number => {
|
||||
if (typeof env.total === 'number') return env.total
|
||||
if (typeof env.data2 === 'number') return env.data2
|
||||
return Array.isArray(rows) ? rows.length : 0
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
constructor(message: string, status = 0) {
|
||||
@@ -326,7 +340,7 @@ export async function originPost<T>(path: string, body?: unknown, query?: Query,
|
||||
* Envelope PUT / PATCH / DELETE pinned to the console's OWN origin (`<origin>/v1/<path>`)
|
||||
* — the idempotent-upsert / partial-edit / remove twins of `originPost`. Used by the
|
||||
* admin AGGREGATE mutations that need a verb beyond POST (`PUT /v1/admin/promos` upserts
|
||||
* the single platform promo; `PATCH`/`DELETE /v1/admin/spend-caps/:id` edit/remove a cap).
|
||||
* the single platform promo; `PATCH`/`DELETE /v1/admin/caps/:id` edit/remove a cap).
|
||||
* They terminate at the global-admin-gated `app/admin/aggregate` proxy — which applies the
|
||||
* same-origin CSRF check on every mutating method (PUT/PATCH/DELETE included) — so pinning
|
||||
* the ORIGIN (not `config.cloudUrl`) keeps a split-origin `NEXT_PUBLIC_CLOUD_URL` from
|
||||
@@ -376,12 +390,11 @@ export async function cloudPost<T = string>(path: string, body?: unknown, query?
|
||||
return r
|
||||
}
|
||||
|
||||
/** GET that returns the full envelope (for list endpoints needing `data2` total). */
|
||||
/** GET that returns the full envelope (for list endpoints needing the total). */
|
||||
export async function getList<T>(path: string, query?: Query): Promise<{ rows: T; total: number }> {
|
||||
const r = await request<T>('GET', path, { query })
|
||||
if (r.status !== 'ok') throw new ApiError(r.msg || 'Request failed')
|
||||
const total = typeof r.data2 === 'number' ? r.data2 : Array.isArray(r.data) ? r.data.length : 0
|
||||
return { rows: r.data, total }
|
||||
return { rows: r.data, total: envelopeTotal(r, r.data) }
|
||||
}
|
||||
|
||||
/** POST a JSON body; returns the `msg` (most mutations return ok/affected). */
|
||||
@@ -393,17 +406,17 @@ export async function post<T = string>(path: string, body?: unknown, query?: Que
|
||||
|
||||
/**
|
||||
* IAM over the SAME `/v1` path + resilient fetch as every cloud call — IAM's
|
||||
* `{status,msg,data,data2}` envelope IS our `ApiResponse`, so there is no second
|
||||
* `{status,msg,data,total}` envelope IS our `ApiResponse`, so there is no second
|
||||
* client. These target `/v1/iam/<segment>`, which the cloud IAM edge (org-scoped)
|
||||
* serves in the one-binary console and the `/v1` bearer proxy forwards in the split
|
||||
* one — ONE path, ONE gate, in both topologies. `iamList` surfaces `data2` (the
|
||||
* total) that plain `get` drops.
|
||||
* one — ONE path, ONE gate, in both topologies. `iamList` surfaces the total
|
||||
* (`total`, legacy `data2`) that plain `get` drops.
|
||||
*/
|
||||
export async function iamList<T>(segment: string, query?: Query): Promise<{ rows: T[]; total: number }> {
|
||||
const r = await request<T[]>('GET', `iam/${segment}`, { query })
|
||||
if (r.status !== 'ok') throw new ApiError(r.msg || 'Request failed')
|
||||
const rows = Array.isArray(r.data) ? r.data : []
|
||||
return { rows, total: typeof r.data2 === 'number' ? r.data2 : rows.length }
|
||||
return { rows, total: envelopeTotal(r, rows) }
|
||||
}
|
||||
|
||||
export async function iamOne<T>(segment: string, query?: Query): Promise<T> {
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('csrf (embed ambient-cookie money path)', () => {
|
||||
it('csrfRequired is SCOPED to the money-write surfaces cloud gates (no spurious pre-auth mint)', async () => {
|
||||
const { csrfRequired } = await import('./csrf')
|
||||
// The cloud requireCSRF surfaces (clients/account/account.go) — must require a token.
|
||||
for (const u of ['/v1/iam/keys', '/v1/iam/onboard', '/v1/commerce/topup/wallet', '/v1/billing/spend-alerts', '/v1/commerce/product'])
|
||||
for (const u of ['/v1/iam/keys', '/v1/iam/onboard', '/v1/commerce/topup/wallet', '/v1/billing/alerts', '/v1/commerce/product'])
|
||||
expect(csrfRequired('POST', u)).toBe(true)
|
||||
// NON-money mutating writes (login/session/control-plane) must NOT trigger the
|
||||
// /v1/csrf mint — that pre-auth fetch was the SPA's only console error.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { envelopeTotal } from './client'
|
||||
|
||||
/**
|
||||
* The ONE count read for the `{status,msg,data,total}` envelope: named `total`
|
||||
* first, legacy Casdoor `data2` second, the rows themselves last. Every list
|
||||
* reader (getList, iamList, makeIamClient, AuditApi) goes through this.
|
||||
*/
|
||||
describe('envelopeTotal — total, then data2, then rows', () => {
|
||||
it('prefers the named total over data2 and the rows', () => {
|
||||
expect(envelopeTotal({ total: 9, data2: 7 }, [1])).toBe(9)
|
||||
expect(envelopeTotal({ total: 0, data2: 7 }, [1])).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to a numeric data2 while legacy emitters remain', () => {
|
||||
expect(envelopeTotal({ data2: 7 }, [1])).toBe(7)
|
||||
expect(envelopeTotal({ data2: 0 }, [1, 2])).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to the rows when neither field is a number', () => {
|
||||
expect(envelopeTotal({}, [1, 2])).toBe(2)
|
||||
expect(envelopeTotal({ total: '9', data2: null }, [1, 2])).toBe(2)
|
||||
expect(envelopeTotal({}, undefined)).toBe(0)
|
||||
})
|
||||
})
|
||||
+10
-9
@@ -4,7 +4,7 @@
|
||||
* warehouse owned by hanzoai/ai — `hanzo.observations` / `hanzo.cloud_usage`).
|
||||
*
|
||||
* This is the ONE client for the console's Observe surface: datasets, dataset
|
||||
* items, evaluators, score-configs, scores, traces (+ the trace-detail
|
||||
* items, evaluators, rubrics, scores, traces (+ the trace-detail
|
||||
* span/observation tree), observations, sessions, dataset runs / experiments,
|
||||
* and the dashboard metrics. It is the faithful port target of the upstream
|
||||
* observability screens, reskinned to @hanzo/gui.
|
||||
@@ -115,7 +115,6 @@ export type EvalRunRequest = {
|
||||
|
||||
export type CreateDatasetBody = { name: string; description?: string; metadata?: Record<string, unknown> }
|
||||
export type CreateDatasetItemBody = {
|
||||
datasetName: string
|
||||
input?: unknown
|
||||
expectedOutput?: unknown
|
||||
metadata?: Record<string, unknown>
|
||||
@@ -348,7 +347,7 @@ export type EvalSessionDetail = { session: EvalSession; traces: Trace[] }
|
||||
|
||||
// ── the eval-orchestration client ─────────────────────────────────────────────
|
||||
// SCOPE: this is the /v1/evals eval-ARTIFACT SDK — datasets, evaluators, runs,
|
||||
// score-configs, scores, AND the eval-domain trace/observation/session reads (which
|
||||
// rubrics, scores, AND the eval-domain trace/observation/session reads (which
|
||||
// carry the eval cost/token/score joins raw OTel spans lack). It is NOT the Observe
|
||||
// console read plane: as of the gen_ai observation-of-record flip,
|
||||
// O11yApi.traces/observations/sessions read the o11y SPAN plane (/v1/o11y), NOT these.
|
||||
@@ -407,7 +406,7 @@ export const EvalsApi = {
|
||||
return { session: base, traces: (res.traces ?? []).map(toTrace) }
|
||||
},
|
||||
|
||||
// ---- scores + score-configs (Observe · Scores) ----------------------------
|
||||
// ---- scores + rubrics (Observe · Scores) ----------------------------------
|
||||
/** List scores as canonical Score[] (for the scores table + metrics folds). */
|
||||
listScoresTyped: async (
|
||||
q: { name?: string; runName?: string; traceId?: string; limit?: number } = {},
|
||||
@@ -421,11 +420,11 @@ export const EvalsApi = {
|
||||
return { data: rows<EvalScore>(res) }
|
||||
},
|
||||
listScoreConfigs: async (): Promise<EvalScoreConfig[]> => {
|
||||
const res = await restGet<{ data?: EvalScoreConfig[] }>(url('score-configs'))
|
||||
const res = await restGet<{ data?: EvalScoreConfig[] }>(url('rubrics'))
|
||||
return rows<EvalScoreConfig>(res)
|
||||
},
|
||||
createScoreConfig: (body: CreateScoreConfigBody): Promise<EvalScoreConfig> =>
|
||||
restPost<EvalScoreConfig>(url('score-configs'), body),
|
||||
restPost<EvalScoreConfig>(url('rubrics'), body),
|
||||
|
||||
// ---- datasets + items (Observe · Datasets) --------------------------------
|
||||
listDatasets: async (): Promise<EvalDataset[]> => {
|
||||
@@ -436,11 +435,13 @@ export const EvalsApi = {
|
||||
createDataset: (body: CreateDatasetBody): Promise<EvalDataset> => restPost<EvalDataset>(url('datasets'), body),
|
||||
deleteDataset: (name: string): Promise<void> => restDelete(url(`datasets/${encodeURIComponent(name)}`)),
|
||||
listDatasetItems: async (datasetName: string, limit?: number): Promise<EvalDatasetItem[]> => {
|
||||
const res = await restGet<{ data?: EvalDatasetItem[] }>(url('dataset-items', { datasetName, limit }))
|
||||
const res = await restGet<{ data?: EvalDatasetItem[] }>(
|
||||
url(`datasets/${encodeURIComponent(datasetName)}/items`, { limit }),
|
||||
)
|
||||
return rows<EvalDatasetItem>(res)
|
||||
},
|
||||
createDatasetItem: (body: CreateDatasetItemBody): Promise<EvalDatasetItem> =>
|
||||
restPost<EvalDatasetItem>(url('dataset-items'), body),
|
||||
createDatasetItem: (datasetName: string, body: CreateDatasetItemBody): Promise<EvalDatasetItem> =>
|
||||
restPost<EvalDatasetItem>(url(`datasets/${encodeURIComponent(datasetName)}/items`), body),
|
||||
|
||||
// ---- evaluators -----------------------------------------------------------
|
||||
listEvaluators: async (): Promise<EvalEvaluator[]> => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* IAM envelope client factory — the ONE typed client for the IAM
|
||||
* `{status,msg,data,data2}` envelope, bound to a same-origin gated proxy base.
|
||||
* `{status,msg,data,total}` envelope (legacy `data2` count accepted), bound to a
|
||||
* same-origin gated proxy base.
|
||||
*
|
||||
* The browser never holds an IAM credential: it calls a SAME-ORIGIN server route
|
||||
* (`/admin/iam/*` for cross-tenant global-admin ops, `/org/iam/*` for a customer
|
||||
@@ -8,11 +9,11 @@
|
||||
* the gate + tenant scoping and forwards to IAM as the user. Both speak the same
|
||||
* envelope, so this ONE factory serves both (DRY) — only the base path differs.
|
||||
*/
|
||||
import { ApiError } from './client'
|
||||
import { ApiError, envelopeTotal } from './client'
|
||||
|
||||
type Envelope<T> = { status?: string; msg?: string; data?: T; data2?: unknown }
|
||||
type Envelope<T> = { status?: string; msg?: string; data?: T; total?: number; data2?: unknown }
|
||||
|
||||
/** Paged result — rows plus the backend's total (data2). */
|
||||
/** Paged result — rows plus the backend's total (`total`, legacy `data2`). */
|
||||
export type Paged<T> = { rows: T[]; total: number }
|
||||
|
||||
export type Query = Record<string, string | number | boolean | undefined | null>
|
||||
@@ -62,8 +63,7 @@ export function makeIamClient(base: string) {
|
||||
async function iamList<T>(segment: string, query: Query): Promise<Paged<T>> {
|
||||
const r = await iamReq<T[]>('GET', segment, { query })
|
||||
const rows = Array.isArray(r.data) ? r.data : []
|
||||
const total = typeof r.data2 === 'number' ? r.data2 : rows.length
|
||||
return { rows, total }
|
||||
return { rows, total: envelopeTotal(r, rows) }
|
||||
}
|
||||
|
||||
async function iamOne<T>(segment: string, query: Query): Promise<T> {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* dash), never a fabricated value, and the `{ status, data }` envelope unwraps.
|
||||
* (2) the REAL O11yApi methods, with the transport + evals mocked, asserting the
|
||||
* FLIP: traces / observations / sessions read the o11y SPAN plane (`/v1/o11y`),
|
||||
* while SCORES + score-configs STAY on `/v1/evals` (the eval artifacts) — the
|
||||
* while SCORES + rubrics STAY on `/v1/evals` (the eval artifacts) — the
|
||||
* mission's one-way contract.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
@@ -172,7 +172,7 @@ describe('O11yApi reads the span plane natively from /v1/o11y', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCORES + score-configs STAY on /v1/evals (never the o11y span plane)', () => {
|
||||
describe('SCORES + rubrics STAY on /v1/evals (never the o11y span plane)', () => {
|
||||
beforeEach(() => {
|
||||
restGet.mockReset()
|
||||
listScoresTyped.mockReset()
|
||||
|
||||
+8
-8
@@ -120,7 +120,7 @@ export type ScoreConfigCategory = { label: string; value: number }
|
||||
/**
|
||||
* A score config — the definition of a score: its data type and valid range or
|
||||
* categories. Numeric configs carry min/max; categorical/boolean carry the
|
||||
* allowed categories. Mirrors `/v1/o11y/score-configs`.
|
||||
* allowed categories. Mirrors `/v1/evals/rubrics`.
|
||||
*/
|
||||
export type ScoreConfig = {
|
||||
id: string
|
||||
@@ -141,7 +141,7 @@ export type ScoreConfig = {
|
||||
|
||||
/**
|
||||
* An annotation queue — a named work queue of traces/observations to review and
|
||||
* score against a set of score configs. Mirrors `/v1/o11y/annotation-queues`.
|
||||
* score against a set of rubrics. Mirrors `/v1/o11y/reviews`.
|
||||
*/
|
||||
export type AnnotationQueue = {
|
||||
id: string
|
||||
@@ -408,10 +408,10 @@ const scoreConfigOf = (c: EvalScoreConfig): ScoreConfig => ({
|
||||
* DETAIL) read NATIVELY from the o11y gen_ai span plane (`/v1/o11y`, the declared
|
||||
* observation-of-record). trace + session DETAIL are composed from the list views
|
||||
* filtered by traceId/sessionId (o11y exposes no detail endpoint — the real
|
||||
* waterfall without a backend change). SCORES + score-configs + every eval artifact
|
||||
* waterfall without a backend change). SCORES + rubrics + every eval artifact
|
||||
* (datasets/evaluators/runs) STAY the eval-orchestration read on `/v1/evals`; the
|
||||
* trace detail's inline scores are the eval scores FOR that trace (listScoresTyped by
|
||||
* traceId), so nothing is lost. users / annotation-queues / health read the same
|
||||
* traceId), so nothing is lost. users / reviews / health read the same
|
||||
* `/v1/o11y` surface. Every read surfaces honest states (ApiError → RuntimeNotice /
|
||||
* empty) until spans flow and the reverse-proxy resolves — never fabricated rows.
|
||||
*/
|
||||
@@ -461,7 +461,7 @@ export const O11yApi = {
|
||||
observations: async (q: O11yListQuery = {}): Promise<O11yList<Observation>> =>
|
||||
asList((await o11yList<O11yObservationWire>('observations', { limit: q.limit, offset: offsetOf(q) })).map(observationOf), q),
|
||||
|
||||
// SCORES + score-configs STAY on /v1/evals (the eval artifacts) — only the span
|
||||
// SCORES + rubrics STAY on /v1/evals (the eval artifacts) — only the span
|
||||
// plane (traces/observations/sessions) moved to o11y.
|
||||
scores: async (q: O11yListQuery = {}): Promise<O11yList<Score>> =>
|
||||
asList(await EvalsApi.listScoresTyped({ limit: q.limit }), q),
|
||||
@@ -475,13 +475,13 @@ export const O11yApi = {
|
||||
// ── annotation QUEUES have no o11y llmobs equivalent (flat annotations only) —
|
||||
// honest states over the same /v1/o11y surface until a queue runtime exists ──
|
||||
annotationQueues: (q: O11yListQuery = {}) =>
|
||||
restGet<O11yList<AnnotationQueue>>(o11yUrl('annotation-queues', { limit: q.limit, page: q.page })),
|
||||
restGet<O11yList<AnnotationQueue>>(o11yUrl('reviews', { limit: q.limit, page: q.page })),
|
||||
|
||||
annotationQueue: (id: string) =>
|
||||
restGet<AnnotationQueueDetail>(o11yUrl(`annotation-queues/${encodeURIComponent(id)}`)),
|
||||
restGet<AnnotationQueueDetail>(o11yUrl(`reviews/${encodeURIComponent(id)}`)),
|
||||
|
||||
annotationQueueItems: (id: string, q: O11yListQuery = {}) =>
|
||||
restGet<O11yList<AnnotationQueueItem>>(o11yUrl(`annotation-queues/${encodeURIComponent(id)}/items`, { limit: q.limit, page: q.page })),
|
||||
restGet<O11yList<AnnotationQueueItem>>(o11yUrl(`reviews/${encodeURIComponent(id)}/items`, { limit: q.limit, page: q.page })),
|
||||
|
||||
/**
|
||||
* Liveness of the embedded o11y runtime — `GET /v1/o11y/health` via the `/v1`
|
||||
|
||||
@@ -20,10 +20,10 @@ describe('Referrals normalizers — real cloud JSON shape, defensive', () => {
|
||||
referrerBonusCents: 1000,
|
||||
refereeBonusCents: 500,
|
||||
creditsEarnedCents: 2000,
|
||||
counts: { total: 3, signedUp: 1, qualified: 0, credited: 2 },
|
||||
counts: { total: 3, signup: 1, qualified: 0, credited: 2 },
|
||||
referrals: [
|
||||
{ id: 'ref_1', referee: 'orgB', status: 'credited', creditsCents: 1000, createdAt: 5, qualifiedAt: 6, creditedAt: 6 },
|
||||
{ id: 'ref_2', referee: 'orgC', status: 'signed_up', creditsCents: 0, createdAt: 7, qualifiedAt: 0, creditedAt: 0 },
|
||||
{ id: 'ref_2', referee: 'orgC', status: 'signup', creditsCents: 0, createdAt: 7, qualifiedAt: 0, creditedAt: 0 },
|
||||
],
|
||||
})
|
||||
expect(o).toMatchObject({
|
||||
@@ -32,7 +32,7 @@ describe('Referrals normalizers — real cloud JSON shape, defensive', () => {
|
||||
referrerBonusCents: 1000,
|
||||
refereeBonusCents: 500,
|
||||
creditsEarnedCents: 2000,
|
||||
counts: { total: 3, signedUp: 1, qualified: 0, credited: 2 },
|
||||
counts: { total: 3, signup: 1, qualified: 0, credited: 2 },
|
||||
})
|
||||
expect(o.referrals.map((r) => r.id)).toEqual(['ref_1', 'ref_2'])
|
||||
expect(o.referrals[0].status).toBe('credited')
|
||||
@@ -41,23 +41,23 @@ describe('Referrals normalizers — real cloud JSON shape, defensive', () => {
|
||||
it('coerces missing/garbage fields to safe defaults (never throws)', () => {
|
||||
const o = normalizeOverview(null)
|
||||
expect(o).toMatchObject({ code: '', link: '', creditsEarnedCents: 0 })
|
||||
expect(o.counts).toEqual({ total: 0, signedUp: 0, qualified: 0, credited: 0 })
|
||||
expect(o.counts).toEqual({ total: 0, signup: 0, qualified: 0, credited: 0 })
|
||||
expect(o.referrals).toEqual([])
|
||||
// A row with no id is filtered out of the list.
|
||||
expect(normalizeOverview({ referrals: [null, 'x', { id: 'ref_9', referee: 'z' }] }).referrals.map((r) => r.id)).toEqual([
|
||||
'ref_9',
|
||||
])
|
||||
// A row defaults status to signed_up.
|
||||
expect(normalizeMyReferral({ id: 'ref_x' }).status).toBe('signed_up')
|
||||
// A row defaults status to signup.
|
||||
expect(normalizeMyReferral({ id: 'ref_x' }).status).toBe('signup')
|
||||
// Numeric coercion from strings.
|
||||
expect(normalizeMyReferral({ id: 'r', creditsCents: '1500' }).creditsCents).toBe(1500)
|
||||
})
|
||||
|
||||
it('normalizes a claim result', () => {
|
||||
expect(normalizeClaim({ id: 'ref_1', code: 'ABC', status: 'signed_up', created: true, createdAt: 9 })).toEqual({
|
||||
expect(normalizeClaim({ id: 'ref_1', code: 'ABC', status: 'signup', created: true, createdAt: 9 })).toEqual({
|
||||
id: 'ref_1',
|
||||
code: 'ABC',
|
||||
status: 'signed_up',
|
||||
status: 'signup',
|
||||
created: true,
|
||||
createdAt: 9,
|
||||
})
|
||||
@@ -77,7 +77,7 @@ describe('ReferralsApi — hits the /v1/referrals bearer-proxy path', () => {
|
||||
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => {
|
||||
fetched.push({ url, method: init?.method ?? 'GET' })
|
||||
const body = url.endsWith('/claim')
|
||||
? { id: 'ref_1', code: 'ABC', status: 'signed_up', created: true, createdAt: 1 }
|
||||
? { id: 'ref_1', code: 'ABC', status: 'signup', created: true, createdAt: 1 }
|
||||
: { code: 'ABC', link: 'https://hanzo.ai/?ref=ABC', counts: {}, referrals: [] }
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
|
||||
@@ -37,8 +37,8 @@ const arrayUnder = (payload: unknown, keys: string[]): Record<string, unknown>[]
|
||||
|
||||
// ── Domain types (mirror cloud clients/referrals JSON tags) ─────────────────
|
||||
|
||||
/** A referral advances signed_up → qualified → credited (credited is terminal). */
|
||||
export type ReferralStatus = 'signed_up' | 'qualified' | 'credited' | (string & {})
|
||||
/** A referral advances signup → qualified → credited (credited is terminal). */
|
||||
export type ReferralStatus = 'signup' | 'qualified' | 'credited' | (string & {})
|
||||
|
||||
/** One row in the referrer's own list (the people THEY referred). */
|
||||
export type MyReferral = {
|
||||
@@ -53,7 +53,7 @@ export type MyReferral = {
|
||||
|
||||
export type ReferralCounts = {
|
||||
total: number
|
||||
signedUp: number
|
||||
signup: number
|
||||
qualified: number
|
||||
credited: number
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export function normalizeMyReferral(v: unknown): MyReferral {
|
||||
return {
|
||||
id: str(r.id),
|
||||
referee: str(r.referee),
|
||||
status: (str(r.status) || 'signed_up') as ReferralStatus,
|
||||
status: (str(r.status) || 'signup') as ReferralStatus,
|
||||
creditsCents: int(r.creditsCents),
|
||||
createdAt: int(r.createdAt),
|
||||
qualifiedAt: int(r.qualifiedAt),
|
||||
@@ -97,7 +97,7 @@ function normalizeCounts(v: unknown): ReferralCounts {
|
||||
const r = asRecord(v)
|
||||
return {
|
||||
total: int(r.total),
|
||||
signedUp: int(r.signedUp),
|
||||
signup: int(r.signup),
|
||||
qualified: int(r.qualified),
|
||||
credited: int(r.credited),
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export function normalizeClaim(v: unknown): ClaimResult {
|
||||
return {
|
||||
id: str(r.id),
|
||||
code: str(r.code),
|
||||
status: (str(r.status) || 'signed_up') as ReferralStatus,
|
||||
status: (str(r.status) || 'signup') as ReferralStatus,
|
||||
created: r.created === true,
|
||||
createdAt: int(r.createdAt),
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* customer snapshot behind the admin.hanzo.ai "SaaS Metrics" board. It is computed
|
||||
* IN commerce (the system of record for subscriptions + the usage ledger) from ONE
|
||||
* cross-org walk and served at `GET /v1/commerce/metrics/saas`; the console only
|
||||
* renders it — no client-side Stripe, no re-aggregation.
|
||||
* renders it — no client-side billing SDK, no re-aggregation.
|
||||
*
|
||||
* Transport: `originGet('admin/saas', …)` pins the request to the console's OWN
|
||||
* origin, terminating at the global-admin-gated `app/admin/saas` proxy, which runs
|
||||
|
||||
@@ -1,85 +1,17 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
SocialApi,
|
||||
PROVIDERS,
|
||||
normalizePost,
|
||||
normalizeSummary,
|
||||
normalizeProviderCapability,
|
||||
normalizeProviders,
|
||||
normalizeAccounts,
|
||||
} from './social'
|
||||
import { SocialApi } from './social'
|
||||
|
||||
/**
|
||||
* Social API + pure normalizers. The module calls the DOCUMENTED cloud `/v1/social`
|
||||
* contract same-origin, keyless and prefix-free (`originV1Url` → `<origin>/v1/social`),
|
||||
* the canonical CRM/Agents form. These tests pin (1) the EXACT same-origin paths for the
|
||||
* new publish surface (providers + posts/:id/publish), (2) that the real store.go JSON
|
||||
* shape — including the server-managed publish results — normalizes, and (3) that a
|
||||
* garbage/absent field degrades to a safe default, never throws.
|
||||
* The console's `/v1/social` BINDING. The contract (types, normalizers, paths) is
|
||||
* tested in `@hanzo/ui/product/social` — the ONE place it lives. What is console-only,
|
||||
* and therefore tested here, is the TRANSPORT: that a contract path resolves to this
|
||||
* origin's `/v1/social/...` (keyless, prefix-free, through the user-bearer BFF), which
|
||||
* is the canonical CRM/Agents form.
|
||||
*/
|
||||
const ORIGIN = 'https://social.hanzo.ai'
|
||||
|
||||
describe('Social normalizers — real store.go JSON shape, defensive', () => {
|
||||
it('normalizes a post including the server-managed publish results', () => {
|
||||
const p = normalizePost({
|
||||
id: 'post_1', content: 'hi', channel: 'linkedin', status: 'published',
|
||||
scheduleAt: 1000, accountId: 'acct_1', externalId: 'ext_9', error: '',
|
||||
createdAt: 1, updatedAt: 2,
|
||||
})
|
||||
expect(p).toMatchObject({
|
||||
id: 'post_1', content: 'hi', channel: 'linkedin', status: 'published',
|
||||
scheduleAt: 1000, accountId: 'acct_1', externalId: 'ext_9',
|
||||
})
|
||||
// Empty error normalizes to undefined (omitted), never the string "".
|
||||
expect(p.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('coerces missing/garbage post fields to safe defaults (never throws)', () => {
|
||||
const p = normalizePost({ id: 'post_2' })
|
||||
expect(p).toMatchObject({ id: 'post_2', content: '', channel: 'x', status: 'draft', scheduleAt: 0 })
|
||||
expect(p.externalId).toBeUndefined()
|
||||
expect(normalizePost(null).id).toBe('')
|
||||
})
|
||||
|
||||
it('carries a post’s media through — cloud’s PUT rebuilds the row, so dropping it would wipe it', () => {
|
||||
expect(normalizePost({ id: 'post_3', media: ['https://s3/a.png', 'https://s3/b.png'] }).media).toEqual([
|
||||
'https://s3/a.png',
|
||||
'https://s3/b.png',
|
||||
])
|
||||
// Always an array, and non-string entries are dropped rather than rendered.
|
||||
expect(normalizePost({ id: 'post_4' }).media).toEqual([])
|
||||
expect(normalizePost({ id: 'post_5', media: 'nope' }).media).toEqual([])
|
||||
expect(normalizePost({ id: 'post_6', media: ['ok', 7, null] }).media).toEqual(['ok'])
|
||||
})
|
||||
|
||||
it('normalizes a provider capability with the missing-credentials list', () => {
|
||||
const c = normalizeProviderCapability({
|
||||
provider: 'x', credentialsConfigured: false, missingCredentials: ['X_API_KEY', 'X_API_SECRET'],
|
||||
})
|
||||
expect(c).toEqual({ provider: 'x', credentialsConfigured: false, missingCredentials: ['X_API_KEY', 'X_API_SECRET'] })
|
||||
// A configured provider with a non-array field degrades to an empty list.
|
||||
expect(normalizeProviderCapability({ provider: 'linkedin', credentialsConfigured: true })).toEqual({
|
||||
provider: 'linkedin', credentialsConfigured: true, missingCredentials: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('reads lists from any envelope key or a bare array', () => {
|
||||
expect(normalizeProviders({ data: [{ provider: 'x' }, { provider: 'threads' }] }).map((c) => c.provider)).toEqual([
|
||||
'x', 'threads',
|
||||
])
|
||||
expect(normalizeAccounts([{ id: 'a' }, { id: 'b' }]).length).toBe(2)
|
||||
expect(normalizeSummary({ posts: 3, scheduled: 1, published: 2, accounts: 4 })).toEqual({
|
||||
posts: 3, scheduled: 1, published: 2, accounts: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes the network vocabulary', () => {
|
||||
expect(PROVIDERS).toEqual(['x', 'facebook', 'instagram', 'linkedin', 'tiktok', 'youtube', 'threads'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SocialApi — hits the same-origin /v1/social contract', () => {
|
||||
describe('SocialApi — binds the contract to the console origin', () => {
|
||||
const fetched: { url: string; method: string }[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+45
-196
@@ -1,205 +1,54 @@
|
||||
/**
|
||||
* Social API — Hanzo Social (per-org accounts + posts), over the REAL cloud
|
||||
* `/v1/social` surface (cloud `clients/social`, a native-Go per-org accounts+posts
|
||||
* store on Base/SQLite — the in-process fold of the live social stack
|
||||
* github.com/hanzoai/social, twin of clients/crm). NOT a proxy to the standalone
|
||||
* social pods.
|
||||
* Social — the console's BINDING to the one `/v1/social` contract, which lives in
|
||||
* `@hanzo/ui/product/social/api` (`createSocialApi` + the types + the normalizers). This file used
|
||||
* to carry its own copy of that contract; the copy is gone, because the dedicated
|
||||
* social.hanzo.ai app renders the same component and there can only be one.
|
||||
*
|
||||
* Every call is same-origin, keyless and prefix-free (`originV1Url('social/...')`
|
||||
* → `<origin>/v1/social/...`). The console's OWN `app/v1` user-bearer BFF serves the
|
||||
* `social` head — it mints a short-lived user-bound IAM token server-side and
|
||||
* forwards it; the cloud backend resolves the org from the token's `owner` claim, so
|
||||
* every read/write is org-scoped SERVER-SIDE and no credential reaches the browser.
|
||||
* This is the EXACT per-tenant path CRM/Agents/Prompts use (the `social` head is
|
||||
* allow-listed in `proxy-allow.ts` CLOUD_HEADS). A cookie-only call would 403
|
||||
* ("X-Org-Id required"), so the bearer BFF is mandatory — never a direct call.
|
||||
* All this owns is TRANSPORT. Every call is same-origin, keyless and prefix-free
|
||||
* (`originV1Url('social/...')` → `<origin>/v1/social/...`), the exact per-tenant path
|
||||
* CRM/Agents/Prompts use: the console's OWN `app/v1` user-bearer BFF serves the `social`
|
||||
* head (allow-listed in `proxy-allow.ts` CLOUD_HEADS), minting a short-lived user-bound
|
||||
* IAM token server-side, and the cloud backend resolves the org from the token's `owner`
|
||||
* claim — so every read/write is org-scoped SERVER-SIDE and no credential reaches the
|
||||
* browser. A cookie-only call would 403 ("X-Org-Id required"), so the bearer BFF is
|
||||
* mandatory — never a direct call.
|
||||
*
|
||||
* Routes (from cloud `clients/social/social.go`):
|
||||
* GET /v1/social/summary per-org roll-up
|
||||
* GET/POST /v1/social/accounts list (?provider=) / connect
|
||||
* GET/PUT/DELETE /v1/social/accounts/:id detail / update / disconnect
|
||||
* GET/POST /v1/social/posts list (?status=) / create-or-schedule
|
||||
* GET/PUT/DELETE /v1/social/posts/:id detail / update / delete
|
||||
*
|
||||
* Plain REST (raw JSON, real HTTP status). Payloads are normalized DEFENSIVELY — a
|
||||
* field rename upstream degrades a cell rather than throwing, and the list is read
|
||||
* from whichever envelope key the backend uses (`data`/`items`/`rows`, or a bare
|
||||
* array). Mirrors the crm.ts / marketing.ts shape exactly.
|
||||
* Backend: cloud `clients/social` — a native-Go per-org accounts+posts store on
|
||||
* Base/SQLite, the in-process fold of the standalone social stack, twin of clients/crm.
|
||||
*/
|
||||
import { createSocialApi, type SocialRest } from '@hanzo/ui/product/social/api'
|
||||
|
||||
import { restGet, restPost, restPut, restDelete, originV1Url } from './client'
|
||||
|
||||
const BASE = 'social'
|
||||
const enc = encodeURIComponent
|
||||
|
||||
// ── Coercion helpers (defensive; crm.ts style) ──────────────────────────────
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||||
const num = (v: unknown): number => {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
|
||||
return 0
|
||||
}
|
||||
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
|
||||
const asRecord = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
|
||||
|
||||
const arrayUnder = (payload: unknown, keys: string[]): Record<string, unknown>[] => {
|
||||
if (Array.isArray(payload)) return payload.filter((x) => x && typeof x === 'object') as Record<string, unknown>[]
|
||||
if (payload && typeof payload === 'object') {
|
||||
for (const k of keys) {
|
||||
const v = (payload as Record<string, unknown>)[k]
|
||||
if (Array.isArray(v)) return v.filter((x) => x && typeof x === 'object') as Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
const rows = (payload: unknown) => arrayUnder(payload, ['data', 'items', 'rows'])
|
||||
|
||||
// ── Domain types (mirror cloud clients/social/store.go JSON tags) ────────────
|
||||
|
||||
/** Networks (cloud rejects an unknown provider/channel; '' → x). */
|
||||
export const PROVIDERS = ['x', 'facebook', 'instagram', 'linkedin', 'tiktok', 'youtube', 'threads'] as const
|
||||
export type Provider = (typeof PROVIDERS)[number]
|
||||
|
||||
/** Account connection lifecycle (cloud rejects an unknown status; '' → connected). */
|
||||
export const ACCOUNT_STATUSES = ['connected', 'disconnected', 'error'] as const
|
||||
|
||||
/** Post lifecycle (cloud rejects an unknown status; '' → draft). */
|
||||
export const POST_STATUSES = ['draft', 'scheduled', 'published', 'failed'] as const
|
||||
|
||||
export type Account = {
|
||||
id: string
|
||||
provider: string
|
||||
handle: string
|
||||
status: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
/** `path` is relative to `/v1/social` — the contract's form, resolved on our origin. */
|
||||
const rest: SocialRest = {
|
||||
get: (path) => restGet<unknown>(originV1Url(`social/${path}`)),
|
||||
post: (path, body) => restPost<unknown>(originV1Url(`social/${path}`), body),
|
||||
put: (path, body) => restPut<unknown>(originV1Url(`social/${path}`), body),
|
||||
del: (path) => restDelete(originV1Url(`social/${path}`)),
|
||||
}
|
||||
|
||||
export type Post = {
|
||||
id: string
|
||||
content: string
|
||||
channel: string
|
||||
status: string
|
||||
/** Unix seconds; 0 = not scheduled / publish now. */
|
||||
scheduleAt: number
|
||||
/**
|
||||
* Attached media URLs. Cloud stores these and ALWAYS serializes an array (never
|
||||
* null), and its PUT rebuilds the row from the body — so this has to round-trip:
|
||||
* an update that omitted it would wipe the post's media.
|
||||
*/
|
||||
media: string[]
|
||||
/** Server-managed publish results (empty until a publish attempt lands). */
|
||||
accountId?: string
|
||||
externalId?: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
export const SocialApi = createSocialApi(rest)
|
||||
|
||||
export type Summary = { posts: number; scheduled: number; published: number; accounts: number }
|
||||
|
||||
/**
|
||||
* A network's publish-readiness (from GET /v1/social/providers): whether this
|
||||
* deployment has the OAuth-app credentials to publish, and — if not — exactly which
|
||||
* env vars are missing. The honest connect affordance; never fabricated.
|
||||
*/
|
||||
export type ProviderCapability = {
|
||||
provider: string
|
||||
credentialsConfigured: boolean
|
||||
missingCredentials: string[]
|
||||
}
|
||||
|
||||
/** Create/update bodies — only the writable fields (server owns id/org/timestamps). */
|
||||
export type NewAccount = Partial<Omit<Account, 'id' | 'createdAt' | 'updatedAt'>> & { provider: string }
|
||||
export type NewPost = Partial<Omit<Post, 'id' | 'createdAt' | 'updatedAt'>> & { content: string }
|
||||
|
||||
// ── Normalizers (pure) ──────────────────────────────────────────────────────
|
||||
|
||||
export function normalizeAccount(raw: unknown): Account {
|
||||
const r = asRecord(raw)
|
||||
return {
|
||||
id: str(r.id),
|
||||
provider: str(r.provider) || 'x',
|
||||
handle: str(r.handle),
|
||||
status: str(r.status) || 'connected',
|
||||
createdAt: num(r.createdAt),
|
||||
updatedAt: num(r.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePost(raw: unknown): Post {
|
||||
const r = asRecord(raw)
|
||||
return {
|
||||
id: str(r.id),
|
||||
content: str(r.content),
|
||||
channel: str(r.channel) || 'x',
|
||||
status: str(r.status) || 'draft',
|
||||
scheduleAt: num(r.scheduleAt),
|
||||
media: strs(r.media),
|
||||
accountId: str(r.accountId) || undefined,
|
||||
externalId: str(r.externalId) || undefined,
|
||||
error: str(r.error) || undefined,
|
||||
createdAt: num(r.createdAt),
|
||||
updatedAt: num(r.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSummary(raw: unknown): Summary {
|
||||
const r = asRecord(raw)
|
||||
return { posts: num(r.posts), scheduled: num(r.scheduled), published: num(r.published), accounts: num(r.accounts) }
|
||||
}
|
||||
|
||||
export function normalizeProviderCapability(raw: unknown): ProviderCapability {
|
||||
const r = asRecord(raw)
|
||||
return {
|
||||
provider: str(r.provider),
|
||||
credentialsConfigured: Boolean(r.credentialsConfigured),
|
||||
missingCredentials: strs(r.missingCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeAccounts = (p: unknown): Account[] => rows(p).map(normalizeAccount).filter((a) => a.id)
|
||||
export const normalizePosts = (p: unknown): Post[] => rows(p).map(normalizePost).filter((p) => p.id)
|
||||
export const normalizeProviders = (p: unknown): ProviderCapability[] =>
|
||||
rows(p).map(normalizeProviderCapability).filter((c) => c.provider)
|
||||
|
||||
// ── Network methods (thin — one per documented route) ───────────────────────
|
||||
|
||||
export const SocialApi = {
|
||||
summary: (): Promise<Summary> => restGet<unknown>(originV1Url(`${BASE}/summary`)).then(normalizeSummary),
|
||||
|
||||
/** Publish-readiness per network (+ the exact missing OAuth-app credentials). */
|
||||
providers: (): Promise<ProviderCapability[]> =>
|
||||
restGet<unknown>(originV1Url(`${BASE}/providers`)).then(normalizeProviders),
|
||||
|
||||
accounts: {
|
||||
list: (provider?: string): Promise<Account[]> =>
|
||||
restGet<unknown>(
|
||||
originV1Url(`${BASE}/accounts${provider ? `?provider=${enc(provider)}` : ''}`),
|
||||
).then(normalizeAccounts),
|
||||
get: (id: string): Promise<Account> =>
|
||||
restGet<unknown>(originV1Url(`${BASE}/accounts/${enc(id)}`)).then(normalizeAccount),
|
||||
create: (body: NewAccount): Promise<Account> =>
|
||||
restPost<unknown>(originV1Url(`${BASE}/accounts`), body).then(normalizeAccount),
|
||||
update: (id: string, body: NewAccount): Promise<Account> =>
|
||||
restPut<unknown>(originV1Url(`${BASE}/accounts/${enc(id)}`), body).then(normalizeAccount),
|
||||
remove: (id: string): Promise<void> => restDelete(originV1Url(`${BASE}/accounts/${enc(id)}`)),
|
||||
},
|
||||
|
||||
posts: {
|
||||
list: (status?: string): Promise<Post[]> =>
|
||||
restGet<unknown>(
|
||||
originV1Url(`${BASE}/posts${status ? `?status=${enc(status)}` : ''}`),
|
||||
).then(normalizePosts),
|
||||
get: (id: string): Promise<Post> =>
|
||||
restGet<unknown>(originV1Url(`${BASE}/posts/${enc(id)}`)).then(normalizePost),
|
||||
create: (body: NewPost): Promise<Post> =>
|
||||
restPost<unknown>(originV1Url(`${BASE}/posts`), body).then(normalizePost),
|
||||
update: (id: string, body: NewPost): Promise<Post> =>
|
||||
restPut<unknown>(originV1Url(`${BASE}/posts/${enc(id)}`), body).then(normalizePost),
|
||||
remove: (id: string): Promise<void> => restDelete(originV1Url(`${BASE}/posts/${enc(id)}`)),
|
||||
/** Publish a post NOW to its channel's connected accounts. */
|
||||
publish: (id: string): Promise<Post> =>
|
||||
restPost<unknown>(originV1Url(`${BASE}/posts/${enc(id)}/publish`)).then(normalizePost),
|
||||
},
|
||||
}
|
||||
// The contract itself — re-exported so console call sites keep ONE import path.
|
||||
export {
|
||||
PROVIDERS,
|
||||
ACCOUNT_STATUSES,
|
||||
POST_STATUSES,
|
||||
normalizeAccount,
|
||||
normalizeAccounts,
|
||||
normalizePost,
|
||||
normalizePosts,
|
||||
normalizeProviderCapability,
|
||||
normalizeProviders,
|
||||
normalizeSummary,
|
||||
} from '@hanzo/ui/product/social/api'
|
||||
export type {
|
||||
Account,
|
||||
NewAccount,
|
||||
NewPost,
|
||||
Post,
|
||||
Provider,
|
||||
ProviderCapability,
|
||||
SocialSummary,
|
||||
} from '@hanzo/ui/product/social/api'
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* watch the analytics datastore (and every volume) fill and scale DO storage before
|
||||
* it runs out. admin.hanzo.ai only.
|
||||
*
|
||||
* ONE read on the aggregate surface: `GET /v1/admin/block-storage` — the `block-storage` head is
|
||||
* ONE read on the aggregate surface: `GET /v1/admin/volumes` — the `volumes` head is
|
||||
* allow-listed in `admin-aggregate.ts` + `next.config.mjs`, so the request rides the
|
||||
* SAME global-admin-gated `app/admin/aggregate` BFF (getAdminGate → fail-closed 403,
|
||||
* then a minted user bearer) as every other admin read; in the go:embed console it
|
||||
* hits cloud's `/v1/admin/block-storage` directly under the first-party session cookie.
|
||||
* hits cloud's `/v1/admin/volumes` directly under the first-party session cookie.
|
||||
*
|
||||
* Backend contract (cloud `clients/admin/block_storage.go`, DO API inventory + o11y/df fill):
|
||||
* {
|
||||
@@ -127,7 +127,7 @@ export function normalizeSnapshot(raw: unknown): StorageSnapshot {
|
||||
export const StorageFleetApi = {
|
||||
/** The block-storage fleet snapshot — global-admin only. */
|
||||
async snapshot(): Promise<StorageSnapshot> {
|
||||
const raw = await restGet<unknown>(originV1Url('admin/block-storage'))
|
||||
const raw = await restGet<unknown>(originV1Url('admin/volumes'))
|
||||
return normalizeSnapshot(raw)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,6 +132,18 @@ export type Message = Owned & {
|
||||
export type Account = {
|
||||
owner: string
|
||||
name: string
|
||||
/**
|
||||
* The Hanzo IAM user id — the OIDC `sub` claim, a UUID. This is the ONE
|
||||
* identifier for this user across every Hanzo property: hanzo.ai and
|
||||
* hanzo.chat know them by the same value.
|
||||
*
|
||||
* `owner`/`name` are the org and the login handle, and their pair (`hanzo/z`)
|
||||
* is an org-relative REFERENCE, not a user id — it changes if either part is
|
||||
* renamed, and it says nothing about the same user on another surface. Use
|
||||
* this for anything that must join across properties; use owner/name for
|
||||
* display and for org-scoped API paths.
|
||||
*/
|
||||
userId?: string
|
||||
/** casibase user type; "anonymous-user" means no real sign-in. */
|
||||
type?: string
|
||||
displayName?: string
|
||||
|
||||
+80
-13
@@ -17,14 +17,11 @@
|
||||
* versions <= 0.3.1 had no envelope code at all.
|
||||
*
|
||||
* Wiring for the console SPA:
|
||||
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`, so the
|
||||
* first-party session cookie rides along (the go:embed cloud binary serves it
|
||||
* natively; the standalone BFF forwards it as the signed-in user). The client
|
||||
* NEVER sends an org/tenant — Cloud stamps it from the validated session.
|
||||
* - `ingestKey` (optional, `NEXT_PUBLIC_EVENT_INGEST_KEY`) — a publishable pk_ key
|
||||
* for LOGGED-OUT / public views (sign-in, marketing faces) so their pageviews +
|
||||
* errors ingest without a session. Unset → logged-in flows via the cookie and
|
||||
* logged-out is best-effort anonymous. Mint one per org via POST /v1/ingest/keys.
|
||||
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`. The
|
||||
* client NEVER sends an org/tenant — Cloud stamps it from the validated bearer.
|
||||
* - `getToken` — the signed-in visitor's own Hanzo IAM access token. THIS is what
|
||||
* attributes the stream, and it is the only mechanism that is correct here; see
|
||||
* the note below for why no publishable key is passed.
|
||||
* - `dsn` (`NEXT_PUBLIC_HANZO_EVENT_DSN`) — the error plane's own credential,
|
||||
* shaped `https://<key>@api.hanzo.ai/v1/sentry/<project>`. Publishable by design
|
||||
* (it ships in the client bundle). Unset → errors are captured and dropped.
|
||||
@@ -34,12 +31,15 @@
|
||||
* via `reportError`.
|
||||
*
|
||||
* Consent + PII: the stream is PII-free by construction — a random anon id + the
|
||||
* stable `owner/name` actor id (never an email), org never sent. On top of that we
|
||||
* honor an explicit browser opt-out (Global Privacy Control / Do-Not-Track): a
|
||||
* IAM user id (never an email), org never sent. On top of that we honor an
|
||||
* explicit browser opt-out (Global Privacy Control / Do-Not-Track): a
|
||||
* visitor who signals it is not tracked at all. This is the consent layer for the
|
||||
* logged-out marketing/public views.
|
||||
*/
|
||||
import { createAnalytics, type Analytics } from '@hanzo/event'
|
||||
import { setTelemetry } from '@hanzo/ui/telemetry'
|
||||
|
||||
import { iamAccessToken } from '~/lib/auth/iam'
|
||||
|
||||
/** Honor an explicit browser opt-out signal (GPC, then legacy DNT). SSR (no
|
||||
* navigator) defaults to enabled; the browser instance reads the real signal. */
|
||||
@@ -51,8 +51,38 @@ function consented(): boolean {
|
||||
return dnt !== '1' && dnt !== 'yes'
|
||||
}
|
||||
|
||||
/** Publishable ingest key for logged-out ingestion; empty → same-origin cookie/anon. */
|
||||
const ingestKey = process.env.NEXT_PUBLIC_EVENT_INGEST_KEY?.trim() || undefined
|
||||
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
|
||||
//
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
|
||||
// here.
|
||||
//
|
||||
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
|
||||
// this image is brand-agnostic: one build serves cloud.hanzo.ai, cloud.lux.cloud and
|
||||
// cloud.zoo.cloud, with the brand resolved at RUNTIME from the request hostname
|
||||
// (src/config). Baking a key would file every brand's — and every customer's —
|
||||
// traffic into whichever org the key belongs to, which is both wrong data and a
|
||||
// cross-tenant leak. It is the same reason Dockerfile bakes no NEXT_PUBLIC_*.
|
||||
//
|
||||
// Worse, it would be SILENT: @hanzo/event resolves the outgoing credential as
|
||||
// `ingestKey ?? token`, so a key takes PRECEDENCE over the bearer — setting one
|
||||
// would OVERRIDE each signed-in user's own identity rather than supplement it.
|
||||
//
|
||||
// THE COOKIE IS NOT A CREDENTIAL HERE. This file used to claim that posting
|
||||
// same-origin let the first-party session ride along, so signed-in traffic landed
|
||||
// correctly. It does not. That cookie is the casibase session, while cloud resolves
|
||||
// a tenant from a VALIDATED IAM bearer (SanitizeIdentity) — so a cookie-only POST
|
||||
// carries no principal. It is not refused: it silently takes the ANONYMOUS lane,
|
||||
// which files every row under the `$public` tenant (a partition no org can read) and
|
||||
// drops `identify` with a 200 receipt. Production proved it — 498 console rows, all
|
||||
// `$public`, zero identified users.
|
||||
//
|
||||
// `getToken` below is the fix, and it has neither problem: cloud resolves the tenant
|
||||
// from the token's OWN owner claim, so each visitor's events land in THEIR org, on
|
||||
// every brand host, with no per-brand configuration. Logged-out views carry no token
|
||||
// and stay anonymous — the honest outcome for a visitor who has not identified
|
||||
// themselves, and still the open question for the public/marketing faces (closing
|
||||
// that needs a PER-HOST key resolved at RUNTIME, e.g. the `GET /v1/brand?host=`
|
||||
// shape src/config already anticipates; a module-scope const cannot receive it).
|
||||
|
||||
/** Error-plane credential. Unset → captureError is inert (fail-safe). */
|
||||
const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
|
||||
@@ -65,11 +95,48 @@ const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
|
||||
export const eventClient: Analytics = createAnalytics({
|
||||
product: 'console',
|
||||
host: '',
|
||||
ingestKey,
|
||||
// Read through a function, not captured once: this module is a singleton built at
|
||||
// import time, when the visitor is not signed in yet and the token does not exist.
|
||||
// The client calls this at flush time, so a sign-in — and every silent refresh
|
||||
// after it — is picked up with no rebuild. Returns undefined on the server and
|
||||
// when signed out, which is the anonymous path.
|
||||
getToken: () => iamAccessToken() ?? undefined,
|
||||
dsn,
|
||||
enabled: consented(),
|
||||
})
|
||||
|
||||
// ── The shared components emit through THIS client, not a second one ─────────
|
||||
//
|
||||
// @hanzo/ui instruments itself: DataTable, PrimaryButton, SlideOver, ConfirmDelete,
|
||||
// the Field* editors, ComboBox, Segmented/SearchInput, MenuItemView, OrgSwitcher,
|
||||
// ThemeToggle, Toast and EmptyState all report what a user did. They emit through
|
||||
// module-scope `track()`, which resolves an AMBIENT client — and left alone that
|
||||
// client would be a SECOND one: default host api.hanzo.ai, no cookie, no session.
|
||||
// Cloud's anonymous lane admits only pageview + error, so every one of those
|
||||
// component events would have been DROPPED on arrival.
|
||||
//
|
||||
// Registering `eventClient` as the ambient client is the whole fix, and it is what
|
||||
// keeps the promise this file's header makes: ONE client, one batch, one anon id,
|
||||
// one stream, same-origin with the session cookie — so component events arrive
|
||||
// CREDENTIALED and are attributed to the signed-in org. `AnalyticsProvider` in
|
||||
// Provider.tsx already hands this same instance to `useAnalytics()`.
|
||||
//
|
||||
// The wrapper is the `Telemetry` shape @hanzogui/telemetry hands out; every method
|
||||
// delegates, so there is nothing here to keep in sync but the delegation itself.
|
||||
setTelemetry({
|
||||
enabled: true,
|
||||
product: 'console',
|
||||
client: eventClient,
|
||||
track: (event, properties, commerce) => eventClient.capture(event, properties, commerce),
|
||||
pageview: (path, properties) => eventClient.pageview(path, properties),
|
||||
identify: (personId, traits) => eventClient.identify(personId, traits),
|
||||
group: (groupId, traits) => eventClient.group(groupId, traits),
|
||||
captureError: (err, context) => eventClient.captureError(err, context),
|
||||
captureException: (err, context) => eventClient.captureError(err, context),
|
||||
setCohort: (patch) => eventClient.setCohort(patch),
|
||||
flush: () => eventClient.flush(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Report a caught / React-boundary error to the ONE error stream. Marks it handled
|
||||
* (the app caught it) and never throws back — telemetry must not break recovery.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { listQuery } from './client'
|
||||
import { changeQuery, listQuery, watching } from './client'
|
||||
|
||||
describe('listQuery — the generic document list querystring', () => {
|
||||
it('is empty for no query', () => {
|
||||
@@ -26,3 +26,40 @@ describe('listQuery — the generic document list querystring', () => {
|
||||
expect(listQuery({ filters: {} })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeQuery — the ONE change-feed querystring (poll AND stream)', () => {
|
||||
it('is empty for no query', () => {
|
||||
expect(changeQuery()).toBe('')
|
||||
expect(changeQuery({})).toBe('')
|
||||
})
|
||||
|
||||
it('joins doctypes and modules, and passes since + limit', () => {
|
||||
const p = new URLSearchParams(
|
||||
changeQuery({ doctypes: ['Ticket', 'HD Team'], modules: ['help'], since: 42, limit: 10 }).replace(/^\?/, ''),
|
||||
)
|
||||
expect(p.get('doctypes')).toBe('Ticket,HD Team')
|
||||
expect(p.get('modules')).toBe('help')
|
||||
expect(p.get('since')).toBe('42')
|
||||
expect(p.get('limit')).toBe('10')
|
||||
})
|
||||
|
||||
it('carries no org — the tenant is server-derived from the bearer, never sent', () => {
|
||||
const qs = changeQuery({ doctypes: ['Ticket'], since: 1, watching: 'Ticket/TKT-1' })
|
||||
expect(qs).not.toMatch(/org/i)
|
||||
expect(qs).not.toMatch(/tenant/i)
|
||||
})
|
||||
|
||||
it('omits since=0 so a fresh subscriber streams from NOW, not from history', () => {
|
||||
expect(changeQuery({ since: 0 })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('watching — how a document is named to the live surface', () => {
|
||||
it('is <DocType>/<name>', () => {
|
||||
expect(watching('Ticket', 'TKT-00001')).toBe('Ticket/TKT-00001')
|
||||
})
|
||||
|
||||
it('is the DocType alone for a Single, which has exactly one document', () => {
|
||||
expect(watching('Support Settings')).toBe('Support Settings')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
*/
|
||||
import { restGet, restPost, restPut, restDelete, cloudProxyV1Url } from '~/lib/api/client'
|
||||
import type {
|
||||
Change,
|
||||
ChangeFeed,
|
||||
ChangeQuery,
|
||||
DocType,
|
||||
FrameworkDoc,
|
||||
ListQuery,
|
||||
ModuleInfo,
|
||||
InstallResult,
|
||||
Viewer,
|
||||
} from './types'
|
||||
|
||||
const BASE = 'framework'
|
||||
@@ -65,6 +69,40 @@ export function listQuery(q?: ListQuery): string {
|
||||
|
||||
const url = (path: string): string => cloudProxyV1Url(`${BASE}/${path}`)
|
||||
|
||||
/**
|
||||
* Build the change-feed querystring. ONE function for the poll and the stream,
|
||||
* because they are ONE server-side query — the stream is that query in a loop.
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function changeQuery(q?: ChangeQuery & { watching?: string }): string {
|
||||
const p = new URLSearchParams()
|
||||
if (q?.doctypes?.length) p.set('doctypes', q.doctypes.join(','))
|
||||
if (q?.modules?.length) p.set('modules', q.modules.join(','))
|
||||
if (q?.since) p.set('since', String(q.since))
|
||||
if (q?.limit) p.set('limit', String(q.limit))
|
||||
if (q?.watching) p.set('watching', q.watching)
|
||||
const s = p.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
/** `<DocType>/<name>` — the ONE way a document is named to the live surface.
|
||||
* A Single needs no name. Split server-side at the FIRST slash. */
|
||||
export const watching = (doctype: string, name?: string): string =>
|
||||
name ? `${doctype}/${name}` : doctype
|
||||
|
||||
const asChange = (v: unknown): Change => {
|
||||
const r = asRecord(v)
|
||||
return {
|
||||
seq: Number(r.seq ?? 0),
|
||||
doctype: String(r.doctype ?? ''),
|
||||
module: r.module ? String(r.module) : undefined,
|
||||
name: String(r.name ?? ''),
|
||||
action: String(r.action ?? '') as Change['action'],
|
||||
docstatus: Number(r.docstatus ?? 0),
|
||||
at: Number(r.at ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export const FrameworkApi = {
|
||||
/** The DocType registry (schemas) — the collection definitions of every app lane. */
|
||||
doctypes: {
|
||||
@@ -120,6 +158,72 @@ export const FrameworkApi = {
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* The change feed — the ONE way anything here learns a document changed, for
|
||||
* EVERY doctype. `list` is one page; `subscribe` is the same query held open
|
||||
* as Server-Sent Events. A view renders current state from `records.list`,
|
||||
* takes the `cursor` it gets back, and applies changes from there.
|
||||
*/
|
||||
changes: {
|
||||
list: (q?: ChangeQuery): Promise<ChangeFeed> =>
|
||||
restGet<unknown>(url(`changes${changeQuery(q)}`)).then((v) => {
|
||||
const r = asRecord(v)
|
||||
return {
|
||||
changes: rows(r.changes ?? v).map(asChange),
|
||||
cursor: Number(r.cursor ?? 0),
|
||||
reset: r.reset === true,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Hold the feed open. Returns the unsubscribe.
|
||||
*
|
||||
* EventSource, not WebSocket, and it needs no credential: it is a same-origin
|
||||
* GET through the console's `/v1` bearer proxy, so it carries the session
|
||||
* cookie and the ORG is resolved server-side from the minted bearer's owner
|
||||
* claim. A browser can never name its own tenant.
|
||||
*
|
||||
* The browser also resumes for us: each frame's `id` is the change's `seq`,
|
||||
* so a dropped connection reconnects with `Last-Event-ID` and misses nothing.
|
||||
* Passing `watching` additionally declares presence for as long as the
|
||||
* connection lives — that is why no client→server channel is needed.
|
||||
*
|
||||
* `onReset` fires when the cursor fell behind the server's retention window:
|
||||
* refetch current state before applying anything further.
|
||||
*/
|
||||
subscribe: (
|
||||
q: (ChangeQuery & { watching?: string }) | undefined,
|
||||
handlers: {
|
||||
onChange: (c: Change) => void
|
||||
onSync?: (cursor: number) => void
|
||||
onReset?: (cursor: number) => void
|
||||
onError?: (e: Event) => void
|
||||
},
|
||||
): (() => void) => {
|
||||
if (typeof EventSource === 'undefined') return () => {} // SSR: nothing to open
|
||||
const es = new EventSource(url(`stream${changeQuery(q)}`))
|
||||
const cursorOf = (e: MessageEvent): number => Number(asRecord(JSON.parse(e.data)).cursor ?? 0)
|
||||
es.addEventListener('change', (e) => handlers.onChange(asChange(JSON.parse((e as MessageEvent).data))))
|
||||
es.addEventListener('sync', (e) => handlers.onSync?.(cursorOf(e as MessageEvent)))
|
||||
es.addEventListener('reset', (e) => handlers.onReset?.(cursorOf(e as MessageEvent)))
|
||||
if (handlers.onError) es.onerror = handlers.onError
|
||||
return () => es.close()
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Who is viewing a document right now. Joins and leaves arrive on the change
|
||||
* feed above as `present`/`away` on the named document; this reads the roster
|
||||
* they point at — the same rule documents follow (the feed says WHAT changed,
|
||||
* the client re-reads the value).
|
||||
*/
|
||||
presence: {
|
||||
list: (doctype: string, name?: string): Promise<Viewer[]> =>
|
||||
restGet<unknown>(url(`presence${changeQuery({ watching: watching(doctype, name) })}`)).then((r) =>
|
||||
rows(r).map((v) => ({ user: String(v.user ?? ''), since: Number(v.since ?? 0) })),
|
||||
),
|
||||
},
|
||||
|
||||
/** Per-org role assignments (grant editors; the owner is seeded System Manager). */
|
||||
roles: {
|
||||
list: (): Promise<{ user: string; role: string }[]> =>
|
||||
|
||||
@@ -106,3 +106,66 @@ export interface ListQuery {
|
||||
|
||||
/** The redacted-Password marker the engine returns for a set secret. */
|
||||
export const REDACTED_MARKER = '__set__'
|
||||
|
||||
/* ── realtime ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One committed state change to one document — the unit of the engine's ONE
|
||||
* realtime mechanism (`/v1/framework/{changes,stream}`).
|
||||
*
|
||||
* It is a FACT and carries NO payload: a subscriber that cares re-reads the
|
||||
* document through the ordinary permission-checked `records.get`, so the feed
|
||||
* never becomes a second, weaker read path. `seq` is a total order in commit
|
||||
* order and is what a client resumes from.
|
||||
*/
|
||||
export interface Change {
|
||||
seq: number
|
||||
doctype: string
|
||||
module?: string
|
||||
name: string
|
||||
action: ChangeAction
|
||||
docstatus: number
|
||||
at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What happened. `present`/`away` are ROSTER changes on the named document —
|
||||
* presence rides the same feed, so a subscriber needs no second subscription;
|
||||
* re-read the roster with `presence.list` rather than expecting it in the frame.
|
||||
*/
|
||||
export type ChangeAction =
|
||||
| 'created'
|
||||
| 'updated'
|
||||
| 'submitted'
|
||||
| 'cancelled'
|
||||
| 'deleted'
|
||||
| 'present'
|
||||
| 'away'
|
||||
|
||||
/** One page of the feed. */
|
||||
export interface ChangeFeed {
|
||||
changes: Change[]
|
||||
/** Pass as the next `since`. It advances past changes this caller cannot see. */
|
||||
cursor: number
|
||||
/**
|
||||
* The requested cursor fell behind the server's retention window, so changes
|
||||
* between them are gone. Refetch current state, THEN resume from `cursor` —
|
||||
* never patch on top of a state you never had.
|
||||
*/
|
||||
reset?: boolean
|
||||
}
|
||||
|
||||
/** Which changes a subscriber wants. Both narrow; neither can widen what the
|
||||
* caller's roles already allow, and neither can name another org. */
|
||||
export interface ChangeQuery {
|
||||
doctypes?: string[]
|
||||
modules?: string[]
|
||||
since?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** One live viewer of a document. */
|
||||
export interface Viewer {
|
||||
user: string
|
||||
since: number
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function resolveRouting(
|
||||
export type ComingSoonGroup = { group: string; items: string[] }
|
||||
export const COMING_SOON: ComingSoonGroup[] = [
|
||||
{ group: 'GPUs & cloud', items: ['RunPod', 'Lambda', 'AWS', 'GCP', 'Azure'] },
|
||||
{ group: 'Payments', items: ['Stripe', 'Square', 'PayPal'] },
|
||||
{ group: 'Payments', items: ['Square', 'Stripe', 'PayPal'] },
|
||||
{ group: 'Ecommerce', items: ['Shopify', 'WooCommerce'] },
|
||||
{ group: 'Marketing', items: ['Mailchimp', 'HubSpot'] },
|
||||
{ group: 'Analytics', items: ['Google Analytics', 'Mixpanel', 'PostHog'] },
|
||||
|
||||
@@ -756,7 +756,7 @@ export const catalog: CatalogEntry[] = [
|
||||
{ slug: 'clusters', label: 'Clusters' },
|
||||
{ slug: 'nodes', label: 'Nodes' },
|
||||
{ slug: 'volumes', label: 'Volumes' },
|
||||
{ slug: 'load-balancers', label: 'Load balancers' },
|
||||
{ slug: 'balancers', label: 'Load balancers' },
|
||||
{ slug: 'audit', label: 'Audit' },
|
||||
],
|
||||
},
|
||||
@@ -784,7 +784,7 @@ export const catalog: CatalogEntry[] = [
|
||||
// admin.hanzo.ai USAGE CAPS & PROMO — the platform config surface for two levers:
|
||||
// the single plan PROMO (percent-off applied to paid plans, over `/v1/admin/promos`)
|
||||
// and cross-tenant CAP oversight/override (list/create/edit/delete any org's usage
|
||||
// caps over `/v1/admin/spend-caps?org=<slug>`). GLOBAL-ADMIN ONLY (`admin: true` hides
|
||||
// caps over `/v1/admin/caps?org=<slug>`). GLOBAL-ADMIN ONLY (`admin: true` hides
|
||||
// it from every customer's nav/palette; both surfaces are server-gated by
|
||||
// getAdminGate behind `/admin/aggregate`). A config surface (not money-moving) — the
|
||||
// caps model reuses the tenant SpendAlert primitive, so `budgets-logic` is shared, no fork.
|
||||
@@ -3571,7 +3571,7 @@ export const catalog: CatalogEntry[] = [
|
||||
},
|
||||
{
|
||||
// Native — score DEFINITIONS (data type + valid range/categories) on the REAL
|
||||
// /v1/o11y/score-configs surface. Read-only list; honest RuntimeNotice on 503.
|
||||
// /v1/evals/rubrics surface. Read-only list; honest RuntimeNotice on 503.
|
||||
id: 'score-configs',
|
||||
label: 'Score Configs',
|
||||
icon: Ruler,
|
||||
@@ -3584,7 +3584,7 @@ export const catalog: CatalogEntry[] = [
|
||||
routes: [{ path: '', component: ScoreConfigsModule }],
|
||||
},
|
||||
{
|
||||
// Native — human-review queues on the REAL /v1/o11y/annotation-queues surface.
|
||||
// Native — human-review queues on the REAL /v1/o11y/reviews surface.
|
||||
// Read-only list; honest RuntimeNotice when the runtime is not initialized.
|
||||
id: 'annotation-queues',
|
||||
label: 'Annotation Queues',
|
||||
|
||||
@@ -54,11 +54,11 @@ describe('allowAdminSurface — least-privilege admin read surface (v1/admin/<he
|
||||
expect(ADMIN_AGGREGATE_HEADS).toContain('promos')
|
||||
})
|
||||
|
||||
it('admits the spend-caps oversight surface — the list AND the :id edit/delete sub-path', () => {
|
||||
expect(allowAdminSurface('v1/admin/spend-caps')).toBe(true)
|
||||
// PATCH/DELETE /v1/admin/spend-caps/:id ride the same head via the sub-path rule.
|
||||
expect(allowAdminSurface('v1/admin/spend-caps/alert-123')).toBe(true)
|
||||
expect(ADMIN_AGGREGATE_HEADS).toContain('spend-caps')
|
||||
it('admits the caps oversight surface — the list AND the :id edit/delete sub-path', () => {
|
||||
expect(allowAdminSurface('v1/admin/caps')).toBe(true)
|
||||
// PATCH/DELETE /v1/admin/caps/:id ride the same head via the sub-path rule.
|
||||
expect(allowAdminSurface('v1/admin/caps/alert-123')).toBe(true)
|
||||
expect(ADMIN_AGGREGATE_HEADS).toContain('caps')
|
||||
})
|
||||
|
||||
it('admits the providers control board — the list AND the two mutation sub-paths', () => {
|
||||
|
||||
@@ -24,15 +24,15 @@
|
||||
* is a HEAD like the others; `allowAdminSurface` admits `v1/admin/providers[/...]`,
|
||||
* so both the read and the two mutation sub-paths pass, and NOTHING else does.
|
||||
* - `promos` — the single platform plan promo (GET the current promo; PUT upserts it).
|
||||
* - `spend-caps` — cross-tenant usage-cap oversight/override (GET the list for an org;
|
||||
* POST creates; PATCH/DELETE `spend-caps/:id` edit/remove — all `?org=<slug>`-scoped).
|
||||
* `allowAdminSurface` admits `v1/admin/spend-caps[/...]`, so the `:id` sub-path passes.
|
||||
* - `caps` — cross-tenant usage-cap oversight/override (GET the list for an org;
|
||||
* POST creates; PATCH/DELETE `caps/:id` edit/remove — all `?org=<slug>`-scoped).
|
||||
* `allowAdminSurface` admits `v1/admin/caps[/...]`, so the `:id` sub-path passes.
|
||||
* - `infra` — the DigitalOcean fleet read (GET the snapshot) plus its three mutations:
|
||||
* POST `infra/volumes/:id/snapshot`, DELETE `infra/volumes/:id`, POST
|
||||
* `infra/nodes/:id/cordon`. `allowAdminSurface` admits `v1/admin/infra[/...]`, so
|
||||
* every sub-path passes and nothing outside `v1/admin/<head>` ever does.
|
||||
*/
|
||||
export const ADMIN_AGGREGATE_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage', 'infra'] as const
|
||||
export const ADMIN_AGGREGATE_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes', 'infra'] as const
|
||||
|
||||
const ALLOWED = new Set<string>(ADMIN_AGGREGATE_HEADS)
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* A REFUSAL, a network error and an ABSENCE are three different answers.
|
||||
*
|
||||
* IAM v1.33.31 made org scoping honour-or-refuse (`internal/authz/authz.go`
|
||||
* `Scope`): a non-SuperAdmin principal that asks about a foreign owner is REFUSED
|
||||
* rather than silently re-pointed at its own org. Read from that source, the wire
|
||||
* shapes are:
|
||||
*
|
||||
* hit HTTP 200 {status:"ok", data:{…}} (httpx.Ok)
|
||||
* absence HTTP 200 {status:"error", msg:"the entity does not exist"}
|
||||
* (httpx.Err — 200 BY
|
||||
* CONTRACT: "branch on
|
||||
* status, not HTTP code")
|
||||
* refusal HTTP 403 {status:"error", msg:"forbidden: …"} (authz.Deny → 403)
|
||||
*
|
||||
* Absence is the ONLY one that may become `null`. This module's confidential
|
||||
* client is exactly the kind of principal the v1.33.31 rollout starts refusing, so
|
||||
* this is a deploy-ordering hazard rather than a theoretical one, and the damage
|
||||
* is concrete:
|
||||
*
|
||||
* - `getMember` is the invite flow's identity read. A `null` tells the invitee
|
||||
* "your membership was removed" (410) when IAM merely would not answer, and it
|
||||
* is the same `null` the single-use activation guard reads.
|
||||
* - `getUserKey` is the API-key state read. A `null` reports "no key", which is
|
||||
* the exact regression its docstring records: the page falls back to "Create",
|
||||
* the live key is hidden, and the user mints a duplicate over a key they can no
|
||||
* longer revoke.
|
||||
*
|
||||
* The admin gate's own read is the deliberate EXCEPTION and is pinned here too: it
|
||||
* is fail-SOFT because the gate needs positive evidence, so softening can only ever
|
||||
* DENY. That asymmetry is the point — soften where a lost answer closes a door,
|
||||
* never where it opens one.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The confidential client is read at module scope, and every route checks
|
||||
// `mintConfigured()` before calling — so configure it before the import, which is
|
||||
// what production guarantees.
|
||||
vi.hoisted(() => {
|
||||
process.env.IAM_MINT_CLIENT_ID = 'hanzo-console'
|
||||
process.env.IAM_MINT_CLIENT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
vi.mock('./session', () => ({ consoleClaims: vi.fn() }))
|
||||
|
||||
import { getMember, getUserKey, getAdminGate, type SessionUser } from './identity'
|
||||
|
||||
/** IAM's genuine-absence envelope, verbatim (compat/aliases.go getHandler). */
|
||||
const ABSENT = { status: 'error', msg: 'the entity does not exist' }
|
||||
/** IAM's refusal, verbatim (authz.errForeignOrg, rendered by authz.Deny at 403). */
|
||||
const REFUSAL = {
|
||||
status: 'error',
|
||||
msg: 'forbidden: this credential is scoped to organization hanzo',
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status })))
|
||||
|
||||
const user: SessionUser = {
|
||||
owner: 'maxpower',
|
||||
name: 'dave',
|
||||
id: 'maxpower/dave',
|
||||
accessKey: '',
|
||||
email: 'dave@maxpower.io',
|
||||
emailVerified: true,
|
||||
isAdmin: true,
|
||||
isSuperAdmin: false,
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('getMember — absence is the ONLY null', () => {
|
||||
it('a genuine absence is null (the invite really does name nobody)', async () => {
|
||||
json(ABSENT)
|
||||
await expect(getMember('acme/ghost')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('a hit returns the member', async () => {
|
||||
json({ status: 'ok', data: { owner: 'acme', name: 'dave', password: '' } })
|
||||
await expect(getMember('acme/dave')).resolves.toMatchObject({ name: 'dave' })
|
||||
})
|
||||
|
||||
it('a REFUSAL is not an absence — it throws, and says why', async () => {
|
||||
json(REFUSAL, 403)
|
||||
await expect(getMember('acme/dave')).rejects.toThrow(/scoped to organization hanzo/)
|
||||
})
|
||||
|
||||
it('a 5xx is not an absence', async () => {
|
||||
json({ status: 'error', msg: 'boom' }, 502)
|
||||
await expect(getMember('acme/dave')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('an unreachable IAM is not an absence', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('fetch failed')
|
||||
}),
|
||||
)
|
||||
await expect(getMember('acme/dave')).rejects.toThrow(/unreachable/)
|
||||
})
|
||||
|
||||
it('an unreadable envelope is not an absence', async () => {
|
||||
json({ status: 'error', msg: 'id (owner/name) or name is required' })
|
||||
await expect(getMember('acme/dave')).rejects.toThrow(/is required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserKey — a lost answer never reports "no key"', () => {
|
||||
it('a genuine absence is an honest empty', async () => {
|
||||
json(ABSENT)
|
||||
await expect(getUserKey(user)).resolves.toEqual({ accessKey: '', updatedAt: '' })
|
||||
})
|
||||
|
||||
it('a hit returns the real key state', async () => {
|
||||
json({ status: 'ok', data: { accessKey: 'sk-live-abc', updatedTime: '2026-01-01T00:00:00Z' } })
|
||||
await expect(getUserKey(user)).resolves.toEqual({
|
||||
accessKey: 'sk-live-abc',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('a REFUSAL throws — it must NOT hide a live key behind "Create"', async () => {
|
||||
json(REFUSAL, 403)
|
||||
await expect(getUserKey(user)).rejects.toThrow(/scoped to organization hanzo/)
|
||||
})
|
||||
|
||||
it('an unreachable IAM throws rather than reporting no key', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('fetch failed')
|
||||
}),
|
||||
)
|
||||
await expect(getUserKey(user)).rejects.toThrow(/unreachable/)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The admin gate reads IAM too, and there the softening is CORRECT: the gate
|
||||
* admits only on positive evidence (a verified brand-domain email AND an IAM admin
|
||||
* flag), so a lost answer can only deny. Pinned so the strictness above is never
|
||||
* "fixed" into throwing here — and, more importantly, so a refusal can never be
|
||||
* mistaken for an admission.
|
||||
*/
|
||||
describe('getAdminGate — a refused IAM read still fails CLOSED', () => {
|
||||
const req = (host: string) =>
|
||||
({
|
||||
headers: {
|
||||
get: (h: string) => (h === 'host' ? host : h === 'cookie' ? 'cloud_session_id=x' : null),
|
||||
},
|
||||
}) as unknown as import('next/server').NextRequest
|
||||
|
||||
it('refuses when IAM refuses the claims read (no gate opened on a 403)', async () => {
|
||||
// get-account answers with a brand-domain user whose verification is unknown;
|
||||
// the authoritative IAM re-read is REFUSED. The gate must not admit.
|
||||
let call = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
call += 1
|
||||
return call === 1
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
data: { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', type: 'normal-user' },
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
: new Response(JSON.stringify(REFUSAL), { status: 403 })
|
||||
}),
|
||||
)
|
||||
await expect(getAdminGate(req('admin.hanzo.ai'))).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The confidential client is read at module scope, and the ONE IAM transport
|
||||
// refuses to call without it (every route checks `mintConfigured()` first, so a
|
||||
// configured client is the production precondition for the mint/issue primitives
|
||||
// exercised below). Set before the import — `vi.hoisted` runs ahead of it.
|
||||
vi.hoisted(() => {
|
||||
process.env.IAM_MINT_CLIENT_ID = 'hanzo-console'
|
||||
process.env.IAM_MINT_CLIENT_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
// Control the console-session claims the resolver sees. (The casibase fallback is
|
||||
// exercised by stubbing the get-account fetch below.)
|
||||
vi.mock('./session', () => ({ consoleClaims: vi.fn() }))
|
||||
|
||||
+130
-229
@@ -18,7 +18,6 @@
|
||||
* Every value here is server-only env (sourced from KMS via the deployment's
|
||||
* secret refs) — NEVER `NEXT_PUBLIC_`, never in the browser bundle.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
@@ -223,64 +222,126 @@ async function resolveSessionUser(
|
||||
return { owner, name, id, accessKey, email, emailVerified, isAdmin, isSuperAdmin }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a user's authoritative identity claims (email + admin flags) from IAM as
|
||||
* the confidential client. GET, Basic auth — same credential the mint/issue
|
||||
* primitives use. Fail-soft (returns null) so a transient IAM error never opens
|
||||
* the admin gate.
|
||||
*/
|
||||
async function iamGetUser(id: string): Promise<UserClaims | null> {
|
||||
if (!mintConfigured()) return null
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchWithTimeout(`${IAM_URL}/v1/iam/get-user?id=${encodeURIComponent(id)}`, {
|
||||
headers: { Authorization: basicAuth(), Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { status?: string; data?: UserClaims } | null
|
||||
if (!res.ok || !json || json.status !== 'ok' || !json.data) return null
|
||||
return json.data
|
||||
}
|
||||
|
||||
function basicAuth(): string {
|
||||
return 'Basic ' + Buffer.from(`${MINT_CLIENT_ID}:${MINT_CLIENT_SECRET}`).toString('base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a privileged IAM primitive as the confidential client, on behalf of a
|
||||
* resolved user. Throws on any non-ok envelope so callers map it to a 5xx.
|
||||
* IAM's genuine-absence answer, verbatim (`internal/compat/aliases.go` getHandler).
|
||||
* It arrives as HTTP **200**: `httpx.Err` is 200 by contract — the SDK is told to
|
||||
* "branch on status, not HTTP code" — so absence is an ENVELOPE fact, never a 404.
|
||||
*/
|
||||
async function iamCall<T = Record<string, unknown>>(
|
||||
const ABSENT = 'the entity does not exist'
|
||||
|
||||
/** An IAM answer we could not act on. `absent` marks the ONE benign kind. */
|
||||
class IamError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly absent = false,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* THE IAM call — one transport, one error convention: it returns data or throws.
|
||||
*
|
||||
* IAM answers three DIFFERENT things and they must stay three (v1.33.31 made org
|
||||
* scoping honour-or-refuse in `internal/authz/authz.go` `Scope`):
|
||||
*
|
||||
* hit 200 `{status:"ok", data}` → the data
|
||||
* absence 200 `{status:"error", msg:"<ABSENT>"}` → throw, `absent`
|
||||
* refusal 403 `{status:"error", msg:"forbidden: …"}` → throw (authz.Deny)
|
||||
*
|
||||
* plus an unreachable IAM and a malformed envelope, which are also throws. Only a
|
||||
* caller for whom "no such row" is a legitimate answer may soften `absent`. A
|
||||
* refusal, a network error and an absence collapsed into one `null` is how a
|
||||
* caller decides a taken slug is free and writes over it, or tells an invitee
|
||||
* their membership was revoked when IAM merely would not answer — so this
|
||||
* function never collapses them.
|
||||
*
|
||||
* `method` is explicit because IAM has param-only POSTs (mint/revoke/issue take
|
||||
* their id in the query and no body). Same shape as cloud's Go client
|
||||
* (`apps/account/iam.go` `do`), so the fleet has ONE IAM call convention.
|
||||
*/
|
||||
async function iam<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
query: Record<string, string>,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const qs = new URLSearchParams(query).toString()
|
||||
const res = await fetchWithTimeout(`${IAM_URL}${path}?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: basicAuth(), Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = (await res.json().catch(() => null)) as { status?: string; msg?: string; data?: T } | null
|
||||
if (!res.ok || !json || json.status !== 'ok') {
|
||||
throw new Error(json?.msg || `IAM ${path} failed (HTTP ${res.status})`)
|
||||
if (!mintConfigured()) {
|
||||
throw new IamError(`IAM ${path} refused: no confidential client is configured`)
|
||||
}
|
||||
return (json.data ?? ({} as T))
|
||||
const qs = new URLSearchParams(query).toString()
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchWithTimeout(`${IAM_URL}${path}${qs ? `?${qs}` : ''}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: basicAuth(),
|
||||
Accept: 'application/json',
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch (e) {
|
||||
throw new IamError(`IAM ${path} unreachable: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { status?: string; msg?: string; data?: T }
|
||||
| null
|
||||
// A non-2xx is an authorization or transport verdict, never a statement about
|
||||
// whether the row exists.
|
||||
if (!res.ok) throw new IamError(json?.msg || `IAM ${path} failed (HTTP ${res.status})`)
|
||||
if (!json || json.status !== 'ok') {
|
||||
const msg = json?.msg || `IAM ${path} returned an unreadable response`
|
||||
throw new IamError(msg, msg === ABSENT)
|
||||
}
|
||||
return (json.data ?? null) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a row whose ABSENCE is a legitimate answer: the data, or `null` when IAM
|
||||
* says there is no such row. Every other failure — a refusal, an unreachable IAM,
|
||||
* a malformed envelope — propagates, so `null` here means "IAM says it does not
|
||||
* exist", never "we could not find out".
|
||||
*/
|
||||
async function iamGetOrAbsent<T>(path: string, query: Record<string, string>): Promise<T | null> {
|
||||
try {
|
||||
return await iam<T>('GET', path, query)
|
||||
} catch (e) {
|
||||
if (e instanceof IamError && e.absent) return null
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's authoritative identity claims (email + admin flags), read as the
|
||||
* confidential client. Fail-SOFT by design and safe: the admin gate needs
|
||||
* POSITIVE evidence (a verified brand-domain email AND an IAM admin flag), so
|
||||
* empty claims deny. Softening every failure here can only ever close the gate —
|
||||
* the opposite of the existence probes above, where a softened failure would
|
||||
* authorize a write.
|
||||
*/
|
||||
async function iamGetUser(id: string): Promise<UserClaims | null> {
|
||||
return iam<UserClaims>('GET', '/v1/iam/get-user', { id }).catch(() => null)
|
||||
}
|
||||
|
||||
/** (Re)generate the user's `hk-` Cloud API key; returns the new key (shown once). */
|
||||
export async function mintUserKey(user: SessionUser): Promise<string> {
|
||||
const data = await iamCall<{ accessKey?: string }>('/v1/iam/mint-user-keys', { id: user.id })
|
||||
const key = data.accessKey ?? ''
|
||||
const data = await iam<{ accessKey?: string } | null>('POST', '/v1/iam/mint-user-keys', {
|
||||
id: user.id,
|
||||
})
|
||||
const key = data?.accessKey ?? ''
|
||||
if (!key) throw new Error('IAM did not return an access key')
|
||||
return key
|
||||
}
|
||||
|
||||
/** Clear the user's `hk-` Cloud API key (immediate revoke; gateway cache ~5m). */
|
||||
export async function revokeUserKey(user: SessionUser): Promise<void> {
|
||||
await iamCall('/v1/iam/revoke-user-keys', { id: user.id })
|
||||
await iam('POST', '/v1/iam/revoke-user-keys', { id: user.id })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,11 +355,19 @@ export async function revokeUserKey(user: SessionUser): Promise<void> {
|
||||
* active key (uncopyable, unrevocable, and re-minted as a duplicate). IAM
|
||||
* `get-user?id=<owner>/<name>` returns the real `accessKey` (+ the user's
|
||||
* `updatedTime`, the last time the key row changed). Basic-auth confidential
|
||||
* client, fail-soft: an unreadable IAM leaves the key absent (honest empty),
|
||||
* never fabricates one.
|
||||
* client.
|
||||
*
|
||||
* An unreadable IAM must NOT report "no key": that is the very bug above, and a
|
||||
* refusal would recreate it — the page falls back to "Create", the live key is
|
||||
* hidden, and the user mints a duplicate over a key they can no longer revoke. So
|
||||
* only a genuine absence is an honest empty; anything else throws and `GET /keys`
|
||||
* answers 502 (it already maps a throw that way).
|
||||
*/
|
||||
export async function getUserKey(user: SessionUser): Promise<{ accessKey: string; updatedAt: string }> {
|
||||
const u = await iamGetData<{ accessKey?: string; updatedTime?: string }>('/v1/iam/get-user', { id: user.id })
|
||||
const u = await iamGetOrAbsent<{ accessKey?: string; updatedTime?: string }>(
|
||||
'/v1/iam/get-user',
|
||||
{ id: user.id },
|
||||
)
|
||||
return { accessKey: u?.accessKey ?? '', updatedAt: u?.updatedTime ?? '' }
|
||||
}
|
||||
|
||||
@@ -320,196 +389,20 @@ export async function issueUserToken(
|
||||
): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const query: Record<string, string> = { id: user.id }
|
||||
if (audience) query.aud = audience
|
||||
const data = await iamCall<{ accessToken?: string; expiresIn?: number }>('/v1/iam/issue-user-token', query)
|
||||
if (!data.accessToken) throw new Error('IAM did not return a token')
|
||||
const data = await iam<{ accessToken?: string; expiresIn?: number } | null>(
|
||||
'POST',
|
||||
'/v1/iam/issue-user-token',
|
||||
query,
|
||||
)
|
||||
if (!data?.accessToken) throw new Error('IAM did not return a token')
|
||||
return { accessToken: data.accessToken, expiresIn: data.expiresIn ?? 0 }
|
||||
}
|
||||
|
||||
// ── Org onboarding (create org + move the caller into it) ─────────────────────
|
||||
// The confidential `hanzo-console` client is allowlisted for BOTH the org-admin
|
||||
// (IAM_ORG_ADMIN_APPS) and user-admin (IAM_USER_ADMIN_APPS) capabilities, so it
|
||||
// may create an organization and make the signed-in user that org's admin. The
|
||||
// cloud backend scopes all data by the user's IAM `owner` (GetEffectiveOrg →
|
||||
// session user.Owner), and an IAM user belongs to exactly ONE org, so giving
|
||||
// a zero-org user their own org means MOVING them into it (owner=slug,
|
||||
// isAdmin=true). The user's password travels with the user row (verification
|
||||
// uses user.PasswordType first — object/check.go), so the move never locks them
|
||||
// out; we still clone the source org's password/locale settings so the new org
|
||||
// is well-formed (and covers a user whose PasswordType is empty).
|
||||
|
||||
/** An IAM organization as IAM returns it (only the fields we read/clone). */
|
||||
type IamOrganization = {
|
||||
owner?: string
|
||||
name?: string
|
||||
displayName?: string
|
||||
passwordType?: string
|
||||
passwordSalt?: string
|
||||
passwordObfuscatorType?: string
|
||||
passwordObfuscatorKey?: string
|
||||
passwordOptions?: string[]
|
||||
countryCodes?: string[]
|
||||
languages?: string[]
|
||||
defaultAvatar?: string
|
||||
accountItems?: unknown[]
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** GET an IAM resource as the confidential client; null when absent/unreadable. */
|
||||
async function iamGetData<T>(path: string, query: Record<string, string>): Promise<T | null> {
|
||||
if (!mintConfigured()) return null
|
||||
const qs = new URLSearchParams(query).toString()
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchWithTimeout(`${IAM_URL}${path}?${qs}`, {
|
||||
headers: { Authorization: basicAuth(), Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { status?: string; data?: T } | null
|
||||
if (!res.ok || !json || json.status !== 'ok' || json.data == null) return null
|
||||
return json.data
|
||||
}
|
||||
|
||||
/** POST a JSON body to an IAM primitive as the confidential client. */
|
||||
async function iamPostBody<T = unknown>(
|
||||
path: string,
|
||||
query: Record<string, string>,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
const qs = new URLSearchParams(query).toString()
|
||||
const res = await fetchWithTimeout(`${IAM_URL}${path}${qs ? `?${qs}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: basicAuth(), Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = (await res.json().catch(() => null)) as { status?: string; msg?: string; data?: T } | null
|
||||
if (!res.ok || !json || json.status !== 'ok') {
|
||||
throw new Error(json?.msg || `IAM ${path} failed (HTTP ${res.status})`)
|
||||
}
|
||||
return (json.data ?? ({} as T))
|
||||
}
|
||||
|
||||
/** Read an organization (owned by the `admin` org) by name; null when absent. */
|
||||
export async function getOrganization(name: string): Promise<IamOrganization | null> {
|
||||
return iamGetData<IamOrganization>('/v1/iam/get-organization', { id: `admin/${name}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer organization. Clones password + locale settings from the
|
||||
* caller's current org (so the org is well-formed and the moved user's login is
|
||||
* unaffected) and clears all instance-specific material (apps, logos, master
|
||||
* secrets, MFA). The org is owned by the `admin` org; `isPersonal` marks a
|
||||
* personal org (the "skip" path).
|
||||
*/
|
||||
export async function createOrganization(opts: {
|
||||
name: string
|
||||
displayName: string
|
||||
personal: boolean
|
||||
/** The caller's current org, cloned for password/locale compatibility. */
|
||||
sourceOwner: string
|
||||
}): Promise<void> {
|
||||
const src = await getOrganization(opts.sourceOwner)
|
||||
const org: IamOrganization = {
|
||||
owner: 'admin',
|
||||
name: opts.name,
|
||||
displayName: opts.displayName,
|
||||
createdTime: new Date().toISOString(),
|
||||
isPersonal: opts.personal,
|
||||
// Cloned for compatibility (best-effort; sane IAM defaults otherwise).
|
||||
passwordType: src?.passwordType || 'bcrypt',
|
||||
passwordSalt: src?.passwordSalt || '',
|
||||
passwordObfuscatorType: src?.passwordObfuscatorType || 'Plain',
|
||||
passwordObfuscatorKey: src?.passwordObfuscatorKey || '',
|
||||
passwordOptions: src?.passwordOptions ?? ['AtLeast6'],
|
||||
countryCodes: src?.countryCodes ?? ['US'],
|
||||
languages: src?.languages ?? ['en'],
|
||||
defaultAvatar: src?.defaultAvatar || 'https://cdn.hanzo.ai/img/hanzo-cloud-user.png',
|
||||
accountItems: src?.accountItems ?? [],
|
||||
// Never inherited — each org gets its own (or none) of these.
|
||||
defaultApplication: '',
|
||||
logo: '',
|
||||
logoDark: '',
|
||||
favicon: '',
|
||||
masterPassword: '',
|
||||
defaultPassword: '',
|
||||
masterVerificationCode: '',
|
||||
mfaItems: [],
|
||||
tags: [],
|
||||
websiteUrl: '',
|
||||
enableSoftDeletion: false,
|
||||
isProfilePublic: false,
|
||||
}
|
||||
await iamPostBody('/v1/iam/add-organization', {}, org)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand-new account as the ADMIN of an org (self-serve signup). The org
|
||||
* (`opts.org`) must already exist — the caller mints it via `createOrganization`
|
||||
* first, so the new user's own personal org carries proper password/locale policy.
|
||||
*
|
||||
* Password hashing is IAM-side and non-negotiable: we send the plaintext `password`
|
||||
* with NO `passwordType`, so casibase's `AddUser` runs `UpdateUserPassword`, which
|
||||
* hashes with the org's policy (argon2id, cloned from the brand org) and explicitly
|
||||
* REFUSES to store plaintext. We pass an explicit `id` (UUID) so AddUser skips the
|
||||
* signup-application lookup a fresh personal org has no default app for.
|
||||
*/
|
||||
export async function createUser(opts: {
|
||||
org: string
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
displayName: string
|
||||
/** The brand app the account signs up through (hygiene; login is by email). */
|
||||
signupApplication: string
|
||||
}): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await iamPostBody('/v1/iam/add-user', {}, {
|
||||
owner: opts.org,
|
||||
name: opts.username,
|
||||
id: randomUUID(),
|
||||
type: 'normal-user',
|
||||
// Plaintext in — IAM hashes it (argon2id) and never persists it as-is. Do NOT
|
||||
// set passwordType, or AddUser skips hashing and stores the value verbatim.
|
||||
password: opts.password,
|
||||
displayName: opts.displayName,
|
||||
email: opts.email,
|
||||
emailVerified: false,
|
||||
phone: '',
|
||||
countryCode: '',
|
||||
signupApplication: opts.signupApplication,
|
||||
createdTime: now,
|
||||
updatedTime: now,
|
||||
isAdmin: true,
|
||||
isForbidden: false,
|
||||
isDeleted: false,
|
||||
avatar: '',
|
||||
score: 0,
|
||||
ranking: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a user into `org` as that org's admin. Sends the FULL current user object
|
||||
* (IAM's update-user overwrites the default column set from the body, so a
|
||||
* partial object would blank fields) with owner + isAdmin changed. The caller is
|
||||
* always the signed-in user (the route binds the id to the session), so this can
|
||||
* only ever move oneself.
|
||||
*/
|
||||
export async function moveUserToOrg(user: SessionUser, org: string): Promise<void> {
|
||||
const current = await iamGetData<Record<string, unknown>>('/v1/iam/get-user', { id: user.id })
|
||||
if (!current) throw new Error('Could not read the current user from IAM')
|
||||
const moved = { ...current, owner: org, isAdmin: true }
|
||||
await iamPostBody('/v1/iam/update-user', { id: user.id }, moved)
|
||||
}
|
||||
|
||||
// ── Team-invite acceptance (activate a pending member) ───────────────────────
|
||||
// An org admin's invite creates a real member row (owner=org, isAdmin=role) with
|
||||
// NO password — it can't sign in yet ("pending"). The accept flow lets the invitee
|
||||
// set that password themselves via a sealed invite token (see server/invite.ts).
|
||||
// Both ops use the SAME confidential client as createUser/moveUserToOrg (already
|
||||
// Both ops use the SAME confidential client as the key/token primitives (already
|
||||
// allowlisted for user-admin), so no new IAM capability is required.
|
||||
|
||||
/** An IAM member row (only the fields the accept flow reads/writes). */
|
||||
@@ -524,9 +417,17 @@ export type IamMember = {
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** Read a member (`<owner>/<name>`) as the confidential client; null when absent. */
|
||||
/**
|
||||
* Read a member (`<owner>/<name>`) as the confidential client.
|
||||
*
|
||||
* `null` means IAM says there is no such member — the ONE thing the callers may
|
||||
* treat as "this invite no longer names anyone". A refusal or an unreachable IAM
|
||||
* throws instead, because reporting those as absence tells an invitee their
|
||||
* membership was revoked when IAM simply would not answer, and it is the same
|
||||
* `null` the activation guard reads.
|
||||
*/
|
||||
export async function getMember(id: string): Promise<IamMember | null> {
|
||||
return iamGetData<IamMember>('/v1/iam/get-user', { id })
|
||||
return iamGetOrAbsent<IamMember>('/v1/iam/get-user', { id })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -554,7 +455,7 @@ export async function activateMember(
|
||||
id: string,
|
||||
opts: { password: string; displayName?: string; signupApplication?: string },
|
||||
): Promise<void> {
|
||||
const current = await iamGetData<IamMember>('/v1/iam/get-user', { id })
|
||||
const current = await iam<IamMember | null>('GET', '/v1/iam/get-user', { id })
|
||||
if (!current) throw new Error('Could not read the member from IAM')
|
||||
const updated: IamMember = {
|
||||
...current,
|
||||
@@ -577,7 +478,7 @@ export async function activateMember(
|
||||
updated.signupApplication = opts.signupApplication
|
||||
columns.push('signup_application')
|
||||
}
|
||||
await iamPostBody('/v1/iam/update-user', { id, columns: columns.join(',') }, updated)
|
||||
await iam('POST', '/v1/iam/update-user', { id, columns: columns.join(',') }, updated)
|
||||
}
|
||||
|
||||
// ── Admin gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -83,7 +83,7 @@ export function validateOrgName(input: string): OrgNameResult {
|
||||
// The `/auth/signup` BFF creates a brand-new IAM user PLUS their own personal org
|
||||
// (as admin) in one shot, then the client signs them in. IAM users always belong
|
||||
// to an org (AddUser rejects owner==""), so a "create account, then onboard" split
|
||||
// isn't possible against Casdoor — the org is minted here. These helpers are the
|
||||
// isn't possible against IAM — the org is minted here. These helpers are the
|
||||
// pure naming/validation policy; the route owns the IAM calls + the crypto digest.
|
||||
|
||||
/** Minimum password length. Above the cloned org policy (AtLeast6), a safe floor. */
|
||||
|
||||
@@ -80,9 +80,9 @@ describe('allowCloudSurface', () => {
|
||||
expect(allowCloudSurface('v1/agents/agent-1/runs')).toBe(true)
|
||||
})
|
||||
|
||||
it('admits the evals facade (scores/datasets/dataset-items/evaluators/runs)', () => {
|
||||
it('admits the evals facade (scores/datasets/rubrics/evaluators/runs)', () => {
|
||||
expect(CLOUD_HEADS).toContain('evals')
|
||||
for (const sub of ['scores', 'datasets', 'dataset-items', 'evaluators', 'runs']) {
|
||||
for (const sub of ['scores', 'datasets', 'rubrics', 'evaluators', 'runs']) {
|
||||
expect(allowCloudSurface(`v1/evals/${sub}`)).toBe(true)
|
||||
}
|
||||
// the bare head + a query-less path both admit
|
||||
|
||||
@@ -46,7 +46,7 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
// prompts/agents — the single `automations` head admits every sub-path (pieces,
|
||||
// flows CRUD + enable/disable/run, runs, mcp). Replaces the retired /v1/auto proxy.
|
||||
'automations',
|
||||
// Webhooks (cloud clients/webhooks): /v1/webhooks[/:id[/{deliveries,test,rotate-secret}]].
|
||||
// Webhooks (cloud clients/webhooks): /v1/webhooks[/:id[/{deliveries,test,secret}]].
|
||||
// The org's outbound event destinations — the handler resolves the org from the Bearer
|
||||
// owner (principal.Tenant) and 403s a cookie-only or forged-header call, so it routes
|
||||
// through /v1 exactly like automations/agents. The single `webhooks` head admits every
|
||||
@@ -101,8 +101,8 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
// through /v1 exactly like crm — the single `company` head admits the formation
|
||||
// read + every stage-action + transition sub-path.
|
||||
'company',
|
||||
// Cap table (cloud clients/captable): /v1/captable/{company,stakeholders,share-classes,
|
||||
// equity-plans,shares,options,safes,convertibles,rounds,investments,summary}[/:id].
|
||||
// Cap table (cloud clients/captable): /v1/captable/{company,stakeholders,classes,
|
||||
// plans,shares,options,safes,convertibles,rounds,investments,summary}[/:id].
|
||||
// The per-org capitalization ledger on Base/SQLite (HIP-0106); every route resolves
|
||||
// the org from the Bearer owner (principal.Org) and 403s a cookie-only call, so it
|
||||
// routes through /v1 exactly like crm — the single `captable` head admits every
|
||||
@@ -184,8 +184,8 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
// a cookie-only call 401s, so it routes through /v1 like the rest. Distinct from the
|
||||
// admin god-view, which stays on the global-admin aggregate proxy.
|
||||
'audit',
|
||||
// Evals facade (cloud clients/eval): /v1/evals/{scores,datasets,dataset-items,
|
||||
// evaluators,runs}. Single-segment sub-paths under the one `evals` head; the
|
||||
// Evals facade (cloud clients/eval): /v1/evals/{scores,datasets[/:name/items],
|
||||
// rubrics,evaluators,runs}. Single-segment sub-paths under the one `evals` head; the
|
||||
// facade resolves the console project key pair from the request tenant (the
|
||||
// Bearer owner), so routing it through /v1 gives correct per-org scoping —
|
||||
// the same reason it must NOT be a cookie-only same-origin call (that 403s).
|
||||
@@ -206,11 +206,13 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
// handler resolves the org from the Bearer owner (X-Org-Id) and 403s a cookie-only
|
||||
// call, so it routes through /v1 like the rest of the surface.
|
||||
'projects',
|
||||
// PaaS control plane (cloud clients/platform): /v1/platform/{projects,projects/:p/
|
||||
// apps,apps/:a/deploy,.../deployments,.../deployments/:id/logs,health}. Per-org
|
||||
// container-app platform on Base/SQLite; SanitizeIdentity resolves the org from the
|
||||
// Bearer owner and 403s a cookie-only call, so it routes through /v1 like the
|
||||
// rest — the single `platform` head admits every project/app/deployment sub-path.
|
||||
// The platform control plane (cloud clients/platform): /v1/platform/{projects,
|
||||
// projects/:p/apps,.../deploy,.../deployments,.../deployments/:id/logs,fleet,health}.
|
||||
// Per-org container-app platform on Base/SQLite; SanitizeIdentity resolves the org
|
||||
// from the Bearer owner and 403s a cookie-only call, so it routes through /v1 like
|
||||
// the rest — the single `platform` head admits every sub-path, including the
|
||||
// operator fleet board at /v1/platform/fleet (folded in from the retired /v1/paas:
|
||||
// paas was a second name for platform, and one product gets one name).
|
||||
'platform',
|
||||
// SBOM datastore (cloud clients/sbom): /v1/sbom/{ref} — the software bill of
|
||||
// materials CI recorded for an image ref/digest (components + licenses). The
|
||||
@@ -238,9 +240,9 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'fleet',
|
||||
'clusters',
|
||||
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
|
||||
// (/v1/vpcs[/:id], /v1/load-balancers[/:id]).
|
||||
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
|
||||
'vpcs',
|
||||
'load-balancers',
|
||||
'balancers',
|
||||
// (`dns` — the managed-DNS head — is declared ONCE above, with the data resources.)
|
||||
// Platform aggregates (read-only, derived): deploy targets, CI pipelines, image/
|
||||
// binary builds, and versioned releases (/v1/{environments,pipelines,builds,releases}).
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('accessClaims', () => {
|
||||
|
||||
describe('sealSession (identity + refresh, browser-safe)', () => {
|
||||
it('seals a SMALL identity cookie (projected claims, NOT the ~KB access JWT)', () => {
|
||||
const bigAccess = fakeJwt({ ...Z_CLAIMS, blob: 'x'.repeat(6000) }) // simulate a fat Casdoor token
|
||||
const bigAccess = fakeJwt({ ...Z_CLAIMS, blob: 'x'.repeat(6000) }) // simulate a fat IAM token
|
||||
const r = sealSession({ accessToken: bigAccess, refreshToken: 'rt', expiresIn: 3600 })
|
||||
expect(r?.expiresInMs).toBe(3600_000)
|
||||
expect(r?.claims.name).toBe('z')
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* (proactively before expiry + reactively on a 401) via `grant_type=refresh_token`.
|
||||
* The casibase cookie stays exactly as-is underneath as the graceful FALLBACK.
|
||||
*
|
||||
* TWO COOKIES, by necessity (Casdoor tokens are ~3.6 KB full-user JWTs):
|
||||
* TWO COOKIES, by necessity (IAM tokens are ~3.6 KB full-user JWTs):
|
||||
* - `hz_session` (Path=/, ~1 KB): the SEALED IDENTITY — {access-token expiry +
|
||||
* the small projected claims the console reads}. resolveUser + every BFF proxy +
|
||||
* /auth/session GET read this; it is small, so it rides every request with no
|
||||
@@ -30,7 +30,7 @@
|
||||
* - The refresh token NEVER reaches the browser's JS (httpOnly) and the refresh call
|
||||
* is server-side (BFF). Sealed at rest in the cookie; rotation-aware (IAM rotates
|
||||
* the refresh token one-time-use — we always persist the NEW one; a replay 400s).
|
||||
* - The IAM access token (a Casdoor JWT that packs secret material — password hash,
|
||||
* - The IAM access token (a JWT that packs secret material — password hash,
|
||||
* TOTP secret) is NEVER stored in a cookie, NEVER logged, NEVER returned to the
|
||||
* client: `sealSession` projects it to the small display/authz claim set and
|
||||
* discards the raw token.
|
||||
@@ -99,7 +99,7 @@ const cookie = (name: string, value: string, path: string, maxAge: number): Cook
|
||||
maxAge,
|
||||
})
|
||||
|
||||
/** Split a sealed string into ≤CHUNK_BYTES pieces (Casdoor tokens exceed one cookie). */
|
||||
/** Split a sealed string into ≤CHUNK_BYTES pieces (IAM tokens exceed one cookie). */
|
||||
function chunk(s: string): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < s.length; i += CHUNK_BYTES) out.push(s.slice(i, i + CHUNK_BYTES))
|
||||
@@ -182,7 +182,7 @@ export function open<T>(sealed: string | undefined | null): T | null {
|
||||
|
||||
// ── Identity + refresh (what the cookies carry) ──────────────────────────────────
|
||||
|
||||
/** The console-JWT claims the console reads. Casdoor packs the full user object; we
|
||||
/** The console-JWT claims the console reads. IAM packs the full user object; we
|
||||
* extract ONLY display/authz fields — never the secret material it also carries. */
|
||||
export type ConsoleClaims = {
|
||||
owner?: string
|
||||
@@ -491,7 +491,7 @@ export function durableSessionClientId(host?: string | null): string | null {
|
||||
}
|
||||
|
||||
/** Project session claims to the client-facing Account (display + admin fields only —
|
||||
* never the secret material Casdoor also packs). `owner === 'admin'` implies
|
||||
* never the secret material IAM also packs). `owner === 'admin'` implies
|
||||
* SuperAdmin. Shared by /auth/session (GET) and /auth/signin. */
|
||||
export function accountOf(c: ConsoleClaims): Account {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user