@hanzo/iam 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/billing.ts CHANGED
@@ -1,205 +1,19 @@
1
1
  /**
2
- * @hanzo/iam/billing — Billing client for Hanzo Commerce API.
2
+ * @hanzo/iam/billing — REMOVED
3
3
  *
4
- * Canonical billing lives in commerce.js/billing. This provides the same
5
- * client for convenience when @hanzo/iam is already installed.
6
- * Both talk to Commerce API — one way to do billing.
4
+ * Billing has moved to @hanzo/commerce (or commerce.js).
7
5
  *
8
- * @example
9
6
  * ```ts
10
- * // Preferred:
11
- * import { BillingClient } from 'commerce.js/billing'
12
- *
13
- * // Also works:
14
- * import { BillingClient } from '@hanzo/iam/billing'
7
+ * // Use this instead:
8
+ * import { Commerce } from '@hanzo/commerce'
9
+ * const commerce = new Commerce({ commerceUrl: '...' })
10
+ * await commerce.getBalance(userId)
15
11
  * ```
12
+ *
13
+ * @deprecated This module is no longer functional. Use @hanzo/commerce.
16
14
  */
17
15
 
18
- const DEFAULT_TIMEOUT_MS = 10_000;
19
-
20
- // ---------------------------------------------------------------------------
21
- // Config
22
- // ---------------------------------------------------------------------------
23
-
24
- export type CommerceConfig = {
25
- /** Commerce API base URL (e.g. "https://commerce.hanzo.ai"). */
26
- commerceUrl: string;
27
- /** Optional IAM access token for authenticated requests. */
28
- token?: string;
29
- };
30
-
31
- // ---------------------------------------------------------------------------
32
- // Types
33
- // ---------------------------------------------------------------------------
34
-
35
- export type Balance = {
36
- balance: number;
37
- holds: number;
38
- available: number;
39
- };
40
-
41
- export type Transaction = {
42
- id?: string;
43
- type: "hold" | "hold-removed" | "transfer" | "deposit" | "withdraw";
44
- currency: string;
45
- amount: number;
46
- tags?: string[];
47
- expiresAt?: string;
48
- metadata?: Record<string, unknown>;
49
- createdAt?: string;
50
- };
51
-
52
- export type Subscription = {
53
- id?: string;
54
- planId?: string;
55
- userId?: string;
56
- status?: string;
57
- billingType?: string;
58
- periodStart?: string;
59
- periodEnd?: string;
60
- createdAt?: string;
61
- };
62
-
63
- export type Plan = {
64
- slug?: string;
65
- name?: string;
66
- description?: string;
67
- price?: number;
68
- currency?: string;
69
- interval?: string;
70
- metadata?: Record<string, unknown>;
71
- };
72
-
73
- export type Payment = {
74
- id?: string;
75
- orderId?: string;
76
- amount?: number;
77
- currency?: string;
78
- status?: string;
79
- captured?: boolean;
80
- createdAt?: string;
81
- };
82
-
83
- // ---------------------------------------------------------------------------
84
- // Client
85
- // ---------------------------------------------------------------------------
86
-
87
- export class BillingClient {
88
- private readonly baseUrl: string;
89
- private token: string | undefined;
90
-
91
- constructor(config: CommerceConfig) {
92
- this.baseUrl = config.commerceUrl.replace(/\/+$/, "");
93
- this.token = config.token;
94
- }
95
-
96
- setToken(token: string) {
97
- this.token = token;
98
- }
99
-
100
- private async request<T>(
101
- path: string,
102
- opts?: { method?: string; body?: unknown; token?: string; params?: Record<string, string> },
103
- ): Promise<T> {
104
- const url = new URL(path, this.baseUrl);
105
- if (opts?.params) {
106
- for (const [k, v] of Object.entries(opts.params)) url.searchParams.set(k, v);
107
- }
108
-
109
- const controller = new AbortController();
110
- const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
111
-
112
- const headers: Record<string, string> = { Accept: "application/json" };
113
- const authToken = opts?.token ?? this.token;
114
- if (authToken) headers.Authorization = `Bearer ${authToken}`;
115
- if (opts?.body) headers["Content-Type"] = "application/json";
116
-
117
- try {
118
- const res = await fetch(url.toString(), {
119
- method: opts?.method ?? "GET",
120
- headers,
121
- body: opts?.body ? JSON.stringify(opts.body) : undefined,
122
- signal: controller.signal,
123
- });
124
- if (!res.ok) {
125
- const text = await res.text().catch(() => "");
126
- throw new CommerceApiError(res.status, `${res.statusText}: ${text}`.trim());
127
- }
128
- return (await res.json()) as T;
129
- } finally {
130
- clearTimeout(timer);
131
- }
132
- }
133
-
134
- async getBalance(user: string, currency = "usd", token?: string): Promise<Balance> {
135
- return this.request("/api/v1/billing/balance", { params: { user, currency }, token });
136
- }
137
-
138
- async getAllBalances(user: string, token?: string): Promise<Record<string, Balance>> {
139
- return this.request("/api/v1/billing/balance/all", { params: { user }, token });
140
- }
141
-
142
- async addUsageRecord(record: { user: string; currency?: string; amount: number; model?: string; provider?: string; tokens?: number }, token?: string): Promise<Transaction> {
143
- return this.request("/api/v1/billing/usage", { method: "POST", body: record, token });
144
- }
145
-
146
- async getUsageRecords(user: string, currency = "usd", token?: string): Promise<Transaction[]> {
147
- return this.request("/api/v1/billing/usage", { params: { user, currency }, token });
148
- }
149
-
150
- async addDeposit(params: { user: string; currency?: string; amount: number; notes?: string; tags?: string[]; expiresIn?: string }, token?: string): Promise<Transaction> {
151
- return this.request("/api/v1/billing/deposit", { method: "POST", body: params, token });
152
- }
153
-
154
- async grantStarterCredit(user: string, token?: string): Promise<Transaction> {
155
- return this.request("/api/v1/billing/credit", { method: "POST", body: { user }, token });
156
- }
157
-
158
- async subscribe(params: { planId: string; userId: string }, token?: string): Promise<Subscription> {
159
- return this.request("/api/v1/subscribe", { method: "POST", body: params, token });
160
- }
161
-
162
- async getSubscription(id: string, token?: string): Promise<Subscription | null> {
163
- try { return await this.request(`/api/v1/subscribe/${id}`, { token }); } catch { return null; }
164
- }
165
-
166
- async cancelSubscription(id: string, token?: string): Promise<void> {
167
- await this.request(`/api/v1/subscribe/${id}`, { method: "DELETE", token });
168
- }
169
-
170
- async getPlans(token?: string): Promise<Plan[]> {
171
- return this.request("/api/v1/plan", { token });
172
- }
173
-
174
- async getPlan(id: string, token?: string): Promise<Plan | null> {
175
- try { return await this.request(`/api/v1/plan/${id}`, { token }); } catch { return null; }
176
- }
177
-
178
- async authorize(orderId: string, token?: string): Promise<Payment> {
179
- return this.request(`/api/v1/authorize/${orderId}`, { method: "POST", token });
180
- }
181
-
182
- async capture(orderId: string, token?: string): Promise<Payment> {
183
- return this.request(`/api/v1/capture/${orderId}`, { method: "POST", token });
184
- }
185
-
186
- async charge(orderId: string, token?: string): Promise<Payment> {
187
- return this.request(`/api/v1/charge/${orderId}`, { method: "POST", token });
188
- }
189
-
190
- async refund(paymentId: string, token?: string): Promise<Payment> {
191
- return this.request(`/api/v1/refund/${paymentId}`, { method: "POST", token });
192
- }
193
- }
194
-
195
- export class CommerceApiError extends Error {
196
- readonly status: number;
197
- constructor(status: number, message: string) {
198
- super(message);
199
- this.name = "CommerceApiError";
200
- this.status = status;
201
- }
202
- }
203
-
204
- // Backwards-compatible alias
205
- export { BillingClient as IamBillingClient };
16
+ throw new Error(
17
+ '@hanzo/iam/billing has been removed. Use @hanzo/commerce or commerce.js instead. ' +
18
+ 'See: https://docs.hanzo.ai/services/commerce/sdk'
19
+ )
package/src/browser.ts CHANGED
@@ -33,6 +33,14 @@ export type BrowserIamConfig = IamConfig & {
33
33
  scope?: string;
34
34
  /** Storage to use for tokens (default: sessionStorage). */
35
35
  storage?: Storage;
36
+ /**
37
+ * Proxy base URL for token exchange and userinfo requests.
38
+ * When set, token exchange POSTs go to `${proxyBaseUrl}/auth/token`
39
+ * and userinfo GETs go to `${proxyBaseUrl}/auth/userinfo` instead of
40
+ * directly to the IAM server. This avoids CORS issues when the IAM
41
+ * server doesn't send Access-Control-Allow-Origin headers.
42
+ */
43
+ proxyBaseUrl?: string;
36
44
  };
