Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9531bf4e3 | ||
|
|
46daa52d43 | ||
|
|
780757ca55 | ||
|
|
5bf8d934b4 | ||
|
|
81db870a51 |
@@ -30,6 +30,9 @@ jobs:
|
||||
- name: Test AI package
|
||||
run: pnpm --filter @hanzo/ai test
|
||||
|
||||
- name: Test auth core
|
||||
run: pnpm --filter @hanzo/auth test
|
||||
|
||||
- name: Test Office add-in
|
||||
run: pnpm --filter @hanzo/office-addin test
|
||||
|
||||
|
||||
@@ -154,6 +154,29 @@ jobs:
|
||||
-derivedDataPath dist/safari-build-ios
|
||||
continue-on-error: true
|
||||
|
||||
# Package the built .app(s) so the GitHub Release Safari link resolves — the
|
||||
# unsigned bundle a developer sideloads (source + built products).
|
||||
- name: Package Safari for release
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
cd dist
|
||||
# Prefer the built .app products; fall back to the Xcode source project.
|
||||
APPS=$(find safari-build-macos safari-build-ios -name '*.app' -type d 2>/dev/null || true)
|
||||
if [ -n "$APPS" ]; then
|
||||
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $APPS
|
||||
else
|
||||
zip -r "/tmp/hanzo-ai-safari-v${VERSION}.zip" "safari/Hanzo AI"
|
||||
fi
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Safari bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-safari
|
||||
path: /tmp/hanzo-ai-safari-v*.zip
|
||||
continue-on-error: true
|
||||
|
||||
# ─── VS Code + Cursor + Windsurf + Antigravity + Open VSX ───
|
||||
vscode:
|
||||
name: VS Code Marketplace
|
||||
@@ -243,7 +266,6 @@ jobs:
|
||||
name: JetBrains
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
if: needs.secrets.outputs.has-jetbrains == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
@@ -251,18 +273,64 @@ jobs:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build and publish
|
||||
# Always build the downloadable plugin zip so the GitHub Release link works
|
||||
# even when the Marketplace token is absent (publish below is token-gated).
|
||||
- name: Build plugin
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew buildPlugin
|
||||
|
||||
- name: Package for release
|
||||
working-directory: packages/jetbrains
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
ZIP=$(ls build/distributions/*.zip | head -1)
|
||||
cp "$ZIP" "/tmp/hanzo-ai-jetbrains-v${VERSION}.zip"
|
||||
|
||||
- name: Upload JetBrains plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-jetbrains
|
||||
path: /tmp/hanzo-ai-jetbrains-v*.zip
|
||||
|
||||
- name: Publish to JetBrains Marketplace
|
||||
if: needs.secrets.outputs.has-jetbrains == 'true'
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew publishPlugin
|
||||
env:
|
||||
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── Microsoft Office add-in (Word / Excel / PowerPoint) ───
|
||||
office:
|
||||
name: Office Add-in
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/office-addin build
|
||||
cd packages/office/dist && npx --yes bestzip "/tmp/hanzo-ai-office-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Office add-in
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-office
|
||||
path: /tmp/hanzo-ai-office-v*.zip
|
||||
|
||||
# ─── GitHub Release with downloadable artifacts ───
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [browser, safari, vscode]
|
||||
needs: [browser, safari, vscode, jetbrains, office]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -291,6 +359,14 @@ jobs:
|
||||
| **Claude Desktop/Code** | Windows / macOS / Linux | [`hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt) |
|
||||
| **JetBrains** | Windows / macOS / Linux | [`hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
## Microsoft Office
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
|
||||
|
||||
## Browser Extensions
|
||||
|
||||
| Browser | Platform | Download |
|
||||
@@ -308,3 +384,6 @@ jobs:
|
||||
files: |
|
||||
artifacts/hanzo-ai-browser/*
|
||||
artifacts/hanzo-ai-ide/*
|
||||
artifacts/hanzo-ai-jetbrains/*
|
||||
artifacts/hanzo-ai-office/*
|
||||
artifacts/hanzo-ai-safari/*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.28",
|
||||
"version": "1.9.29",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hanzo/auth",
|
||||
"version": "1.0.0",
|
||||
"description": "The one Hanzo IAM auth core — canonical HIP-0111 OAuth2 + PKCE, shared by every Hanzo extension (browser, office, vscode, cli, mcp).",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// The ONE definition of Hanzo IAM endpoints and the per-surface OAuth client
|
||||
// registry. Every Hanzo extension (browser, office, vscode, cli, mcp) resolves
|
||||
// its auth URLs HERE — so a path can never drift between five hand-rolled
|
||||
// copies again (the vscode add-in shipped `hanzo.id/oauth/authorize`, missing
|
||||
// the /v1/iam prefix, for exactly that reason).
|
||||
//
|
||||
// HIP-0111: the canonical OAuth2/OIDC surface is `${issuer}/v1/iam/oauth/*`.
|
||||
// The login UI (authorize) lives on hanzo.id; the token/userinfo API on
|
||||
// iam.hanzo.ai. Both carry the SAME /v1/iam prefix — that is the whole point.
|
||||
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
export const DEFAULT_SCOPES = 'openid profile email offline_access';
|
||||
|
||||
// SurfaceClient is one registered IAM OAuth client — one per product surface, so
|
||||
// tokens, redirect URIs, and audit trails are attributable to the surface that
|
||||
// minted them. redirectUri is where IAM sends the code back; it must be
|
||||
// registered on the IAM client of the same id. Adding a surface is ONE entry
|
||||
// here, never a new copy of the flow.
|
||||
export interface SurfaceClient {
|
||||
id: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
// CLIENTS is the registry. The ids match the applications seeded in Hanzo IAM
|
||||
// (<org>-<app> convention). redirectUris match what those clients allow.
|
||||
export const CLIENTS = {
|
||||
browser: { id: 'hanzo-browser', redirectUri: 'https://hanzo.ai/callback' },
|
||||
office: { id: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html' },
|
||||
vscode: { id: 'hanzo-vscode', redirectUri: 'https://hanzo.ai/callback' },
|
||||
cli: { id: 'hanzo-cli', redirectUri: 'http://127.0.0.1/callback' },
|
||||
} as const satisfies Record<string, SurfaceClient>;
|
||||
|
||||
export type SurfaceName = keyof typeof CLIENTS;
|
||||
|
||||
// URL builders — the only place these paths are assembled. issuer defaults to
|
||||
// the login host for authorize and the API host for token/userinfo, but both
|
||||
// are overridable (self-hosted brands, tests) via the opts.
|
||||
|
||||
export function authorizeURL(issuer: string = IAM_LOGIN_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/authorize`;
|
||||
}
|
||||
export function tokenURL(issuer: string = IAM_API_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/token`;
|
||||
}
|
||||
export function userinfoURL(issuer: string = IAM_API_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/userinfo`;
|
||||
}
|
||||
|
||||
function trimEnd(s: string): string {
|
||||
return s.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// The auth FLOW — written exactly once, against TokenStore + Opener. This is the
|
||||
// decomplected heart: browser / office / vscode / cli call these functions with
|
||||
// their own adapter and get identical login, refresh, and session semantics. No
|
||||
// surface re-implements PKCE, endpoint assembly, code exchange, or refresh.
|
||||
|
||||
import { createPkce } from './pkce.js';
|
||||
import {
|
||||
buildAuthorizeURL, exchangeCode, refreshTokens, fetchUserInfo, parseCallback,
|
||||
type UserInfo,
|
||||
} from './oauth.js';
|
||||
import type { TokenStore, TokenSet, Opener } from './types.js';
|
||||
|
||||
export interface AuthClient {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
loginIssuer?: string;
|
||||
apiIssuer?: string;
|
||||
}
|
||||
|
||||
// EXPIRY_SKEW_MS refreshes a token slightly before it truly expires, so a call
|
||||
// never races the deadline.
|
||||
const EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
// login runs the full interactive authorization-code + PKCE flow and persists
|
||||
// the tokens. Returns the TokenSet. Throws on user-cancel, state mismatch, an
|
||||
// IAM error param, or a failed exchange — the surface shows the message.
|
||||
export async function login(client: AuthClient, store: TokenStore, opener: Opener): Promise<TokenSet> {
|
||||
const pkce = await createPkce();
|
||||
const authorizeUrl = buildAuthorizeURL({
|
||||
clientId: client.clientId,
|
||||
redirectUri: client.redirectUri,
|
||||
challenge: pkce.challenge,
|
||||
state: pkce.state,
|
||||
scope: client.scope,
|
||||
loginIssuer: client.loginIssuer,
|
||||
});
|
||||
|
||||
const redirectUrl = await opener.open(authorizeUrl, client.redirectUri);
|
||||
const cb = parseCallback(redirectUrl);
|
||||
if (cb.error) throw new Error(`sign-in failed: ${cb.error}`);
|
||||
if (!cb.code) throw new Error('sign-in returned no authorization code');
|
||||
if (cb.state !== pkce.state) throw new Error('sign-in state mismatch (possible CSRF) — try again');
|
||||
|
||||
const tokens = await exchangeCode({
|
||||
clientId: client.clientId,
|
||||
code: cb.code,
|
||||
verifier: pkce.verifier,
|
||||
redirectUri: client.redirectUri,
|
||||
apiIssuer: client.apiIssuer,
|
||||
});
|
||||
await store.set(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// isExpired reports whether a TokenSet is at/near its deadline. A token with no
|
||||
// expiresAt is treated as non-expiring (the surface still validates server-side
|
||||
// on 401). Pure.
|
||||
export function isExpired(tokens: TokenSet | null, now: number = Date.now()): boolean {
|
||||
if (!tokens) return true;
|
||||
if (tokens.expiresAt === undefined) return false;
|
||||
return now >= tokens.expiresAt - EXPIRY_SKEW_MS;
|
||||
}
|
||||
|
||||
// getValidToken returns a usable access token, transparently refreshing an
|
||||
// expired one when a refresh_token is present. Returns '' when there is no
|
||||
// session (never signed in) or the refresh failed — the caller then either runs
|
||||
// login() or proceeds anonymously. This is the ONE function every surface calls
|
||||
// before an authenticated request.
|
||||
export async function getValidToken(client: AuthClient, store: TokenStore): Promise<string> {
|
||||
const current = await store.get();
|
||||
if (!current) return '';
|
||||
if (!isExpired(current)) return current.accessToken;
|
||||
if (!current.refreshToken) return current.accessToken; // no refresh path; let the server 401
|
||||
try {
|
||||
const next = await refreshTokens({
|
||||
clientId: client.clientId,
|
||||
refreshToken: current.refreshToken,
|
||||
apiIssuer: client.apiIssuer,
|
||||
});
|
||||
await store.set(next);
|
||||
return next.accessToken;
|
||||
} catch {
|
||||
return ''; // refresh failed (revoked/expired) — surface re-prompts login
|
||||
}
|
||||
}
|
||||
|
||||
// logout clears the stored session.
|
||||
export async function logout(store: TokenStore): Promise<void> {
|
||||
await store.clear();
|
||||
}
|
||||
|
||||
// currentUser returns the signed-in identity for onboarding UIs, or null when
|
||||
// there is no valid session. Refreshes if needed.
|
||||
export async function currentUser(client: AuthClient, store: TokenStore): Promise<UserInfo | null> {
|
||||
const token = await getValidToken(client, store);
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await fetchUserInfo(token, client.apiIssuer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// @hanzo/auth — the ONE Hanzo IAM auth core. Every extension (browser, office,
|
||||
// vscode, cli, mcp) imports from here: canonical HIP-0111 endpoints, PKCE, the
|
||||
// OAuth2 authorization-code + PKCE flow, refresh, and userinfo. A surface
|
||||
// supplies only a TokenStore (persistence) and an Opener (the interactive leg);
|
||||
// the flow itself lives once, in flow.ts.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './pkce.js';
|
||||
export * from './oauth.js';
|
||||
export * from './flow.js';
|
||||
export type { TokenSet, TokenStore, Opener } from './types.js';
|
||||
|
||||
// Convenience: resolve a surface's AuthClient from the CLIENTS registry so a
|
||||
// caller writes `authClientFor('office')` instead of assembling ids/redirects.
|
||||
import { CLIENTS, type SurfaceName } from './config.js';
|
||||
import type { AuthClient } from './flow.js';
|
||||
|
||||
export function authClientFor(surface: SurfaceName, overrides: Partial<AuthClient> = {}): AuthClient {
|
||||
const c = CLIENTS[surface];
|
||||
return { clientId: c.id, redirectUri: c.redirectUri, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// The OAuth2/OIDC HTTP calls — pure functions over `fetch`. No storage, no UI,
|
||||
// no runtime specifics. Every authorize URL and token exchange in the Hanzo
|
||||
// extension family is built HERE, against config.ts, so HIP-0111 paths are
|
||||
// assembled exactly once.
|
||||
|
||||
import { authorizeURL, tokenURL, userinfoURL, DEFAULT_SCOPES, IAM_LOGIN_URL, IAM_API_URL } from './config.js';
|
||||
import type { TokenSet } from './types.js';
|
||||
|
||||
export interface AuthorizeParams {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
scope?: string;
|
||||
loginIssuer?: string;
|
||||
}
|
||||
|
||||
// buildAuthorizeURL assembles the authorization-code + PKCE (S256) URL.
|
||||
export function buildAuthorizeURL(p: AuthorizeParams): string {
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: p.clientId,
|
||||
redirect_uri: p.redirectUri,
|
||||
scope: p.scope || DEFAULT_SCOPES,
|
||||
state: p.state,
|
||||
code_challenge: p.challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${authorizeURL(p.loginIssuer || IAM_LOGIN_URL)}?${q.toString()}`;
|
||||
}
|
||||
|
||||
// normalizeTokens converts an OIDC token response into our TokenSet, turning the
|
||||
// relative expires_in into an absolute expiresAt deadline (now-based).
|
||||
export function normalizeTokens(raw: any, now: number = Date.now()): TokenSet {
|
||||
if (!raw || typeof raw.access_token !== 'string' || !raw.access_token) {
|
||||
throw new Error('token response missing access_token');
|
||||
}
|
||||
const set: TokenSet = { accessToken: raw.access_token };
|
||||
if (raw.refresh_token) set.refreshToken = raw.refresh_token;
|
||||
if (raw.id_token) set.idToken = raw.id_token;
|
||||
if (raw.token_type) set.tokenType = raw.token_type;
|
||||
if (raw.scope) set.scope = raw.scope;
|
||||
if (typeof raw.expires_in === 'number') set.expiresAt = now + raw.expires_in * 1000;
|
||||
return set;
|
||||
}
|
||||
|
||||
// exchangeCode trades an authorization code for tokens (RFC 6749 §4.1.3 +
|
||||
// PKCE). Form-encoded per the spec — application/json is NOT accepted by IAM.
|
||||
export async function exchangeCode(args: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
verifier: string;
|
||||
redirectUri: string;
|
||||
apiIssuer?: string;
|
||||
}): Promise<TokenSet> {
|
||||
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: args.clientId,
|
||||
code: args.code,
|
||||
code_verifier: args.verifier,
|
||||
redirect_uri: args.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`token exchange failed: ${resp.status} ${await safeText(resp)}`);
|
||||
}
|
||||
return normalizeTokens(await resp.json());
|
||||
}
|
||||
|
||||
// refreshTokens uses a refresh_token to mint a fresh access token. IAM may or
|
||||
// may not rotate the refresh_token; if it omits one we keep the old.
|
||||
export async function refreshTokens(args: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
apiIssuer?: string;
|
||||
}): Promise<TokenSet> {
|
||||
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: args.clientId,
|
||||
refresh_token: args.refreshToken,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`refresh failed: ${resp.status} ${await safeText(resp)}`);
|
||||
}
|
||||
const set = normalizeTokens(await resp.json());
|
||||
if (!set.refreshToken) set.refreshToken = args.refreshToken; // reuse when not rotated
|
||||
return set;
|
||||
}
|
||||
|
||||
// UserInfo is the OIDC userinfo shape the surfaces render for onboarding.
|
||||
export interface UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
org?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export async function fetchUserInfo(accessToken: string, apiIssuer?: string): Promise<UserInfo> {
|
||||
const resp = await fetch(userinfoURL(apiIssuer || IAM_API_URL), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`userinfo failed: ${resp.status}`);
|
||||
const j = await resp.json();
|
||||
return { sub: j.sub, name: j.name, email: j.email, org: j.org || j.owner, picture: j.picture };
|
||||
}
|
||||
|
||||
// parseCallback extracts { code, state, error } from the redirect URL IAM lands
|
||||
// on (the Opener resolves this). Pure.
|
||||
export function parseCallback(redirectUrl: string): { code?: string; state?: string; error?: string } {
|
||||
const qi = redirectUrl.indexOf('?');
|
||||
const params = new URLSearchParams(qi >= 0 ? redirectUrl.slice(qi + 1) : '');
|
||||
return {
|
||||
code: params.get('code') || undefined,
|
||||
state: params.get('state') || undefined,
|
||||
error: params.get('error') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeText(resp: Response): Promise<string> {
|
||||
try {
|
||||
return (await resp.text()).slice(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// PKCE (RFC 7636) — pure crypto over the platform CSPRNG + WebCrypto, so it runs
|
||||
// identically in a browser extension, an Office task pane, VS Code's extension
|
||||
// host, and Node. The ONE PKCE implementation for every Hanzo surface.
|
||||
|
||||
export function base64url(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function randomString(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
(globalThis.crypto as Crypto).getRandomValues(buf);
|
||||
return base64url(buf);
|
||||
}
|
||||
|
||||
// challengeFromVerifier = BASE64URL(SHA256(verifier)) — the S256 method.
|
||||
export async function challengeFromVerifier(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
|
||||
return base64url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export interface Pkce {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function createPkce(): Promise<Pkce> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await challengeFromVerifier(verifier);
|
||||
const state = randomString(16);
|
||||
return { verifier, challenge, state };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// The runtime-agnostic contracts a surface implements. The flow (flow.ts) is
|
||||
// written ONCE against these; browser / office / vscode / cli differ only in the
|
||||
// adapter they pass. This is what makes "one auth, many surfaces" real.
|
||||
|
||||
// TokenSet is the persisted credential — the OIDC token response, normalized.
|
||||
export interface TokenSet {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
// expiresAt is an absolute epoch-ms deadline (NOT the relative expires_in),
|
||||
// so validity is a pure comparison with the clock, storable and portable.
|
||||
expiresAt?: number;
|
||||
tokenType?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
// TokenStore is the per-surface persistence: chrome.storage (browser),
|
||||
// SecretStorage (vscode), roamingSettings (office), a 0600 file (cli). Async so
|
||||
// every backend fits. clear() removes the credential (logout).
|
||||
export interface TokenStore {
|
||||
get(): Promise<TokenSet | null>;
|
||||
set(tokens: TokenSet): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
// Opener drives the interactive leg: open the authorize URL and resolve with the
|
||||
// full redirect URL IAM lands on (carrying ?code=&state=). Browser: a tab +
|
||||
// tabs.onUpdated. Office: the Dialog API. VS Code: env.openExternal + a Uri
|
||||
// handler. CLI: a loopback HTTP server. The flow never knows which.
|
||||
export interface Opener {
|
||||
open(authorizeUrl: string, redirectUri: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { authorizeURL, tokenURL, userinfoURL, CLIENTS, IAM_API_PATH } from '../src/config';
|
||||
import { authClientFor } from '../src/index';
|
||||
|
||||
describe('canonical HIP-0111 endpoints — the drift guard', () => {
|
||||
it('authorize/token/userinfo ALL carry the /v1/iam prefix', () => {
|
||||
// This is the exact bug that shipped in vscode: hanzo.id/oauth/authorize,
|
||||
// missing /v1/iam. Pin every path so it can never regress.
|
||||
expect(authorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(tokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(userinfoURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
|
||||
expect(IAM_API_PATH).toBe('/v1/iam');
|
||||
});
|
||||
|
||||
it('a custom issuer keeps the /v1/iam prefix', () => {
|
||||
expect(authorizeURL('https://id.acme.test')).toBe('https://id.acme.test/v1/iam/oauth/authorize');
|
||||
expect(tokenURL('https://iam.acme.test/')).toBe('https://iam.acme.test/v1/iam/oauth/token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('surface client registry — one client per surface', () => {
|
||||
it('every surface has a distinct hanzo-<surface> id', () => {
|
||||
expect(CLIENTS.browser.id).toBe('hanzo-browser');
|
||||
expect(CLIENTS.office.id).toBe('hanzo-office');
|
||||
expect(CLIENTS.vscode.id).toBe('hanzo-vscode');
|
||||
expect(CLIENTS.cli.id).toBe('hanzo-cli');
|
||||
const ids = Object.values(CLIENTS).map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('authClientFor resolves id + redirect and honors overrides', () => {
|
||||
const c = authClientFor('office');
|
||||
expect(c.clientId).toBe('hanzo-office');
|
||||
expect(c.redirectUri).toBe('https://office.hanzo.ai/auth-callback.html');
|
||||
expect(authClientFor('office', { redirectUri: 'https://localhost:3000/cb' }).redirectUri).toBe(
|
||||
'https://localhost:3000/cb',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { login, getValidToken, logout, isExpired, currentUser } from '../src/flow';
|
||||
import type { TokenStore, TokenSet, Opener } from '../src/types';
|
||||
import { authClientFor } from '../src/index';
|
||||
|
||||
// memStore is the fake per-surface TokenStore — the same seam browser/office/
|
||||
// vscode/cli implement over their real storage.
|
||||
function memStore(initial: TokenSet | null = null): TokenStore & { value: TokenSet | null } {
|
||||
return {
|
||||
value: initial,
|
||||
async get() { return this.value; },
|
||||
async set(t) { this.value = t; },
|
||||
async clear() { this.value = null; },
|
||||
};
|
||||
}
|
||||
|
||||
// fakeOpener returns a canned redirect URL (what IAM would land on).
|
||||
function fakeOpener(redirectUrl: (authorizeUrl: string) => string): Opener {
|
||||
return { async open(authorizeUrl) { return redirectUrl(authorizeUrl); } };
|
||||
}
|
||||
|
||||
const client = authClientFor('office');
|
||||
|
||||
describe('login', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('runs PKCE → authorize → exchange → store', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }) } as any)));
|
||||
const store = memStore();
|
||||
// Echo the state from the authorize URL back in the redirect so it matches.
|
||||
const opener = fakeOpener((authUrl) => {
|
||||
const state = new URL(authUrl).searchParams.get('state');
|
||||
return `https://office.hanzo.ai/auth-callback.html?code=CODE&state=${state}`;
|
||||
});
|
||||
const tokens = await login(client, store, opener);
|
||||
expect(tokens.accessToken).toBe('AT');
|
||||
expect(store.value?.accessToken).toBe('AT');
|
||||
});
|
||||
|
||||
it('rejects a state mismatch (CSRF guard)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
const store = memStore();
|
||||
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?code=CODE&state=WRONG');
|
||||
await expect(login(client, store, opener)).rejects.toThrow(/state mismatch/);
|
||||
expect(store.value).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces an IAM error param', async () => {
|
||||
const store = memStore();
|
||||
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?error=access_denied');
|
||||
await expect(login(client, store, opener)).rejects.toThrow(/access_denied/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('null / past-deadline is expired; no-deadline is not', () => {
|
||||
expect(isExpired(null)).toBe(true);
|
||||
expect(isExpired({ accessToken: 'a' })).toBe(false);
|
||||
expect(isExpired({ accessToken: 'a', expiresAt: 1000 }, 999_999)).toBe(true);
|
||||
expect(isExpired({ accessToken: 'a', expiresAt: 10_000_000 }, 1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidToken', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns the token when fresh (no network)', async () => {
|
||||
const store = memStore({ accessToken: 'FRESH', expiresAt: Date.now() + 3600_000 });
|
||||
expect(await getValidToken(client, store)).toBe('FRESH');
|
||||
});
|
||||
|
||||
it('refreshes transparently when expired', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as any)));
|
||||
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
|
||||
expect(await getValidToken(client, store)).toBe('NEW');
|
||||
expect(store.value?.accessToken).toBe('NEW');
|
||||
expect(store.value?.refreshToken).toBe('RT'); // reused
|
||||
});
|
||||
|
||||
it('returns empty when refresh fails (revoked) so the surface re-prompts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401, text: async () => 'invalid_grant' } as any)));
|
||||
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
|
||||
expect(await getValidToken(client, store)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty when never signed in', async () => {
|
||||
expect(await getValidToken(client, memStore())).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout + currentUser', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('logout clears the store', async () => {
|
||||
const store = memStore({ accessToken: 'a' });
|
||||
await logout(store);
|
||||
expect(store.value).toBeNull();
|
||||
});
|
||||
|
||||
it('currentUser returns null with no session, identity with one', async () => {
|
||||
expect(await currentUser(client, memStore())).toBeNull();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai' }) } as any)));
|
||||
const u = await currentUser(client, memStore({ accessToken: 'AT', expiresAt: Date.now() + 3600_000 }));
|
||||
expect(u?.email).toBe('z@hanzo.ai');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
buildAuthorizeURL, normalizeTokens, exchangeCode, refreshTokens, parseCallback, fetchUserInfo,
|
||||
} from '../src/oauth';
|
||||
import { challengeFromVerifier } from '../src/pkce';
|
||||
|
||||
describe('buildAuthorizeURL', () => {
|
||||
it('is the HIP-0111 authorize URL with PKCE S256', () => {
|
||||
const u = new URL(buildAuthorizeURL({
|
||||
clientId: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html',
|
||||
challenge: 'CHAL', state: 'STATE',
|
||||
}));
|
||||
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(u.searchParams.get('response_type')).toBe('code');
|
||||
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
|
||||
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
|
||||
expect(u.searchParams.get('state')).toBe('STATE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTokens', () => {
|
||||
it('turns expires_in into an absolute expiresAt', () => {
|
||||
const t = normalizeTokens({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }, 1_000_000);
|
||||
expect(t.accessToken).toBe('a');
|
||||
expect(t.refreshToken).toBe('r');
|
||||
expect(t.expiresAt).toBe(1_000_000 + 3600_000);
|
||||
});
|
||||
it('throws without an access_token', () => {
|
||||
expect(() => normalizeTokens({})).toThrow(/access_token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCallback', () => {
|
||||
it('extracts code/state/error from a redirect URL', () => {
|
||||
expect(parseCallback('https://x/cb?code=abc&state=xyz')).toEqual({ code: 'abc', state: 'xyz', error: undefined });
|
||||
expect(parseCallback('https://x/cb?error=access_denied').error).toBe('access_denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCode + refreshTokens (form-encoded, HIP-0111)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('POSTs form-urlencoded to the /v1/iam token URL and normalizes', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
const body = new URLSearchParams(init.body.toString());
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code_verifier')).toBe('ver');
|
||||
return { ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 60 }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const t = await exchangeCode({ clientId: 'hanzo-vscode', code: 'c', verifier: 'ver', redirectUri: 'https://hanzo.ai/callback' });
|
||||
expect(t.accessToken).toBe('AT');
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refresh reuses the old refresh_token when IAM does not rotate it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT2', expires_in: 60 }) } as any)));
|
||||
const t = await refreshTokens({ clientId: 'hanzo-cli', refreshToken: 'OLD' });
|
||||
expect(t.accessToken).toBe('AT2');
|
||||
expect(t.refreshToken).toBe('OLD');
|
||||
});
|
||||
|
||||
it('surfaces a failed exchange', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 400, text: async () => 'invalid_grant' } as any)));
|
||||
await expect(exchangeCode({ clientId: 'x', code: 'c', verifier: 'v', redirectUri: 'r' })).rejects.toThrow(/400/);
|
||||
});
|
||||
|
||||
it('userinfo reads sub/email/org', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
|
||||
expect(init.headers.Authorization).toBe('Bearer AT');
|
||||
return { ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai', owner: 'acme' }) } as any;
|
||||
}));
|
||||
const u = await fetchUserInfo('AT');
|
||||
expect(u).toEqual({ sub: 'u1', name: undefined, email: 'z@hanzo.ai', org: 'acme', picture: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PKCE round-trips with the S256 challenge', () => {
|
||||
it('RFC 7636 test vector', async () => {
|
||||
expect(await challengeFromVerifier('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe(
|
||||
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.28",
|
||||
"version": "1.9.29",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -51,6 +51,15 @@ tasks {
|
||||
gradleVersion = properties("gradleVersion").get()
|
||||
}
|
||||
|
||||
// buildSearchableOptions launches a headless IDE (JCEF) to pre-index plugin
|
||||
// settings — it needs native font libs (libfreetype) the CI runners lack, so
|
||||
// it crashes buildPlugin non-deterministically (only passing when the task is
|
||||
// cache-UP-TO-DATE). The index is optional; the IDE rebuilds it at runtime.
|
||||
// Disable it so buildPlugin reliably produces the distributable zip.
|
||||
buildSearchableOptions {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
patchPluginXml {
|
||||
version = properties("pluginVersion")
|
||||
sinceBuild = properties("pluginSinceBuild")
|
||||
|
||||
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
|
||||
pluginName = Hanzo AI
|
||||
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
|
||||
# SemVer format -> https://semver.org
|
||||
pluginVersion = 1.8.0
|
||||
pluginVersion = 1.9.29
|
||||
|
||||
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
|
||||
pluginSinceBuild = 233
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/office-addin",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo AI for Microsoft Office — Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
|
||||
"version": "1.9.29",
|
||||
"description": "Hanzo AI for Microsoft Office \u2014 Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@hanzo/auth": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node src/build.js",
|
||||
"watch": "node src/build.js --watch",
|
||||
|
||||
+101
-98
@@ -1,26 +1,80 @@
|
||||
// Auth: token storage in Office roaming settings (persists per-user across the
|
||||
// user's devices) + the IAM authorization-code+PKCE flow run through the Office
|
||||
// Dialog API. The URL/crypto is in pkce.ts (tested); this is the dialog glue.
|
||||
// Office's auth = the @hanzo/auth core + two thin Office adapters. The OAuth2 +
|
||||
// PKCE flow, endpoints, and refresh live ONCE in @hanzo/auth; here we only teach
|
||||
// it how Office stores tokens (roamingSettings) and how it opens a login window
|
||||
// (the Dialog API). Plus the office-only API-key path for zero-setup use.
|
||||
|
||||
/* global Office */
|
||||
|
||||
import { TOKEN_SETTING_KEY, APIKEY_SETTING_KEY, iamTokenURL, CLIENT_ID, pickBearer } from './config.js';
|
||||
import { createPkce, buildAuthorizeURL } from './pkce.js';
|
||||
import {
|
||||
authClientFor, login as coreLogin, logout as coreLogout, getValidToken,
|
||||
type TokenStore, type TokenSet, type Opener,
|
||||
} from '@hanzo/auth';
|
||||
import { APIKEY_SETTING_KEY, TOKEN_SETTING_KEY, pickBearer } from './config.js';
|
||||
|
||||
// getToken / setToken / clearToken persist the IAM access token. roamingSettings
|
||||
// is per-user and roams with the Office identity; it is NOT shared across users
|
||||
// on a shared machine.
|
||||
export function getToken(): string {
|
||||
try {
|
||||
return (Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
// The office IAM client, with the redirect served from the add-in's own origin
|
||||
// (registered on the hanzo-office IAM client).
|
||||
function client() {
|
||||
return authClientFor('office', { redirectUri: `${location.origin}/auth-callback.html` });
|
||||
}
|
||||
|
||||
// getApiKey / setApiKey / clearApiKey persist a pasted Hanzo API key (`hk-…`).
|
||||
// This is the zero-setup credential — no OAuth client registration required —
|
||||
// used by the downloadable test kit.
|
||||
// roamingStore is the Office TokenStore: roamingSettings is per-user and roams
|
||||
// with the Office identity. The TokenSet is JSON-serialized under one key.
|
||||
const roamingStore: TokenStore = {
|
||||
async get(): Promise<TokenSet | null> {
|
||||
try {
|
||||
const raw = Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string;
|
||||
return raw ? (JSON.parse(raw) as TokenSet) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(tokens: TokenSet): Promise<void> {
|
||||
return saveRoaming(TOKEN_SETTING_KEY, JSON.stringify(tokens));
|
||||
},
|
||||
clear(): Promise<void> {
|
||||
return removeRoaming(TOKEN_SETTING_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
// dialogOpener is the Office Opener: it opens the authorize URL in an Office
|
||||
// dialog and resolves with the callback URL the dialog posts back via
|
||||
// messageParent (auth-callback.html does that post).
|
||||
const dialogOpener: Opener = {
|
||||
open(authorizeUrl: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Office.context.ui.displayDialogAsync(authorizeUrl, { height: 60, width: 30 }, (result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(new Error(result.error?.message || 'could not open login dialog'));
|
||||
return;
|
||||
}
|
||||
const dialog = result.value;
|
||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
|
||||
dialog.close();
|
||||
resolve(String(arg.message || ''));
|
||||
});
|
||||
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
|
||||
reject(new Error('login dialog closed'));
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// signIn / clearToken / getToken delegate to the core with the Office adapters.
|
||||
export async function signIn(): Promise<string> {
|
||||
const tokens = await coreLogin(client(), roamingStore, dialogOpener);
|
||||
return tokens.accessToken;
|
||||
}
|
||||
export function clearToken(): Promise<void> {
|
||||
return coreLogout(roamingStore);
|
||||
}
|
||||
// getToken returns a valid (auto-refreshed) access token, or '' if not signed in.
|
||||
export async function getToken(): Promise<string> {
|
||||
return getValidToken(client(), roamingStore);
|
||||
}
|
||||
|
||||
// ── API-key path (office-only, zero-setup) ────────────────────────────────
|
||||
|
||||
export function getApiKey(): string {
|
||||
try {
|
||||
return (Office.context.roamingSettings.get(APIKEY_SETTING_KEY) as string) || '';
|
||||
@@ -28,101 +82,50 @@ export function getApiKey(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Office.context.roamingSettings.set(APIKEY_SETTING_KEY, key.trim());
|
||||
Office.context.roamingSettings.saveAsync((r) => {
|
||||
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save key failed'));
|
||||
});
|
||||
});
|
||||
return saveRoaming(APIKEY_SETTING_KEY, key.trim());
|
||||
}
|
||||
|
||||
export function clearApiKey(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
Office.context.roamingSettings.remove(APIKEY_SETTING_KEY);
|
||||
Office.context.roamingSettings.saveAsync(() => resolve());
|
||||
});
|
||||
return removeRoaming(APIKEY_SETTING_KEY);
|
||||
}
|
||||
|
||||
// bearer is the credential the chat call sends: the pasted API key wins over the
|
||||
// OAuth token (see pickBearer), else empty for anonymous.
|
||||
export function bearer(): string {
|
||||
return pickBearer(getApiKey(), getToken());
|
||||
// bearer is the credential the chat call sends: a pasted API key wins over the
|
||||
// OAuth token (see pickBearer). Async because the OAuth token may refresh.
|
||||
export async function bearer(): Promise<string> {
|
||||
const key = getApiKey();
|
||||
if (key) return key;
|
||||
return getValidToken(client(), roamingStore);
|
||||
}
|
||||
|
||||
export function setToken(token: string): Promise<void> {
|
||||
// hasApiKey / hasSession are SYNC reflect checks for the pane (no refresh, no
|
||||
// network) — used only to render which credential is live, not to authorize.
|
||||
export function hasApiKey(): boolean {
|
||||
return !!getApiKey();
|
||||
}
|
||||
export function hasSession(): boolean {
|
||||
try {
|
||||
return !!(Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── roamingSettings helpers ───────────────────────────────────────────────
|
||||
|
||||
function saveRoaming(key: string, value: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Office.context.roamingSettings.set(TOKEN_SETTING_KEY, token);
|
||||
Office.context.roamingSettings.set(key, value);
|
||||
Office.context.roamingSettings.saveAsync((r) => {
|
||||
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save token failed'));
|
||||
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function clearToken(): Promise<void> {
|
||||
function removeRoaming(key: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
Office.context.roamingSettings.remove(TOKEN_SETTING_KEY);
|
||||
Office.context.roamingSettings.remove(key);
|
||||
Office.context.roamingSettings.saveAsync(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// redirectURI is the dialog callback served from the add-in's own host (same
|
||||
// origin as the task pane), registered on the hanzo-office IAM client.
|
||||
function redirectURI(): string {
|
||||
return `${location.origin}/auth-callback.html`;
|
||||
}
|
||||
|
||||
// signIn opens the IAM login in an Office dialog, runs PKCE, exchanges the code
|
||||
// for a token, stores it, and resolves the token. Rejects if the user cancels
|
||||
// or the exchange fails. The dialog posts its `?code=&state=` back via
|
||||
// messageParent; auth-callback.html is that trivial page.
|
||||
export async function signIn(): Promise<string> {
|
||||
const { verifier, challenge, state } = await createPkce();
|
||||
const url = buildAuthorizeURL(redirectURI(), challenge, state);
|
||||
|
||||
const callback = await openAuthDialog(url);
|
||||
const params = new URLSearchParams(callback.split('?')[1] || '');
|
||||
if (params.get('state') !== state) throw new Error('auth state mismatch');
|
||||
const code = params.get('code');
|
||||
if (!code) throw new Error(params.get('error') || 'no authorization code');
|
||||
|
||||
const resp = await fetch(iamTokenURL(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectURI(),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`token exchange failed: ${resp.status}`);
|
||||
const tok = await resp.json();
|
||||
const access = tok.access_token as string;
|
||||
if (!access) throw new Error('no access_token in response');
|
||||
await setToken(access);
|
||||
return access;
|
||||
}
|
||||
|
||||
// openAuthDialog wraps the Office Dialog API in a promise that resolves with the
|
||||
// callback URL the dialog posts back (via Office.context.ui.messageParent).
|
||||
function openAuthDialog(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Office.context.ui.displayDialogAsync(url, { height: 60, width: 30 }, (result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(new Error(result.error?.message || 'could not open login dialog'));
|
||||
return;
|
||||
}
|
||||
const dialog = result.value;
|
||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
|
||||
dialog.close();
|
||||
resolve(String(arg.message || ''));
|
||||
});
|
||||
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
|
||||
reject(new Error('login dialog closed'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
// pickBearer re-export kept for callers/tests that import it from auth.
|
||||
export { pickBearer };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// and fully unit-testable (no Office.js, no DOM). taskpane.ts is the thin glue
|
||||
// that reads the document via Office.js and hands the text here.
|
||||
|
||||
import { chatCompletionsURL, DEFAULT_MODEL } from './config.js';
|
||||
import { chatCompletionsURL, modelsURL, DEFAULT_MODEL } from './config.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -35,17 +35,19 @@ export function buildMessages(task: string, selection: string): ChatMessage[] {
|
||||
export interface ChatOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
/** Request an SSE stream. Defaults to false (one-shot completion). */
|
||||
stream?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// requestBody is the exact JSON the gateway receives. Separated so a test can
|
||||
// assert the wire shape (model default, stream=false, messages) without a
|
||||
// network call.
|
||||
// assert the wire shape (model default, stream flag, messages) without a
|
||||
// network call. `stream` defaults to false — the one-shot `ask` contract.
|
||||
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
|
||||
return {
|
||||
model: opts.model || DEFAULT_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
stream: opts.stream === true,
|
||||
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||
};
|
||||
}
|
||||
@@ -106,3 +108,75 @@ export async function ask(
|
||||
}
|
||||
return extractContent(data);
|
||||
}
|
||||
|
||||
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
|
||||
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
|
||||
export function streamDelta(payload: string): string {
|
||||
const json = JSON.parse(payload);
|
||||
return json?.choices?.[0]?.delta?.content ?? '';
|
||||
}
|
||||
|
||||
// askStream runs a streaming completion, invoking onDelta with each text chunk
|
||||
// as it arrives, and resolves with the full concatenated text. Same inputs as
|
||||
// `ask`; use it for a live-typing pane. Falls back to a thrown error on non-2xx.
|
||||
export async function askStream(
|
||||
task: string,
|
||||
selection: string,
|
||||
token: string,
|
||||
onDelta: (text: string) => void,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, selection), { ...opts, stream: true })),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let full = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (payload === '[DONE]') return full;
|
||||
try {
|
||||
const delta = streamDelta(payload);
|
||||
if (delta) {
|
||||
full += delta;
|
||||
onDelta(delta);
|
||||
}
|
||||
} catch {
|
||||
// partial SSE frame — wait for the next chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models.
|
||||
// The endpoint is org-scoped by the bearer; an empty token lists public models.
|
||||
export async function listModels(token: string): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(modelsURL(), { headers });
|
||||
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
|
||||
const data: any = await resp.json();
|
||||
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
|
||||
return items
|
||||
.map((m: any) => (typeof m === 'string' ? m : m?.id))
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
// Canonical Hanzo endpoints — the SAME gateway + IAM the browser extension
|
||||
// uses (packages/browser/src/shared/config.ts). llm.hanzo.ai is dead; every
|
||||
// model call goes through api.hanzo.ai/v1 (OpenAI-compatible, IAM JWT at the
|
||||
// gateway). Kept as its own tiny module so the task pane, auth, and tests all
|
||||
// read one source of truth.
|
||||
// Office add-in config — ONLY what is office-specific. IAM endpoints, the OAuth
|
||||
// client, PKCE, and scopes now live in @hanzo/auth (the one auth core); this
|
||||
// module keeps the api.hanzo.ai model gateway + the roaming-settings keys.
|
||||
|
||||
export const API_BASE_URL = 'https://api.hanzo.ai';
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
|
||||
// hanzo-office is the registered IAM OAuth client for this add-in. Its
|
||||
// redirectUris must include the add-in's dialog callback (served from the same
|
||||
// host as the task pane). Mirrors the browser extension's hanzo-browser client.
|
||||
export const CLIENT_ID = 'hanzo-office';
|
||||
export const OAUTH_SCOPES = 'openid profile email';
|
||||
|
||||
// Default model. Overridable per-request; the gateway routes it.
|
||||
export const DEFAULT_MODEL = 'zen-1';
|
||||
|
||||
// Roaming-settings keys (Office.context.roamingSettings persists per-user across
|
||||
// the user's devices). Two auth paths: the IAM OAuth access token, and a pasted
|
||||
// Hanzo API key (`hk-…`) for zero-setup use — the key needs no registered OAuth
|
||||
// client, which is why the downloadable Windows test kit uses it.
|
||||
export const TOKEN_SETTING_KEY = 'hanzo.accessToken';
|
||||
// the user's devices). Two auth paths: the IAM OAuth token set (managed by
|
||||
// @hanzo/auth) and a pasted Hanzo API key (`hk-…`) for zero-setup use — the key
|
||||
// needs no registered OAuth client, which is why the Windows test kit uses it.
|
||||
export const TOKEN_SETTING_KEY = 'hanzo.tokens';
|
||||
export const APIKEY_SETTING_KEY = 'hanzo.apiKey';
|
||||
|
||||
// pickBearer chooses the credential to send: a pasted API key wins over the
|
||||
@@ -32,18 +21,10 @@ export function pickBearer(apiKey: string, oauthToken: string): string {
|
||||
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
|
||||
}
|
||||
|
||||
// chatCompletionsURL / modelsURL / iamTokenURL / iamAuthorizeURL are the exact
|
||||
// endpoints the add-in calls. Centralized so a path never drifts between the
|
||||
// task pane and the tests.
|
||||
// chatCompletionsURL / modelsURL — the model gateway endpoints the add-in calls.
|
||||
export function chatCompletionsURL(): string {
|
||||
return `${API_BASE_URL}/v1/chat/completions`;
|
||||
}
|
||||
export function modelsURL(): string {
|
||||
return `${API_BASE_URL}/v1/models`;
|
||||
}
|
||||
export function iamAuthorizeURL(): string {
|
||||
return `${IAM_LOGIN_URL}${IAM_API_PATH}/oauth/authorize`;
|
||||
}
|
||||
export function iamTokenURL(): string {
|
||||
return `${IAM_API_URL}${IAM_API_PATH}/oauth/token`;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// PKCE (RFC 7636) + the IAM authorize URL — pure crypto, no Office, no DOM, so
|
||||
// it is unit-tested directly. The add-in runs the authorization-code+PKCE flow
|
||||
// against hanzo.id (HIP-0111) through the Office Dialog API; auth.ts is the thin
|
||||
// dialog glue over these functions.
|
||||
|
||||
import { iamAuthorizeURL, CLIENT_ID, OAUTH_SCOPES } from './config.js';
|
||||
|
||||
// base64url encodes bytes without padding (the PKCE + OAuth-state alphabet).
|
||||
export function base64url(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// randomString returns a base64url token of `bytes` entropy (verifier/state),
|
||||
// using the platform CSPRNG (browser webcrypto or node webcrypto).
|
||||
export function randomString(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
(globalThis.crypto as Crypto).getRandomValues(buf);
|
||||
return base64url(buf);
|
||||
}
|
||||
|
||||
// challengeFromVerifier is the S256 code_challenge = BASE64URL(SHA256(verifier)).
|
||||
export async function challengeFromVerifier(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
|
||||
return base64url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export interface PkcePair {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function createPkce(): Promise<PkcePair> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await challengeFromVerifier(verifier);
|
||||
const state = randomString(16);
|
||||
return { verifier, challenge, state };
|
||||
}
|
||||
|
||||
// buildAuthorizeURL assembles the hanzo.id authorize URL for the dialog to
|
||||
// open. redirectUri is the add-in's dialog callback (same host as the task
|
||||
// pane), which must be registered on the hanzo-office IAM client.
|
||||
export function buildAuthorizeURL(redirectUri: string, challenge: string, state: string): string {
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
scope: OAUTH_SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${iamAuthorizeURL()}?${q.toString()}`;
|
||||
}
|
||||
@@ -26,15 +26,29 @@
|
||||
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
|
||||
.spacer { flex: 1; }
|
||||
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
|
||||
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
|
||||
.chip:hover:not(:disabled) { border-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/icon-32.png" alt="Hanzo" />
|
||||
<span class="title">Hanzo AI</span>
|
||||
<select id="model" aria-label="Model"></select>
|
||||
<span class="host" id="hostname">Office</span>
|
||||
</header>
|
||||
|
||||
<!-- One-click actions over the selection (drafting/review, e.g. a law office). -->
|
||||
<div class="chips">
|
||||
<button class="chip" data-task="Continue drafting this document in the same voice and formatting. Return only the continuation.">Draft</button>
|
||||
<button class="chip" data-task="Summarize the following, preserving parties, dates, defined terms and obligations.">Summarize</button>
|
||||
<button class="chip" data-task="Explain this in plain, non-jargon language, and note any risks or obligations.">Explain plainly</button>
|
||||
<button class="chip" data-task="Tighten and clarify this text without changing its legal meaning. Return only the revised text.">Tighten / redline</button>
|
||||
<button class="chip" data-task="Continue writing from here in the same voice, tense and formatting. Return only the continuation.">Continue</button>
|
||||
</div>
|
||||
|
||||
<label for="prompt">What should Hanzo do with your selection?</label>
|
||||
<textarea id="prompt" placeholder="e.g. Rewrite this clause in plain English · Summarize the selected rows · Draft a mutual NDA section about confidentiality"></textarea>
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
/* global Office, document */
|
||||
|
||||
import { ask } from './chat.js';
|
||||
import { askStream, listModels } from './chat.js';
|
||||
import { readSelection, insertResult, currentHost, type InsertMode } from './host.js';
|
||||
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, getToken } from './auth.js';
|
||||
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, hasApiKey, hasSession } from './auth.js';
|
||||
import { DEFAULT_MODEL } from './config.js';
|
||||
|
||||
Office.onReady((info) => {
|
||||
if (
|
||||
@@ -25,40 +26,79 @@ Office.onReady((info) => {
|
||||
const outputEl = $('output') as HTMLTextAreaElement;
|
||||
const insertBtn = $('insert') as HTMLButtonElement;
|
||||
const modeEl = $('mode') as HTMLSelectElement;
|
||||
const modelEl = $('model') as HTMLSelectElement;
|
||||
const signInBtn = $('signin') as HTMLButtonElement;
|
||||
const signOutBtn = $('signout') as HTMLButtonElement;
|
||||
const apiKeyEl = $('apikey') as HTMLInputElement;
|
||||
const saveKeyBtn = $('savekey') as HTMLButtonElement;
|
||||
const chips = Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'));
|
||||
|
||||
$('hostname').textContent = hostLabel(currentHost());
|
||||
apiKeyEl.value = getApiKey();
|
||||
reflectAuth();
|
||||
void populateModels();
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
runBtn.onclick = async () => {
|
||||
const task = promptEl.value.trim();
|
||||
// One code path for the Ask button AND the quick-action chips: read the
|
||||
// selection, stream the completion into the output, enable Insert.
|
||||
async function runTask(task: string) {
|
||||
if (!task) {
|
||||
setStatus('Type an instruction first.', 'warn');
|
||||
setStatus('Type an instruction or pick an action first.', 'warn');
|
||||
return;
|
||||
}
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
setBusy(true, 'Reading selection…');
|
||||
outputEl.value = '';
|
||||
insertBtn.disabled = true;
|
||||
try {
|
||||
const selection = await readSelection();
|
||||
setStatus('Asking Hanzo…');
|
||||
const result = await ask(task, selection, bearer(), { signal: controller.signal });
|
||||
outputEl.value = result;
|
||||
insertBtn.disabled = false;
|
||||
setStatus('Done — review, then Insert.', 'ok');
|
||||
await askStream(
|
||||
task,
|
||||
selection,
|
||||
await bearer(),
|
||||
(delta) => { outputEl.value += delta; outputEl.scrollTop = outputEl.scrollHeight; },
|
||||
{ model: modelEl.value || DEFAULT_MODEL, signal: controller.signal },
|
||||
);
|
||||
insertBtn.disabled = !outputEl.value;
|
||||
setStatus(outputEl.value ? 'Done — review, then Insert.' : 'No content returned.', outputEl.value ? 'ok' : 'warn');
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return;
|
||||
setStatus(errMessage(e), 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
runBtn.onclick = () => runTask(promptEl.value.trim());
|
||||
|
||||
// Quick-action chip: seed the prompt with its preset task, then run it.
|
||||
for (const chip of chips) {
|
||||
chip.onclick = () => {
|
||||
const task = chip.dataset.task ?? '';
|
||||
promptEl.value = task;
|
||||
void runTask(task);
|
||||
};
|
||||
}
|
||||
|
||||
async function populateModels() {
|
||||
try {
|
||||
const ids = (await listModels(await bearer())).sort();
|
||||
if (!ids.length) return;
|
||||
modelEl.innerHTML = '';
|
||||
for (const id of ids) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = id;
|
||||
modelEl.appendChild(opt);
|
||||
}
|
||||
modelEl.value = ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0];
|
||||
} catch {
|
||||
// Non-fatal — a fixed default still works; leave the picker with its default.
|
||||
modelEl.innerHTML = `<option value="${DEFAULT_MODEL}">${DEFAULT_MODEL}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
insertBtn.onclick = async () => {
|
||||
const text = outputEl.value;
|
||||
@@ -77,6 +117,7 @@ Office.onReady((info) => {
|
||||
await signIn();
|
||||
setStatus('Signed in.', 'ok');
|
||||
reflectAuth();
|
||||
void populateModels(); // a token unlocks the caller's org-specific models
|
||||
} catch (e: any) {
|
||||
setStatus(errMessage(e), 'error');
|
||||
}
|
||||
@@ -98,20 +139,24 @@ Office.onReady((info) => {
|
||||
setStatus('API key cleared.', 'ok');
|
||||
}
|
||||
reflectAuth();
|
||||
void populateModels(); // the new key may unlock a different model set
|
||||
};
|
||||
|
||||
function reflectAuth() {
|
||||
// Signed-in state is either an OAuth token or a saved API key.
|
||||
const authed = !!bearer();
|
||||
const oauth = !!getToken();
|
||||
// Sync reflect only (no refresh/network): signed in via OAuth session or a
|
||||
// saved API key. bearer()/getToken() do the real (async) resolution at call
|
||||
// time; this just renders which credential is live.
|
||||
const oauth = hasSession();
|
||||
const keyed = hasApiKey();
|
||||
signInBtn.style.display = oauth ? 'none' : '';
|
||||
signOutBtn.style.display = oauth ? '' : 'none';
|
||||
$('authhint').textContent = authed
|
||||
? 'Ready — using ' + (getApiKey() ? 'your API key.' : 'your Hanzo sign-in.')
|
||||
$('authhint').textContent = (oauth || keyed)
|
||||
? 'Ready — using ' + (keyed ? 'your API key.' : 'your Hanzo sign-in.')
|
||||
: 'Paste a Hanzo API key or sign in to use your models.';
|
||||
}
|
||||
function setBusy(busy: boolean, msg?: string) {
|
||||
runBtn.disabled = busy;
|
||||
for (const chip of chips) chip.disabled = busy;
|
||||
if (msg) setStatus(msg);
|
||||
}
|
||||
function setStatus(msg: string, kind: 'ok' | 'warn' | 'error' | '' = '') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { buildMessages, requestBody, extractContent, ask } from '../src/chat';
|
||||
import { buildMessages, requestBody, extractContent, ask, streamDelta, listModels } from '../src/chat';
|
||||
import { DEFAULT_MODEL } from '../src/config';
|
||||
|
||||
describe('buildMessages', () => {
|
||||
@@ -35,6 +35,43 @@ describe('requestBody', () => {
|
||||
expect(b.model).toBe('zen-pro');
|
||||
expect(b.temperature).toBe(0.2);
|
||||
});
|
||||
it('enables streaming only when opts.stream is true', () => {
|
||||
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(true);
|
||||
expect(requestBody(buildMessages('x', ''), { stream: false }).stream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamDelta', () => {
|
||||
it('reads choices[0].delta.content from an SSE frame', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }))).toBe('Hel');
|
||||
});
|
||||
it('returns empty string for a role-only / empty delta', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
|
||||
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns ids from the /v1/models data array with the bearer', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(init.headers.Authorization).toBe('Bearer t');
|
||||
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
|
||||
});
|
||||
|
||||
it('tolerates a bare string array and omits the bearer when unauthenticated', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, json: async () => ['a', 'b'] } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await listModels('')).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
chatCompletionsURL, modelsURL, iamAuthorizeURL, iamTokenURL,
|
||||
API_BASE_URL, IAM_LOGIN_URL, CLIENT_ID,
|
||||
} from '../src/config';
|
||||
import { chatCompletionsURL, modelsURL, API_BASE_URL, pickBearer } from '../src/config';
|
||||
|
||||
describe('canonical endpoints', () => {
|
||||
describe('model gateway endpoints', () => {
|
||||
it('chat + models go through api.hanzo.ai/v1 (llm.hanzo.ai is dead)', () => {
|
||||
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||
expect(chatCompletionsURL()).not.toContain('llm.hanzo.ai');
|
||||
});
|
||||
|
||||
it('IAM is the canonical HIP-0111 /v1/iam/oauth/* surface', () => {
|
||||
expect(iamAuthorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(iamTokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(IAM_LOGIN_URL).toBe('https://hanzo.id');
|
||||
});
|
||||
|
||||
it('the add-in IAM client is hanzo-office', () => {
|
||||
expect(CLIENT_ID).toBe('hanzo-office');
|
||||
});
|
||||
// IAM endpoints, the hanzo-office client, PKCE, and scopes now live in
|
||||
// @hanzo/auth and are pinned by its config/oauth tests — not duplicated here.
|
||||
});
|
||||
|
||||
import { pickBearer } from '../src/config';
|
||||
|
||||
describe('pickBearer', () => {
|
||||
it('API key wins over the OAuth token', () => {
|
||||
expect(pickBearer('hk-abc', 'jwt-xyz')).toBe('hk-abc');
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { base64url, randomString, challengeFromVerifier, createPkce, buildAuthorizeURL } from '../src/pkce';
|
||||
|
||||
describe('base64url', () => {
|
||||
it('is url-safe and unpadded', () => {
|
||||
const s = base64url(new Uint8Array([251, 255, 191, 0, 1, 2]));
|
||||
expect(s).not.toMatch(/[+/=]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('randomString', () => {
|
||||
it('is non-empty and unique per call', () => {
|
||||
const a = randomString();
|
||||
const b = randomString();
|
||||
expect(a.length).toBeGreaterThan(20);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('challengeFromVerifier', () => {
|
||||
it('matches the RFC 7636 S256 test vector', async () => {
|
||||
// Appendix B: verifier → BASE64URL(SHA256(verifier)) == known challenge.
|
||||
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
const challenge = await challengeFromVerifier(verifier);
|
||||
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPkce', () => {
|
||||
it('produces a verifier, its S256 challenge, and a state', async () => {
|
||||
const p = await createPkce();
|
||||
expect(p.verifier).toBeTruthy();
|
||||
expect(p.state).toBeTruthy();
|
||||
expect(await challengeFromVerifier(p.verifier)).toBe(p.challenge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAuthorizeURL', () => {
|
||||
it('targets hanzo.id with PKCE S256 and the hanzo-office client', () => {
|
||||
const url = buildAuthorizeURL('https://office.hanzo.ai/auth-callback.html', 'CHAL', 'STATE');
|
||||
const u = new URL(url);
|
||||
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(u.searchParams.get('response_type')).toBe('code');
|
||||
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
|
||||
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
|
||||
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(u.searchParams.get('state')).toBe('STATE');
|
||||
expect(u.searchParams.get('redirect_uri')).toBe('https://office.hanzo.ai/auth-callback.html');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "hanzo-ide",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.29",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,6 +5,20 @@ import * as os from 'os';
|
||||
import axios from 'axios';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// The canonical HIP-0111 OAuth path prefix. Both the login UI (authorize) and
|
||||
// the API (token) carry it — this file previously omitted it (hitting
|
||||
// hanzo.id/oauth/authorize, which 404s), the exact drift @hanzo/auth exists to
|
||||
// prevent. Kept as a local const here because this MCP-standalone auth compiles
|
||||
// without the workspace bundler; the value MUST equal @hanzo/auth's IAM_API_PATH.
|
||||
const IAM_API_PATH = '/v1/iam';
|
||||
|
||||
// form() encodes a token-endpoint body as application/x-www-form-urlencoded —
|
||||
// RFC 6749 requires it; posting a JSON object (axios's default) is rejected by
|
||||
// IAM. axios serializes URLSearchParams as form and sets the header itself.
|
||||
function form(body: Record<string, string>): URLSearchParams {
|
||||
return new URLSearchParams(body);
|
||||
}
|
||||
|
||||
interface AuthToken {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
@@ -104,12 +118,14 @@ export class StandaloneAuthManager {
|
||||
|
||||
private async refreshToken(refreshToken: string): Promise<AuthToken | null> {
|
||||
try {
|
||||
const response = await axios.post(`${this.iamConfig.endpoint}/oauth/token`, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.iamConfig.clientId,
|
||||
client_secret: this.iamConfig.clientSecret
|
||||
});
|
||||
const response = await axios.post(
|
||||
`${this.iamConfig.endpoint}${IAM_API_PATH}/oauth/token`,
|
||||
form({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.iamConfig.clientId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.data?.access_token) {
|
||||
const token: AuthToken = {
|
||||
@@ -166,11 +182,11 @@ export class StandaloneAuthManager {
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
|
||||
const authUrl = new URL(`${this.iamConfig.loginUrl}/oauth/authorize`);
|
||||
const authUrl = new URL(`${this.iamConfig.loginUrl}${IAM_API_PATH}/oauth/authorize`);
|
||||
authUrl.searchParams.append('client_id', this.iamConfig.clientId);
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('redirect_uri', 'http://localhost:8765/callback');
|
||||
authUrl.searchParams.append('scope', 'read write');
|
||||
authUrl.searchParams.append('scope', 'openid profile email offline_access');
|
||||
authUrl.searchParams.append('state', state);
|
||||
authUrl.searchParams.append('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.append('code_challenge_method', 'S256');
|
||||
@@ -247,9 +263,8 @@ export class StandaloneAuthManager {
|
||||
redirect_uri: 'http://localhost:8765/callback',
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
if (clientSecret) {body.client_secret = clientSecret;}
|
||||
|
||||
const tokenResp = await axios.post(`${endpoint}/oauth/token`, body);
|
||||
// Public PKCE client: NO client_secret (the verifier is the proof).
|
||||
const tokenResp = await axios.post(`${endpoint}${IAM_API_PATH}/oauth/token`, form(body));
|
||||
const tokens = tokenResp.data;
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
|
||||
Generated
+13
@@ -194,6 +194,15 @@ importers:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(@types/node@20.19.9)(jsdom@26.1.0)(terser@5.43.1)
|
||||
|
||||
packages/auth:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
vitest:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(@types/node@20.19.9)(jsdom@26.1.0)(terser@5.43.1)
|
||||
|
||||
packages/browser:
|
||||
dependencies:
|
||||
'@supabase/supabase-js':
|
||||
@@ -333,6 +342,10 @@ importers:
|
||||
version: 5.8.3
|
||||
|
||||
packages/office:
|
||||
dependencies:
|
||||
'@hanzo/auth':
|
||||
specifier: workspace:*
|
||||
version: link:../auth
|
||||
devDependencies:
|
||||
'@types/office-js':
|
||||
specifier: ^1.0.377
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- 'packages/ai'
|
||||
- 'packages/aci'
|
||||
- 'packages/auth'
|
||||
- 'packages/browser'
|
||||
- 'packages/dxt'
|
||||
- 'packages/jetbrains'
|
||||
|
||||
Reference in New Issue
Block a user