37
45
 
38
46
  export class BrowserIamSdk {
@@ -53,13 +61,32 @@ export class BrowserIamSdk {
53
61
  if (this.discoveryCache) return this.discoveryCache;
54
62
 
55
63
  const baseUrl = this.config.serverUrl.replace(/\/+$/, "");
56
- const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
57
- headers: { Accept: "application/json" },
58
- });
59
- if (!res.ok) {
60
- throw new Error(`OIDC discovery failed: ${res.status}`);
64
+
65
+ // Try fetching the OIDC discovery document. If it fails (e.g. due to
66
+ // CORS when the IAM server doesn't send Access-Control-Allow-Origin),
67
+ // construct a fallback from well-known Casdoor/Hanzo IAM endpoint paths.
68
+ try {
69
+ const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
70
+ headers: { Accept: "application/json" },
71
+ });
72
+ if (res.ok) {
73
+ this.discoveryCache = (await res.json()) as OidcDiscovery;
74
+ return this.discoveryCache;
75
+ }
76
+ } catch {
77
+ // CORS or network error — fall through to constructed discovery
61
78
  }
62
- this.discoveryCache = (await res.json()) as OidcDiscovery;
79
+
80
+ this.discoveryCache = {
81
+ issuer: baseUrl,
82
+ authorization_endpoint: `${baseUrl}/login/oauth/authorize`,
83
+ token_endpoint: `${baseUrl}/api/login/oauth/access_token`,
84
+ userinfo_endpoint: `${baseUrl}/api/userinfo`,
85
+ jwks_uri: `${baseUrl}/.well-known/jwks`,
86
+ response_types_supported: ["code", "token", "id_token"],
87
+ grant_types_supported: ["authorization_code", "implicit", "refresh_token"],
88
+ scopes_supported: ["openid", "email", "profile"],
89
+ };
63
90
  return this.discoveryCache;
64
91
  }
65
92
 
@@ -112,8 +139,6 @@ export class BrowserIamSdk {
112
139
  */
113
140
  async handleCallback(callbackUrl?: string): Promise<TokenResponse> {
114
141
  const url = new URL(callbackUrl ?? window.location.href);
115
- const code = url.searchParams.get("code");
116
- const state = url.searchParams.get("state");
117
142
  const error = url.searchParams.get("error");
118
143
 
119
144
  if (error) {
@@ -121,15 +146,34 @@ export class BrowserIamSdk {
121
146
  throw new Error(`OAuth error: ${desc}`);
122
147
  }
123
148
 
124
- if (!code) {
125
- throw new Error("Missing authorization code in callback URL");
126
- }
127
-
149
+ const state = url.searchParams.get("state");
128
150
  const savedState = this.storage.getItem(KEY_STATE);
129
- if (!savedState || savedState !== state) {
151
+ if (savedState && state !== savedState) {
130
152
  throw new Error("OAuth state mismatch — possible CSRF attack");
131
153
  }
132
154
 
155
+ // Implicit flow: access_token returned directly in URL
156
+ const accessToken = url.searchParams.get("access_token");
157
+ if (accessToken) {
158
+ this.storage.removeItem(KEY_STATE);
159
+ this.storage.removeItem(KEY_CODE_VERIFIER);
160
+
161
+ const tokens: TokenResponse = {
162
+ access_token: accessToken,
163
+ token_type: "Bearer",
164
+ refresh_token: url.searchParams.get("refresh_token") ?? undefined,
165
+ expires_in: 7200,
166
+ };
167
+ this.storeTokens(tokens);
168
+ return tokens;
169
+ }
170
+
171
+ // Authorization code flow: exchange code for tokens via PKCE
172
+ const code = url.searchParams.get("code");
173
+ if (!code) {
174
+ throw new Error("Missing authorization code in callback URL");
175
+ }
176
+
133
177
  const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
134
178
  if (!codeVerifier) {
135
179
  throw new Error("Missing PKCE code verifier — was signinRedirect() called?");
@@ -148,7 +192,12 @@ export class BrowserIamSdk {
148
192
  code_verifier: codeVerifier,
149
193
  });
150
194
 
151
- const res = await fetch(discovery.token_endpoint, {
195
+ // Use proxy URL when configured to avoid CORS on the token endpoint.
196
+ const tokenUrl = this.config.proxyBaseUrl
197
+ ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token`
198
+ : discovery.token_endpoint;
199
+
200
+ const res = await fetch(tokenUrl, {
152
201
  method: "POST",
153
202
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
154
203
  body: body.toString(),
@@ -182,7 +231,11 @@ export class BrowserIamSdk {
182
231
  refresh_token: refreshToken,
183
232
  });
184
233
 
185
- const res = await fetch(discovery.token_endpoint, {
234
+ const tokenUrl = this.config.proxyBaseUrl
235
+ ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token`
236
+ : discovery.token_endpoint;
237
+
238
+ const res = await fetch(tokenUrl, {
186
239
  method: "POST",
187
240
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
188
241
  body: body.toString(),
@@ -417,7 +470,10 @@ export class BrowserIamSdk {
417
470
  throw new Error("No valid access token — user must log in");
418
471
  }
419
472
  const discovery = await this.getDiscovery();
420
- const res = await fetch(discovery.userinfo_endpoint, {
473
+ const userinfoUrl = this.config.proxyBaseUrl
474
+ ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/userinfo`
475
+ : discovery.userinfo_endpoint;
476
+ const res = await fetch(userinfoUrl, {
421
477
  headers: { Authorization: `Bearer ${token}` },
422
478
  });
423
479
  if (!res.ok) {
package/src/index.ts CHANGED
@@ -22,9 +22,8 @@
22
22
  // Core client (auth, users, orgs, projects → IAM)
23
23
  export { IamClient, IamApiError } from "./client.js";
24
24
 
25
- // Billing client (subscriptions, plans, payments, usage Commerce API)
26
- // Canonical source: commerce.js/billing. Re-exported here for convenience.
27
- export { BillingClient, IamBillingClient, CommerceApiError } from "./billing.js";
25
+ // Billing has moved to @hanzo/commerce. Import Commerce from "@hanzo/commerce" instead.
26
+ // See: https://docs.hanzo.ai/services/commerce/sdk
28
27
 
29
28
  // JWT validation
30
29
  export { validateToken, clearJwksCache } from "./auth.js";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Passport.js OAuth2 strategy factory for Hanzo IAM.
3
+ *
4
+ * Creates a pre-configured passport-oauth2 strategy that authenticates
5
+ * against hanzo.id with PKCE and fetches user info on callback.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import passport from "passport";
10
+ * import { createIamPassportStrategy } from "@hanzo/iam/passport";
11
+ *
12
+ * passport.use("iam", createIamPassportStrategy({
13
+ * serverUrl: "https://hanzo.id",
14
+ * clientId: "hanzo-kms-client-id",
15
+ * clientSecret: process.env.IAM_CLIENT_SECRET!,
16
+ * callbackUrl: "https://kms.hanzo.ai/api/v1/sso/oidc/callback",
17
+ * }));
18
+ * ```
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import type { IamConfig } from "./types.js";
24
+
25
+ export interface IamPassportConfig extends IamConfig {
26
+ /** Full callback URL for OAuth2 redirect. */
27
+ callbackUrl: string;
28
+ /** OAuth2 scopes. Default: "openid profile email". */
29
+ scope?: string;
30
+ }
31
+
32
+ export interface IamPassportUser {
33
+ accessToken: string;
34
+ refreshToken?: string;
35
+ userinfo: Record<string, unknown>;
36
+ }
37
+
38
+ /**
39
+ * Create a Passport OAuth2 strategy for Hanzo IAM.
40
+ *
41
+ * Requires `passport-oauth2` as a peer dependency.
42
+ * Returns an OAuth2Strategy instance ready to pass to `passport.use()`.
43
+ *
44
+ * The verify callback fetches userinfo from the IAM server and passes
45
+ * `{ accessToken, refreshToken, userinfo }` as the user object.
46
+ */
47
+ export function createIamPassportStrategy(
48
+ config: IamPassportConfig,
49
+ ): unknown {
50
+ // Dynamic import to keep passport-oauth2 as optional peer dep.
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const { Strategy: OAuth2Strategy } = require("passport-oauth2") as {
53
+ Strategy: new (
54
+ options: Record<string, unknown>,
55
+ verify: (...args: unknown[]) => void,
56
+ ) => unknown;
57
+ };
58
+
59
+ const baseUrl = config.serverUrl.replace(/\/+$/, "");
60
+
61
+ const verify = async (
62
+ ...args: unknown[]
63
+ ): Promise<void> => {
64
+ // passReqToCallback=true: (req, accessToken, refreshToken, profile, done)
65
+ const accessToken = args[1] as string;
66
+ const refreshToken = args[2] as string | undefined;
67
+ const done = args[4] as (err: Error | null, user?: IamPassportUser) => void;
68
+
69
+ try {
70
+ const res = await fetch(`${baseUrl}/api/userinfo`, {
71
+ headers: { Authorization: `Bearer ${accessToken}` },
72
+ });
73
+ if (!res.ok) {
74
+ return done(new Error(`IAM userinfo failed: ${res.status}`));
75
+ }
76
+ const userinfo = (await res.json()) as Record<string, unknown>;
77
+ done(null, { accessToken, refreshToken, userinfo });
78
+ } catch (err) {
79
+ done(err instanceof Error ? err : new Error(String(err)));
80
+ }
81
+ };
82
+
83
+ return new OAuth2Strategy(
84
+ {
85
+ authorizationURL: `${baseUrl}/login/oauth/authorize`,
86
+ tokenURL: `${baseUrl}/api/login/oauth/access_token`,
87
+ clientID: config.clientId,
88
+ clientSecret: config.clientSecret ?? "",
89
+ callbackURL: config.callbackUrl,
90
+ scope: config.scope ?? "openid profile email",
91
+ state: true,
92
+ pkce: true,
93
+ passReqToCallback: true,
94
+ },
95
+ verify,
96
+ );
97
+ }
package/src/react.ts CHANGED
@@ -591,3 +591,129 @@ export function useIamToken(): {
591
591
 
592
592
  // Re-export context for advanced use
593
593
  export { IamContext };
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // OrgProjectSwitcher component
597
+ // ---------------------------------------------------------------------------
598
+
599
+ export interface OrgProjectSwitcherProps {
600
+ organizations: Array<{ name: string; displayName?: string; owner?: string }>;
601
+ currentOrgId: string | null;
602
+ switchOrg: (orgId: string) => void;
603
+ projects?: Array<{ name: string; displayName?: string; organization?: string; isDefault?: boolean }>;
604
+ currentProjectId?: string | null;
605
+ switchProject?: (projectId: string | null) => void;
606
+ onTenantChange?: (orgId: string | null, projectId: string | null) => void;
607
+ environment?: string | null;
608
+ className?: string;
609
+ alwaysShow?: boolean;
610
+ }
611
+
612
+ /**
613
+ * Organization and project switcher component.
614
+ *
615
+ * @example
616
+ * ```tsx
617
+ * import { useOrganizations, OrgProjectSwitcher } from '@hanzo/iam/react'
618
+ *
619
+ * function Nav() {
620
+ * const orgState = useOrganizations()
621
+ * return <OrgProjectSwitcher {...orgState} />
622
+ * }
623
+ * ```
624
+ */
625
+ export function OrgProjectSwitcher({
626
+ organizations,
627
+ currentOrgId,
628
+ switchOrg,
629
+ projects = [],
630
+ currentProjectId = null,
631
+ switchProject,
632
+ onTenantChange,
633
+ environment,
634
+ className = "",
635
+ alwaysShow = false,
636
+ }: OrgProjectSwitcherProps) {
637
+ useEffect(() => {
638
+ onTenantChange?.(currentOrgId, currentProjectId ?? null);
639
+ }, [currentOrgId, currentProjectId, onTenantChange]);
640
+
641
+ const handleOrgChange = useCallback(
642
+ (e: { target: { value: string } }) => switchOrg(e.target.value),
643
+ [switchOrg],
644
+ );
645
+
646
+ const handleProjectChange = useCallback(
647
+ (e: { target: { value: string } }) => switchProject?.(e.target.value || null),
648
+ [switchProject],
649
+ );
650
+
651
+ if (!alwaysShow && organizations.length <= 1 && projects.length <= 1) {
652
+ if (organizations.length === 1) {
653
+ const org = organizations[0];
654
+ return createElement(
655
+ "div",
656
+ { className: `flex items-center gap-2 text-sm ${className}` },
657
+ createElement("span", { className: "font-medium" }, org.displayName || org.name),
658
+ projects.length === 1
659
+ ? [
660
+ createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
661
+ createElement("span", { key: "proj" }, projects[0].displayName || projects[0].name),
662
+ ]
663
+ : null,
664
+ environment
665
+ ? createElement(
666
+ "span",
667
+ { className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
668
+ environment,
669
+ )
670
+ : null,
671
+ );
672
+ }
673
+ return null;
674
+ }
675
+
676
+ return createElement(
677
+ "div",
678
+ { className: `flex items-center gap-2 ${className}` },
679
+ createElement(
680
+ "select",
681
+ {
682
+ value: currentOrgId ?? "",
683
+ onChange: handleOrgChange,
684
+ className:
685
+ "h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
686
+ "aria-label": "Switch organization",
687
+ },
688
+ ...organizations.map((org) =>
689
+ createElement("option", { key: org.name, value: org.name }, org.displayName || org.name),
690
+ ),
691
+ ),
692
+ projects.length > 0 && switchProject
693
+ ? [
694
+ createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
695
+ createElement(
696
+ "select",
697
+ {
698
+ key: "proj-select",
699
+ value: currentProjectId ?? "",
700
+ onChange: handleProjectChange,
701
+ className:
702
+ "h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
703
+ "aria-label": "Switch project",
704
+ },
705
+ ...projects.map((proj) =>
706
+ createElement("option", { key: proj.name, value: proj.name }, proj.displayName || proj.name),
707
+ ),
708
+ ),
709
+ ]
710
+ : null,
711
+ environment
712
+ ? createElement(
713
+ "span",
714
+ { className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
715
+ environment,
716
+ )
717
+ : null,
718
+ );
719
+ }