feat(workday): Hanzo AI for Workday — read-only HCM app
@hanzo/workday: an embedded-app panel + read-only OAuth + API-proxy service
over Workday people data, built on @hanzo/ai + @hanzo/iam via api.hanzo.ai.
Mirrors packages/procore's pure-core shape.
- OAuth2 Authorization Code + refresh against the tenant token endpoint
(…/ccx/oauth2/{tenant}/token) with HTTP Basic client auth (secret in the
header, never the body); non-rotating refresh carried forward.
- Workday REST wrappers (staffing v6 workers/orgs/directReports, recruiting v4
requisitions, absenceManagement v1 absence types) with limit/offset + the
{ total, data } envelope, plus RaaS custom-report reads ({ Report_Entry }).
- Pure people-context assembly with honest budget/truncation windowing.
- Five HR-guardrailed AI actions: summarize worker, draft job description,
org & headcount, answer HR question, draft review feedback.
- Read-only by design: the proxy is GET-only (405 otherwise); write-back is
documented as a gated future addition.
- 113 vitest tests; tsc --noEmit clean; esbuild build green.
Registers packages/workday in pnpm-workspace.yaml.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
# Hanzo AI for Workday
|
||||
|
||||
AI over your Workday people data — **summarize a worker or team**, **draft a job
|
||||
description / requisition**, **summarize an org and headcount**, **answer HR /
|
||||
policy questions**, and **draft review feedback**, plus a freeform "ask about
|
||||
these people" box. An embedded-app **panel** you host at `workday.hanzo.ai`,
|
||||
backed by a small **read-only OAuth + API-proxy service** that keeps the Workday
|
||||
client secret and access tokens server-side.
|
||||
|
||||
Built on the published Hanzo SDK:
|
||||
|
||||
- **[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)** — the headless client.
|
||||
`createAiClient({ baseUrl, token }).chat.completions.create({ model, messages })`
|
||||
and `.models.list()`. All model calls go to `https://api.hanzo.ai` on `/v1/...`
|
||||
(never an `/api/` prefix). We import it; we do not reimplement the transport.
|
||||
- **[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)** — Hanzo identity /
|
||||
the `hk-…` API key the panel uses as its gateway bearer.
|
||||
|
||||
Workday is the enterprise system of record for people data: workers, supervisory
|
||||
organizations, time off, and recruiting. This app reads those records through the
|
||||
Workday REST API and RaaS custom reports, and grounds every AI answer in them.
|
||||
|
||||
> **Read-only by design.** v1 exposes reads only. Workday writes (requesting time
|
||||
> off, business-process submissions) are heavy, kick off approval workflows, and
|
||||
> mutate the system of record — a gated write-back is a deliberate, documented
|
||||
> future addition (see *Write-back* below), not an oversight.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser panel (dist/index.html, app.js) Server service (dist/server.js)
|
||||
───────────────────────────────────── ──────────────────────────────
|
||||
reads Workday via ──► /proxy/* ──────────► injects OAuth Bearer + refresh,
|
||||
same-origin proxy (cookie) resolves the resource route to a
|
||||
(never sees a token or the tenant) REST / RaaS URL on the tenant host
|
||||
│
|
||||
└── calls api.hanzo.ai /v1 directly with the pasted hk-… Hanzo key
|
||||
(@hanzo/ai headless client)
|
||||
```
|
||||
|
||||
- The **panel** never holds a Workday token, the client secret, or even the tenant
|
||||
name. It reaches Workday only through the same-origin `/proxy/*` endpoint (with
|
||||
an HttpOnly session cookie), calling *resource* routes (`/proxy/workers`,
|
||||
`/proxy/organizations`, `/proxy/report/{owner}/{name}`). It talks to
|
||||
`api.hanzo.ai` directly with the user's pasted Hanzo key.
|
||||
- The **service** is the only place `WORKDAY_CLIENT_SECRET`, the OAuth tokens, and
|
||||
the tenant address exist. It runs the install flow, holds the session, refreshes
|
||||
the access token transparently on expiry, and resolves each resource route to a
|
||||
Workday REST/RaaS URL. It accepts **GET only**.
|
||||
|
||||
### Source layout (all pure logic is unit-tested)
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/config.ts` | Tenant address (host + tenant), REST service/version descriptors, OAuth/REST/RaaS URL builders, gateway URL, server-secret config (`readServerConfig` fails fast). |
|
||||
| `src/workday-oauth.ts` | OAuth2 Authorization-Code shaping: `basicAuthHeader`, `authorizeUrl`, `tokenExchange`, `refreshExchange`, token parsing + `carryRefreshToken` + expiry math. Pure. |
|
||||
| `src/workday-api.ts` | REST wrappers (`listWorkers`, `getWorker`, `listDirectReports`, `listSupervisoryOrganizations`, `listJobRequisitions`, `listEligibleAbsenceTypes`) + RaaS (`timeOffReport`) with `limit`/`offset` pagination, and `{ total, data }` / `Report_Entry` parsers. Pure. |
|
||||
| `src/people.ts` | Worker/org/requisition/absence/report JSON → windowed context text + honest truncation. Pure. |
|
||||
| `src/hanzo.ts` | Thin over `@hanzo/ai`: HR-guardrailed prompt assembly + the single `ask` path + `listModels`. |
|
||||
| `src/actions.ts` | The five AI actions (id → prompt) over `ask`. One code path to the model. |
|
||||
| `src/panel.ts` | Browser proxy-client request shaping (resource routes) + launch-context parsing. Pure. |
|
||||
| `src/app.ts` | The DOM glue for the panel (the one impure browser entry). |
|
||||
| `src/server.ts` | Node http service: OAuth install/callback + read-only `/proxy/*`. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Register a Workday API Client (OAuth)
|
||||
|
||||
In your Workday tenant, an integration/security admin registers an **API Client**:
|
||||
|
||||
1. Search the task **Register API Client** (or *Register API Client for
|
||||
Integrations* for client-credentials integrations). For this app, use the
|
||||
**Authorization Code Grant**:
|
||||
- Search **Register API Client**, choose **Authorization Code Grant**.
|
||||
2. Set:
|
||||
- **Client Grant Type**: *Authorization Code Grant*.
|
||||
- **Access Token Type**: *Bearer*.
|
||||
- **Redirection URI** — exactly the URL your service serves the callback at:
|
||||
|
||||
```
|
||||
https://workday.hanzo.ai/oauth/callback
|
||||
```
|
||||
|
||||
(For local development, also add `http://localhost:8791/oauth/callback`.)
|
||||
- **Scope (Functional Areas)** — grant the areas this app reads: **Staffing**,
|
||||
**Organizations and Roles**, **Recruiting**, and **Time Off and Leave**. The
|
||||
client's registered scope governs access; the OAuth `/authorize` call sends
|
||||
no `scope` param for a standard client.
|
||||
- **Non-Expiring Refresh Tokens** — enable if you want the refresh token to
|
||||
persist (Workday refresh tokens do **not** rotate on refresh).
|
||||
3. On save, Workday shows the **Client ID** and **Client Secret** (the secret is
|
||||
shown once — store it in KMS). Note your **tenant name** and your **datacenter
|
||||
host** (the origin your users hit, e.g. `https://wd2-impl-services1.workday.com`
|
||||
for an implementation tenant, `https://services1.myworkday.com` for production).
|
||||
|
||||
### Implementation ("sandbox") vs production
|
||||
|
||||
There is no fixed host per environment — each Workday datacenter differs. An
|
||||
**implementation** tenant carries `-impl` in the host (`isSandboxHost` detects
|
||||
this). Point the service at whichever host+tenant you are targeting:
|
||||
|
||||
| | Host (`WORKDAY_HOST`) | Tenant (`WORKDAY_TENANT`) |
|
||||
| --- | --- | --- |
|
||||
| `sandbox` | `https://wd2-impl-services1.workday.com` | your impl tenant |
|
||||
| `production` | `https://services1.myworkday.com` | your prod tenant |
|
||||
|
||||
Develop against the implementation tenant, flip to production when validated.
|
||||
|
||||
---
|
||||
|
||||
## 2. OAuth flow
|
||||
|
||||
Standard **Authorization Code** grant (server-side secret, **HTTP Basic** client
|
||||
auth on the token endpoint):
|
||||
|
||||
1. `GET /oauth/install` → 302 to
|
||||
`https://{host}/ccx/oauth2/{tenant}/authorize?response_type=code&client_id=…&redirect_uri=…&state=…`.
|
||||
2. The user consents; Workday redirects back to `GET /oauth/callback?code=…&state=…`.
|
||||
3. The service verifies `state`, then POSTs to `/ccx/oauth2/{tenant}/token` with
|
||||
`grant_type=authorization_code`, the `code`, and the same `redirect_uri`. The
|
||||
**client id + secret ride in the `Authorization: Basic base64(id:secret)`
|
||||
header — never in the body**.
|
||||
4. It opens a session, sets an HttpOnly cookie, and the panel is authenticated.
|
||||
5. Access tokens are short-lived (~1h). The service refreshes with
|
||||
`grant_type=refresh_token` **before** each proxied call when the token has
|
||||
expired. Workday does **not** rotate the refresh token, and the refresh
|
||||
response usually omits it, so the prior refresh token is carried forward
|
||||
(`carryRefreshToken`).
|
||||
|
||||
> In-memory sessions here are for a single instance. For a multi-instance
|
||||
> deployment behind `hanzoai/ingress`, persist sessions + the OAuth `state` set to
|
||||
> `hanzoai/kv` (Valkey), and read `WORKDAY_CLIENT_SECRET` from KMS
|
||||
> (`kms.hanzo.ai`) — never from a committed env file.
|
||||
|
||||
---
|
||||
|
||||
## 3. Workday REST API + RaaS
|
||||
|
||||
**REST** reads go through `https://{host}/ccx/api/{service}/{version}/{tenant}/...`.
|
||||
Each service is versioned independently; list endpoints paginate with `limit`
|
||||
(≤ 100) + `offset` (0-based) and return a `{ total, data: [...] }` envelope.
|
||||
|
||||
| Resource | Service | Endpoint (after the tenant) |
|
||||
| --- | --- | --- |
|
||||
| Workers | `staffing/v6` | `GET /workers`, `GET /workers/{id}` |
|
||||
| Direct reports | `staffing/v6` | `GET /workers/{id}/directReports` |
|
||||
| Supervisory orgs | `staffing/v6` | `GET /supervisoryOrganizations`, `.../{id}` |
|
||||
| Job requisitions | `recruiting/v4` | `GET /jobRequisitions`, `.../{id}` |
|
||||
| Eligible absence types | `absenceManagement/v1` | `GET /workers/{id}/eligibleAbsenceTypes` |
|
||||
|
||||
**RaaS** (Report-as-a-Service) reads a customer-defined report as JSON:
|
||||
|
||||
```
|
||||
GET https://{host}/ccx/service/customreport2/{tenant}/{owner}/{report}?format=json
|
||||
→ { "Report_Entry": [ { … }, … ] }
|
||||
```
|
||||
|
||||
`owner` is the report owner's username; the app forces `format=json` and parses
|
||||
the `Report_Entry` rows (nested `{ Descriptor }` references are flattened).
|
||||
|
||||
### RaaS report setup (time off)
|
||||
|
||||
Time-off history and balances are customer-configured, so they are pulled via a
|
||||
custom report rather than a fixed REST endpoint:
|
||||
|
||||
1. In Workday, build a **Custom Report** (e.g. *Team Time Off*) with the fields
|
||||
you want per row (Worker, Time Off Type, Units, Date).
|
||||
2. Enable **Web Service** output (the *customreport2* alias) and share the report
|
||||
with the API Client's ISU / owner account.
|
||||
3. Call it from the panel via `report(owner, name)` (route
|
||||
`/proxy/report/{owner}/{name}`); it returns `Report_Entry` rows that feed the
|
||||
people context under the **Time off** section.
|
||||
|
||||
---
|
||||
|
||||
## 4. The summarize-a-worker flow
|
||||
|
||||
1. Connect the app (`/oauth/install`) so the service holds a Workday token.
|
||||
2. Open the panel; it lists the tenant's workers. Pick a worker and **Load**. The
|
||||
panel pulls the worker's profile, direct reports, supervisory organizations,
|
||||
open requisitions, and eligible absence types through the proxy and assembles a
|
||||
windowed, truncation-honest context.
|
||||
3. **Summarize worker** — the model summarizes role, business title, org and
|
||||
manager, location, and (if reports are loaded) team size and notable roles,
|
||||
grounded only in the records, never inferring compensation, performance, or any
|
||||
protected characteristic.
|
||||
4. **Org & headcount** — the model summarizes the org, headcount breakdown, open
|
||||
requisitions and openings, and time-off coverage.
|
||||
5. The other actions — **Draft job description**, **Answer HR question**, and
|
||||
**Draft review feedback** — run the same way. A freeform box asks anything over
|
||||
the loaded records.
|
||||
|
||||
Every answer is grounded in the fenced Workday records with an honest note when
|
||||
the context was truncated to fit the model window.
|
||||
|
||||
---
|
||||
|
||||
## Write-back (documented, not in v1)
|
||||
|
||||
Workday writes (e.g. `POST …/requestTimeOff`, business-process submissions) mutate
|
||||
the system of record and trigger approval workflows. v1 is intentionally
|
||||
read-only: the proxy accepts `GET` only and returns `405` for other methods. A
|
||||
write-back — e.g. drafting a job requisition and submitting it — is a deliberate
|
||||
future addition that must be gated behind an explicit user confirmation, a
|
||||
narrowly-scoped API Client permission, and an audit log. It is documented here so
|
||||
its absence is a decision, not a gap.
|
||||
|
||||
---
|
||||
|
||||
## Build & run
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @hanzo/workday build # → dist/ (panel + server)
|
||||
pnpm --filter @hanzo/workday test # vitest
|
||||
pnpm --filter @hanzo/workday typecheck # tsc --noEmit
|
||||
|
||||
# run the service
|
||||
WORKDAY_HOST=https://wd2-impl-services1.workday.com \
|
||||
WORKDAY_TENANT=acme \
|
||||
WORKDAY_CLIENT_ID=… \
|
||||
WORKDAY_CLIENT_SECRET=… \
|
||||
WORKDAY_REDIRECT_URI=https://workday.hanzo.ai/oauth/callback \
|
||||
WORKDAY_ENV=sandbox \
|
||||
node packages/workday/dist/server.js
|
||||
```
|
||||
|
||||
### Required environment (service)
|
||||
|
||||
| Var | Notes |
|
||||
| --- | --- |
|
||||
| `WORKDAY_HOST` | Tenant datacenter host origin, e.g. `https://wd2-impl-services1.workday.com`. |
|
||||
| `WORKDAY_TENANT` | Tenant name, e.g. `acme`. |
|
||||
| `WORKDAY_CLIENT_ID` | API Client id (public). |
|
||||
| `WORKDAY_CLIENT_SECRET` | **Server only.** From KMS in production. |
|
||||
| `WORKDAY_REDIRECT_URI` | Must match the API Client's registered redirect. |
|
||||
| `WORKDAY_ENV` | `sandbox` (default) or `production` — a label only. |
|
||||
| `PORT` | Listen port (default `8791`). |
|
||||
|
||||
`readServerConfig` throws on any missing value — the service refuses to start
|
||||
rather than pretend it can address the tenant or complete an OAuth exchange.
|
||||
|
||||
---
|
||||
|
||||
## Deploy over hanzoai/ingress
|
||||
|
||||
Host the static panel (`dist/index.html`, `app.js`, `styles.css`) with the
|
||||
**hanzoai/static** plugin and run `dist/server.js` as a small service, both behind
|
||||
**hanzoai/ingress** at `workday.hanzo.ai` (no nginx, no caddy):
|
||||
|
||||
- `/` and the static assets → the panel.
|
||||
- `/oauth/*`, `/proxy/*`, `/healthz` → the service.
|
||||
|
||||
Secrets come from **KMS** as `KMSSecret`-synced env; sessions from **Valkey**
|
||||
(`hanzoai/kv`) for multi-instance. Image published to
|
||||
`ghcr.io/hanzoai/workday:<tag>` by CI/CD (platform.hanzo.ai / Tekton) — never
|
||||
built locally.
|
||||
|
||||
---
|
||||
|
||||
*Routed through `api.hanzo.ai`. Answers are grounded in your Workday records —
|
||||
read-only, informational HR support, not an employment, compensation, legal, or
|
||||
disciplinary decision.*
|
||||
@@ -0,0 +1,86 @@
|
||||
// Build @hanzo/workday into dist/: bundle the web panel (app.ts) as ESM for the
|
||||
// browser, copy index.html (with its entry script stamped) + styles.css, and
|
||||
// bundle the OAuth + API-proxy server for Node. No framework — esbuild + Node
|
||||
// stdlib only. @hanzo/ai is bundled into both outputs (it is the headless client
|
||||
// the panel and server both call).
|
||||
//
|
||||
// node build.js → production build
|
||||
// node build.js --watch → rebuild on change
|
||||
// HANZO_WORKDAY_BASE=https://localhost:8443 node build.js → dev base (informational)
|
||||
//
|
||||
// Output layout:
|
||||
// dist/index.html dist/app.js dist/styles.css (panel)
|
||||
// dist/server.js (service)
|
||||
//
|
||||
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
|
||||
// workday.hanzo.ai; the server runs as a small service (OAuth callback + API
|
||||
// proxy). All model calls go to api.hanzo.ai regardless of the host base.
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASE = (process.env.HANZO_WORKDAY_BASE || 'https://workday.hanzo.ai').replace(/\/+$/, '');
|
||||
const watch = process.argv.includes('--watch');
|
||||
const src = join(__dirname, 'src');
|
||||
const dist = join(__dirname, 'dist');
|
||||
|
||||
async function build() {
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist, { recursive: true });
|
||||
|
||||
// Bundle the panel as ESM for the browser.
|
||||
const panelCtx = await esbuild.context({
|
||||
entryPoints: { app: join(src, 'app.ts') },
|
||||
outdir: dist,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
logLevel: 'info',
|
||||
});
|
||||
await panelCtx.rebuild();
|
||||
|
||||
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
|
||||
const html = readFileSync(join(src, 'index.html'), 'utf8')
|
||||
.split('__ENTRY__').join('app.js')
|
||||
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
|
||||
writeFileSync(join(dist, 'index.html'), html);
|
||||
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
|
||||
|
||||
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
|
||||
const serverCtx = await esbuild.context({
|
||||
entryPoints: [join(src, 'server.ts')],
|
||||
outfile: join(dist, 'server.js'),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node18'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
|
||||
logLevel: 'info',
|
||||
});
|
||||
await serverCtx.rebuild();
|
||||
|
||||
console.log(`Hanzo AI for Workday built -> dist/ (base ${BASE})`);
|
||||
console.log(' Panel: dist/index.html · Service: dist/server.js');
|
||||
|
||||
if (watch) {
|
||||
await panelCtx.watch();
|
||||
await serverCtx.watch();
|
||||
console.log('watching...');
|
||||
} else {
|
||||
await panelCtx.dispose();
|
||||
await serverCtx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@hanzo/workday",
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo AI for Workday — AI over your people data: summarize a worker or team, draft a job description / requisition, summarize an org and headcount, answer HR / policy questions, and draft review feedback. An embedded-app panel plus a read-only OAuth + API-proxy service, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"watch": "node build.js --watch",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.0",
|
||||
"@hanzo/iam": "^0.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"workday",
|
||||
"hcm",
|
||||
"hr",
|
||||
"people",
|
||||
"ai",
|
||||
"oauth",
|
||||
"raas"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git",
|
||||
"directory": "packages/workday"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// The AI actions over Workday people records — summarize a worker/team, draft a
|
||||
// job description / requisition, summarize an org / headcount, answer an HR /
|
||||
// policy question, and draft review feedback. Each is a prompt template applied to
|
||||
// the windowed people context via the single `ask` primitive in hanzo.ts. There
|
||||
// is exactly ONE code path to the model: an action is (id → prompt), and the panel
|
||||
// and any server-side caller resolve an id here and call `ask`. No action speaks
|
||||
// to the gateway directly. (A freeform question is `ask` directly, not an action —
|
||||
// it has no fixed prompt.)
|
||||
|
||||
import { ask, type AskOptions, type PeopleMeta } from './hanzo.js';
|
||||
import type { PeopleContext } from './people.js';
|
||||
|
||||
// The action catalog. `id` is the stable key the panel's chips use; `label` is the
|
||||
// button text; `prompt` is the instruction handed to the model as the task, over
|
||||
// the fenced people records. Prompts are specific, output-shaped (bullets,
|
||||
// structured lists), and carry the HR guardrails (no protected characteristics, no
|
||||
// invented facts, informational only) so results are consistent and safe.
|
||||
export const ACTIONS = {
|
||||
summarizeWorker: {
|
||||
label: 'Summarize worker',
|
||||
prompt:
|
||||
'Summarize the worker profile(s) in these records for an HR business ' +
|
||||
'partner. Cover: role and business title, the organization and manager, ' +
|
||||
'location, and whether they manage people. If direct reports are shown, note ' +
|
||||
'team size and the notable roles. Use short bullets, reference each worker by ' +
|
||||
'name, ground every statement in the records, and do not infer compensation, ' +
|
||||
'performance, or any protected characteristic. No preamble. If more than one ' +
|
||||
'worker is shown, summarize each briefly.',
|
||||
},
|
||||
draftJobDescription: {
|
||||
label: 'Draft job description',
|
||||
prompt:
|
||||
'Draft a clear, inclusive job description / requisition for the role ' +
|
||||
'indicated by these records (use the requisition title, organization, and ' +
|
||||
'location when present). Include: a one-paragraph role summary, key ' +
|
||||
'responsibilities as bullets, and required and preferred qualifications as ' +
|
||||
'bullets. Use neutral, bias-free language free of age, gender, or other ' +
|
||||
'protected-characteristic coded terms, and do not invent compensation, ' +
|
||||
'benefits, or requirements the records do not support. Return only the job ' +
|
||||
'description, ready to paste into a Workday requisition.',
|
||||
},
|
||||
summarizeOrg: {
|
||||
label: 'Org & headcount',
|
||||
prompt:
|
||||
'Write a concise organization and headcount summary from these records for ' +
|
||||
'an HR leader. Cover: the organization and its manager, total headcount and ' +
|
||||
'how the workers break down by role or sub-team, any open requisitions and ' +
|
||||
'their openings, and anything the time-off records indicate about current ' +
|
||||
'coverage. Structure as a one-line headline followed by grouped bullets. Base ' +
|
||||
'every number and statement strictly on the records and say plainly if the ' +
|
||||
'records look incomplete.',
|
||||
},
|
||||
answerHrQuestion: {
|
||||
label: 'Answer HR question',
|
||||
prompt:
|
||||
'Answer the HR or policy question using these Workday records and general HR ' +
|
||||
'best practice. Ground any employee-specific claim in the records provided ' +
|
||||
'and cite the worker or organization it comes from; where the answer depends ' +
|
||||
'on a company policy document not included here, state exactly what policy or ' +
|
||||
'approval is needed rather than inventing one. Be concise and neutral, and ' +
|
||||
'note that the answer is informational and may require review by HR or legal ' +
|
||||
'before any action is taken.',
|
||||
},
|
||||
draftReviewFeedback: {
|
||||
label: 'Draft review feedback',
|
||||
prompt:
|
||||
'Draft constructive, balanced performance-review feedback for the worker in ' +
|
||||
'these records, written from a manager to the employee. Use only what the ' +
|
||||
'records support (role, responsibilities, organization) and clearly frame ' +
|
||||
'observations as prompts the manager should confirm with specific examples, ' +
|
||||
'since these records do not contain performance ratings. Cover strengths, ' +
|
||||
'areas for growth, and forward-looking goals, in neutral and respectful ' +
|
||||
'language free of any protected-characteristic reference. Return only the ' +
|
||||
'feedback draft.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// An action id from the catalog.
|
||||
export type ActionId = keyof typeof ACTIONS;
|
||||
|
||||
// isActionId narrows an arbitrary string to a known action id. Boundary guard —
|
||||
// the panel and the server validate an inbound id here before running it.
|
||||
export function isActionId(id: string): id is ActionId {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
|
||||
}
|
||||
|
||||
// actionPrompt resolves an action id to its prompt template. Throws on an unknown
|
||||
// id (a boundary error, surfaced to the caller) rather than silently running a
|
||||
// default. Pure.
|
||||
export function actionPrompt(id: string): string {
|
||||
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
|
||||
return ACTIONS[id].prompt;
|
||||
}
|
||||
|
||||
// actionList is the ordered catalog for building the UI (chips) — id + label,
|
||||
// derived from ACTIONS so the panel and the catalog can never drift. Pure.
|
||||
export function actionList(): Array<{ id: ActionId; label: string }> {
|
||||
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
|
||||
}
|
||||
|
||||
// runAction is the single entry point every surface calls: resolve the action's
|
||||
// prompt and run it over the windowed people context via `ask`. This is the one
|
||||
// code path from an action id to the model. Async so an unknown id surfaces as a
|
||||
// rejected promise (not a synchronous throw), giving callers ONE way to handle
|
||||
// failure: `await`/`.catch`. A gateway error rejects via `ask`.
|
||||
export async function runAction(
|
||||
id: string,
|
||||
ctx: PeopleContext,
|
||||
meta?: PeopleMeta,
|
||||
opts: AskOptions = {},
|
||||
): Promise<string> {
|
||||
return ask(actionPrompt(id), ctx, meta, opts);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// The Workday panel glue: the embedded/linked-app page that opens over a tenant
|
||||
// (optionally deep-linked to a worker or organization) and drives the AI actions
|
||||
// over the tenant's live people records. All the logic-heavy work (action prompts,
|
||||
// context windowing, chat shaping, proxy request shaping, auth) lives in its own
|
||||
// tested modules; this file binds them to the DOM. It is the one impure,
|
||||
// browser-only entry point.
|
||||
//
|
||||
// Data flow: the panel reads Workday ONLY through the same-origin server proxy
|
||||
// (createProxyClient), which holds the OAuth token + tenant address server-side.
|
||||
// It pulls the selected worker's profile + direct reports + organizations +
|
||||
// requisitions (+ eligible absence types), assembles a windowed context
|
||||
// (buildPeopleContext), and runs an action or a freeform question against
|
||||
// api.hanzo.ai with the pasted Hanzo key. v1 is read-only — there is no write-back.
|
||||
|
||||
import { actionList, isActionId, runAction } from './actions.js';
|
||||
import { ask as askHanzo, listModels, type PeopleMeta } from './hanzo.js';
|
||||
import {
|
||||
buildPeopleContext,
|
||||
contextNote,
|
||||
workerSection,
|
||||
directReportSection,
|
||||
organizationSection,
|
||||
requisitionSection,
|
||||
absenceTypeSection,
|
||||
type PeopleContext,
|
||||
} from './people.js';
|
||||
import { DEFAULT_MODEL } from './config.js';
|
||||
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
|
||||
import { createProxyClient, parseLaunchContext, type ProxyClient } from './panel.js';
|
||||
import type { Worker } from './workday-api.js';
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const launch = parseLaunchContext(window.location.search);
|
||||
|
||||
const workerEl = $<HTMLSelectElement>('worker');
|
||||
const loadBtn = $<HTMLButtonElement>('load');
|
||||
const refreshBtn = $<HTMLButtonElement>('refresh');
|
||||
const recordsEl = $('records');
|
||||
const outputEl = $<HTMLTextAreaElement>('output');
|
||||
const statusEl = $('status');
|
||||
const modelEl = $<HTMLSelectElement>('model');
|
||||
const chipRow = $('chips');
|
||||
const runBtn = $<HTMLButtonElement>('run');
|
||||
const stopBtn = $<HTMLButtonElement>('stop');
|
||||
const promptEl = $<HTMLTextAreaElement>('prompt');
|
||||
const apiKeyEl = $<HTMLInputElement>('apikey');
|
||||
const saveKeyBtn = $<HTMLButtonElement>('savekey');
|
||||
const authHint = $('authhint');
|
||||
|
||||
apiKeyEl.value = getApiKey();
|
||||
reflectAuth();
|
||||
void populateModels();
|
||||
|
||||
// Loaded people state: the records that make up the current context, plus the
|
||||
// worker we're scoped to.
|
||||
let ctx: PeopleContext | null = null;
|
||||
let meta: PeopleMeta = {};
|
||||
|
||||
// Action chips — derived from the catalog so UI and logic never drift.
|
||||
for (const a of actionList()) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'chip';
|
||||
b.textContent = a.label;
|
||||
b.dataset.action = a.id;
|
||||
b.onclick = () => void run(a.id);
|
||||
chipRow.appendChild(b);
|
||||
}
|
||||
|
||||
// client builds a proxy client over the same-origin server proxy.
|
||||
function client(): ProxyClient {
|
||||
return createProxyClient();
|
||||
}
|
||||
|
||||
loadBtn.onclick = () => void loadWorker();
|
||||
refreshBtn.onclick = () => void populateWorkers();
|
||||
runBtn.onclick = () => {
|
||||
const prompt = promptEl.value.trim();
|
||||
if (prompt) void ask(prompt);
|
||||
};
|
||||
stopBtn.onclick = () => controller?.abort();
|
||||
saveKeyBtn.onclick = async () => {
|
||||
const key = apiKeyEl.value.trim();
|
||||
setStatus('Checking key…');
|
||||
try {
|
||||
const models = await validateKey(key);
|
||||
setApiKey(key);
|
||||
fillModels(models);
|
||||
reflectAuth();
|
||||
setStatus('Key saved.', 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Key rejected.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// List the tenant's workers on open; if we launched with a worker, load it.
|
||||
void populateWorkers().then(() => {
|
||||
if (launch.workerId) {
|
||||
workerEl.value = launch.workerId;
|
||||
void loadWorker();
|
||||
}
|
||||
});
|
||||
|
||||
// populateWorkers fills the worker picker from the proxy. An auth failure (the
|
||||
// app isn't connected) leaves the picker empty with a status hint.
|
||||
async function populateWorkers(): Promise<void> {
|
||||
setStatus('Loading workers…');
|
||||
try {
|
||||
const { items, total } = await client().listWorkers({ limit: 100 });
|
||||
fillWorkers(items);
|
||||
setStatus(
|
||||
items.length
|
||||
? `Loaded ${items.length}${total > items.length ? ` of ${total}` : ''} workers.`
|
||||
: 'No workers visible.',
|
||||
items.length ? 'ok' : 'warn',
|
||||
);
|
||||
} catch (e: any) {
|
||||
setStatus(
|
||||
e?.message || 'Could not load workers — is the app connected? (Connect on workday.hanzo.ai)',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// loadWorker pulls the selected worker + direct reports + organizations +
|
||||
// requisitions + eligible absence types and assembles the context the actions
|
||||
// run over. Each secondary fetch is tolerated so a worker missing one facet
|
||||
// still yields a usable context.
|
||||
async function loadWorker(): Promise<void> {
|
||||
const workerId = workerEl.value;
|
||||
if (!workerId) {
|
||||
setStatus('Pick a worker first.', 'warn');
|
||||
return;
|
||||
}
|
||||
setStatus('Loading Workday records…');
|
||||
const c = client();
|
||||
const [worker, reports, orgs, reqs, absence] = await Promise.all([
|
||||
c.getWorker(workerId).catch(() => null),
|
||||
c.listDirectReports(workerId, { limit: 100 }).then((r) => r.items).catch(() => [] as Worker[]),
|
||||
c.listOrganizations({ limit: 100 }).then((r) => r.items).catch(() => []),
|
||||
c.listRequisitions({ limit: 100 }).then((r) => r.items).catch(() => []),
|
||||
c.listAbsenceTypes(workerId, { limit: 100 }).then((r) => r.items).catch(() => []),
|
||||
]);
|
||||
|
||||
ctx = buildPeopleContext([
|
||||
workerSection(worker ? [worker] : []),
|
||||
directReportSection(reports),
|
||||
organizationSection(orgs),
|
||||
requisitionSection(reqs),
|
||||
absenceTypeSection(absence),
|
||||
]);
|
||||
meta = {
|
||||
subject: worker?.name || undefined,
|
||||
organization: worker?.organization || undefined,
|
||||
};
|
||||
|
||||
recordsEl.textContent =
|
||||
`${worker ? '1 worker' : 'no worker'} · ${reports.length} direct reports · ${orgs.length} orgs · ${reqs.length} requisitions · ${absence.length} absence types`;
|
||||
setStatus(contextNote(ctx));
|
||||
}
|
||||
|
||||
// run executes one of the named actions over the loaded people context.
|
||||
async function run(actionId: string): Promise<void> {
|
||||
if (!isActionId(actionId)) return;
|
||||
await execute((c, m, opts) => runAction(actionId, c, m, opts));
|
||||
}
|
||||
|
||||
// ask executes a freeform question over the loaded people context.
|
||||
async function ask(prompt: string): Promise<void> {
|
||||
await execute((c, m, opts) => askHanzo(prompt, c, m, opts));
|
||||
}
|
||||
|
||||
// execute is the shared runner: require a loaded context, call the model, show
|
||||
// the result. Both the chips and freeform ask funnel through here (one path).
|
||||
async function execute(
|
||||
call: (
|
||||
c: PeopleContext,
|
||||
m: PeopleMeta,
|
||||
opts: { token: string; model: string; signal: AbortSignal },
|
||||
) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!ctx || ctx.totalBlocks === 0) {
|
||||
setStatus('Load a worker first (pick one and press Load).', 'warn');
|
||||
return;
|
||||
}
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
setBusy(true);
|
||||
setStatus(contextNote(ctx));
|
||||
outputEl.value = '';
|
||||
try {
|
||||
const text = await call(ctx, meta, {
|
||||
token: bearer(),
|
||||
model: modelEl.value || DEFAULT_MODEL,
|
||||
signal: controller.signal,
|
||||
});
|
||||
outputEl.value = text;
|
||||
setStatus('Done.', 'ok');
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') setStatus('Stopped.');
|
||||
else setStatus(e?.message || 'Request failed.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- small DOM helpers ----------------------------------------------------
|
||||
|
||||
function fillWorkers(items: Worker[]): void {
|
||||
workerEl.innerHTML = '';
|
||||
for (const w of items) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.id;
|
||||
opt.textContent = w.businessTitle ? `${w.name} — ${w.businessTitle}` : w.name || w.id;
|
||||
workerEl.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function populateModels(): Promise<void> {
|
||||
try {
|
||||
fillModels(await listModels({ token: bearer() }));
|
||||
} catch {
|
||||
fillModels([DEFAULT_MODEL]);
|
||||
}
|
||||
}
|
||||
function fillModels(ids: string[]): void {
|
||||
const list = ids.length ? ids : [DEFAULT_MODEL];
|
||||
modelEl.innerHTML = '';
|
||||
for (const id of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
modelEl.appendChild(opt);
|
||||
}
|
||||
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function reflectAuth(): void {
|
||||
authHint.textContent = hasApiKey()
|
||||
? 'Using your saved Hanzo key.'
|
||||
: 'No key saved — using public models. Paste an hk-… key for your org models.';
|
||||
}
|
||||
|
||||
function setBusy(b: boolean): void {
|
||||
runBtn.disabled = b;
|
||||
stopBtn.disabled = !b;
|
||||
loadBtn.disabled = b;
|
||||
refreshBtn.disabled = b;
|
||||
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
|
||||
}
|
||||
|
||||
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = `status${kind ? ' ' + kind : ''}`;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Auth for the web panel: the zero-setup pasted-key path for the Hanzo gateway.
|
||||
// The Workday panel is a static web page (iframe), so the Hanzo credential lives
|
||||
// in localStorage and is validated by a real /v1/models call — a key that can't
|
||||
// list models is rejected before it's saved, so the user learns at paste time,
|
||||
// not at first action. Mirrors @hanzo/procore exactly (one way to hold a key).
|
||||
//
|
||||
// The Workday access token (for reading workers/orgs/requisitions) is a SEPARATE
|
||||
// credential minted server-side by the OAuth flow and held by server.ts; the panel
|
||||
// reaches Workday only through the server proxy (with its session cookie), so it
|
||||
// never holds the Workday token or secret. This module is only the Hanzo-gateway
|
||||
// bearer.
|
||||
|
||||
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
|
||||
import { listModels } from './hanzo.js';
|
||||
|
||||
export function getApiKey(): string {
|
||||
try {
|
||||
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): void {
|
||||
try {
|
||||
const k = key.trim();
|
||||
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
|
||||
else localStorage.removeItem(APIKEY_STORAGE_KEY);
|
||||
} catch {
|
||||
/* private-mode / storage disabled — the in-memory key still works this session */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
setApiKey('');
|
||||
}
|
||||
|
||||
export function hasApiKey(): boolean {
|
||||
return !!getApiKey();
|
||||
}
|
||||
|
||||
// bearer is the credential the chat call sends: the pasted key (or empty for
|
||||
// anonymous public models). When Hanzo OAuth lands it slots in as the second
|
||||
// argument to pickBearer with no change to callers.
|
||||
export function bearer(): string {
|
||||
return pickBearer(getApiKey(), '');
|
||||
}
|
||||
|
||||
// validateKey confirms a pasted key actually works by listing models with it.
|
||||
// Returns the models on success so the caller populates the picker in one
|
||||
// round-trip; throws with the gateway's message on failure.
|
||||
export async function validateKey(key: string): Promise<string[]> {
|
||||
const k = key.trim();
|
||||
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
|
||||
return listModels({ token: k });
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Workday config — the tenant address (host + tenant name), the REST/RaaS
|
||||
// endpoint builders, the api.hanzo.ai model gateway, and the server-side secret
|
||||
// set (Workday OAuth). Endpoints and the bearer choice mirror @hanzo/procore and
|
||||
// @hanzo/docusign so the productivity suite stays DRY; the HR-specific pieces
|
||||
// (people-context windowing, the AI actions) live in people.ts / hanzo.ts /
|
||||
// actions.ts, not here.
|
||||
//
|
||||
// Unlike Procore (a fixed production/sandbox host pair), a Workday customer runs
|
||||
// on a specific datacenter host (e.g. https://wd2-impl-services1.workday.com) with
|
||||
// a named tenant (e.g. `acme`). Both are deployment config, not hard-coded — every
|
||||
// URL is built from `host` + `tenant`, so pointing at a different tenant is one
|
||||
// env change. Implementation ("sandbox") tenants carry `-impl` in the host.
|
||||
|
||||
// ---- Hanzo model gateway --------------------------------------------------
|
||||
|
||||
// Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1 only,
|
||||
// never an /api/ prefix (api.hanzo.ai IS the api host).
|
||||
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
|
||||
|
||||
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
|
||||
// the gateway routes it.
|
||||
export const DEFAULT_MODEL = 'zen5';
|
||||
|
||||
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
|
||||
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
|
||||
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
|
||||
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-workday';
|
||||
|
||||
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
|
||||
// panel is a static web page (iframe), not a host with roamingSettings, so the
|
||||
// zero-setup credential lives in localStorage — same as @hanzo/procore.
|
||||
export const APIKEY_STORAGE_KEY = 'hanzo.workday.apiKey';
|
||||
|
||||
// People text budget. A tenant has thousands of workers; the whole corpus won't
|
||||
// fit a model window and shouldn't be sent. This caps the characters of Workday
|
||||
// text we attach to any one request — chosen so it fits comfortably inside a
|
||||
// modern context window alongside the reply, and is honest rather than optimal
|
||||
// (we truncate visibly, never drop silently).
|
||||
export const PEOPLE_CHAR_BUDGET = 60_000;
|
||||
|
||||
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API
|
||||
// key wins over an OAuth token (an explicit key is a deliberate override), else
|
||||
// the token, else empty (anonymous — the gateway still serves public models).
|
||||
// Pure — unit-tested.
|
||||
export function pickBearer(apiKey: string, oauthToken: string): string {
|
||||
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
|
||||
}
|
||||
|
||||
// chatCompletionsURL / modelsURL — the model gateway endpoints.
|
||||
export function chatCompletionsURL(): string {
|
||||
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
|
||||
}
|
||||
export function modelsURL(): string {
|
||||
return `${HANZO_API_BASE_URL}/v1/models`;
|
||||
}
|
||||
|
||||
// ---- Workday tenant address -----------------------------------------------
|
||||
|
||||
// normHost strips a trailing slash so `${host}${path}` never doubles the slash.
|
||||
// The ONE place a Workday host is normalized. Pure.
|
||||
export function normHost(host: string): string {
|
||||
return host.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// A Workday REST API is addressed by a service name + version, both in the path.
|
||||
// Workday versions each service independently (staffing v6, recruiting v4,
|
||||
// absenceManagement v1), so the pair travels together.
|
||||
export interface WorkdayService {
|
||||
api: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// The REST services this app reads. One value per service — we never bump to a
|
||||
// speculative version; when Workday ships a new version we change it here once.
|
||||
export const STAFFING: WorkdayService = { api: 'staffing', version: 'v6' };
|
||||
export const RECRUITING: WorkdayService = { api: 'recruiting', version: 'v4' };
|
||||
export const ABSENCE: WorkdayService = { api: 'absenceManagement', version: 'v1' };
|
||||
export const COMMON: WorkdayService = { api: 'common', version: 'v1' };
|
||||
|
||||
// oauthBaseUrl is the tenant's OAuth 2.0 root: `${host}/ccx/oauth2/{tenant}`. The
|
||||
// /authorize and /token endpoints hang off it (built in workday-oauth.ts). Pure.
|
||||
export function oauthBaseUrl(host: string, tenant: string): string {
|
||||
return `${normHost(host)}/ccx/oauth2/${tenant}`;
|
||||
}
|
||||
|
||||
// restPath builds the host-relative REST path for a service + sub-path:
|
||||
// `/ccx/api/{api}/{version}/{tenant}{sub}`. Host-agnostic on purpose so the same
|
||||
// builder serves the server (which prepends the host) and any path-only consumer.
|
||||
// Pure — unit-tested.
|
||||
export function restPath(tenant: string, service: WorkdayService, sub = ''): string {
|
||||
return `/ccx/api/${service.api}/${service.version}/${tenant}${sub}`;
|
||||
}
|
||||
|
||||
// restUrl is the absolute REST URL for a service + sub-path on a host. Pure.
|
||||
export function restUrl(host: string, tenant: string, service: WorkdayService, sub = ''): string {
|
||||
return `${normHost(host)}${restPath(tenant, service, sub)}`;
|
||||
}
|
||||
|
||||
// raasPath builds the host-relative Report-as-a-Service path for a custom report:
|
||||
// `/ccx/service/customreport2/{tenant}/{owner}/{report}`. `owner` is the report
|
||||
// owner's username (the ISU/account the report is shared from). Pure.
|
||||
export function raasPath(tenant: string, owner: string, report: string): string {
|
||||
return `/ccx/service/customreport2/${tenant}/${owner}/${report}`;
|
||||
}
|
||||
|
||||
// raasUrl is the absolute RaaS URL for a custom report on a host. Pure.
|
||||
export function raasUrl(host: string, tenant: string, owner: string, report: string): string {
|
||||
return `${normHost(host)}${raasPath(tenant, owner, report)}`;
|
||||
}
|
||||
|
||||
// The OAuth scopes the app requests. Workday's Authorization Code grant governs
|
||||
// access by the API Client's registered functional-area scope in the tenant, so
|
||||
// the authorize call omits `scope` for a standard client; it is only appended
|
||||
// when non-empty, for forward-compat with fine-grained scopes.
|
||||
export const OAUTH_SCOPES = [] as const;
|
||||
|
||||
// ---- Environment label ----------------------------------------------------
|
||||
|
||||
// Which environment a deployment targets. Purely a label for the UI/logs —
|
||||
// Workday has no fixed host per environment (each datacenter differs); sandbox is
|
||||
// an implementation tenant (host carries `-impl`).
|
||||
export type WorkdayEnv = 'sandbox' | 'production';
|
||||
|
||||
// isWorkdayEnv narrows an arbitrary string to a WorkdayEnv, else undefined-safe.
|
||||
export function isWorkdayEnv(v: string | undefined): v is WorkdayEnv {
|
||||
return v === 'sandbox' || v === 'production';
|
||||
}
|
||||
|
||||
// isSandboxHost reports whether a Workday host is an implementation ("sandbox")
|
||||
// tenant — Workday implementation hosts carry `-impl` (e.g. wd2-impl-services1).
|
||||
// Used to sanity-check that env label and host agree. Pure.
|
||||
export function isSandboxHost(host: string): boolean {
|
||||
return /-impl/i.test(host);
|
||||
}
|
||||
|
||||
// ---- Server-side configuration (Workday OAuth) ----------------------------
|
||||
//
|
||||
// Read from the environment by src/server.ts. These NEVER reach the browser
|
||||
// bundle: the client id + host + tenant are effectively public, but the client
|
||||
// secret is server-only and is validated to be present before the server will
|
||||
// start (readServerConfig throws on a missing secret). This is the ONLY place the
|
||||
// secret exists.
|
||||
|
||||
export interface ServerConfig {
|
||||
/** Workday datacenter host origin, e.g. https://wd2-impl-services1.workday.com */
|
||||
workdayHost: string;
|
||||
/** Workday tenant name, e.g. acme */
|
||||
workdayTenant: string;
|
||||
/** Workday OAuth client id (public). */
|
||||
workdayClientId: string;
|
||||
/** Workday OAuth client secret (SERVER ONLY — token exchange + refresh). */
|
||||
workdayClientSecret: string;
|
||||
/** OAuth redirect registered on the API Client (e.g. https://workday.hanzo.ai/oauth/callback). */
|
||||
workdayRedirectUri: string;
|
||||
/** Environment label: 'sandbox' (implementation) or 'production'. */
|
||||
workdayEnv: WorkdayEnv;
|
||||
/** Listen port. */
|
||||
port: number;
|
||||
}
|
||||
|
||||
// readServerConfig fails fast (throws) if a required Workday value is missing — a
|
||||
// server that cannot address the tenant or exchange OAuth codes must not pretend
|
||||
// to start. Pure given an env map, so it is unit-tested without touching
|
||||
// process.env. The host is normalized here at the one boundary.
|
||||
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
|
||||
const workdayHost = env.WORKDAY_HOST;
|
||||
const workdayTenant = env.WORKDAY_TENANT;
|
||||
const workdayClientId = env.WORKDAY_CLIENT_ID;
|
||||
const workdayClientSecret = env.WORKDAY_CLIENT_SECRET;
|
||||
const workdayRedirectUri = env.WORKDAY_REDIRECT_URI;
|
||||
const missing: string[] = [];
|
||||
if (!workdayHost) missing.push('WORKDAY_HOST');
|
||||
if (!workdayTenant) missing.push('WORKDAY_TENANT');
|
||||
if (!workdayClientId) missing.push('WORKDAY_CLIENT_ID');
|
||||
if (!workdayClientSecret) missing.push('WORKDAY_CLIENT_SECRET');
|
||||
if (!workdayRedirectUri) missing.push('WORKDAY_REDIRECT_URI');
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment: ${missing.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
workdayHost: normHost(workdayHost!),
|
||||
workdayTenant: workdayTenant!,
|
||||
workdayClientId: workdayClientId!,
|
||||
workdayClientSecret: workdayClientSecret!,
|
||||
workdayRedirectUri: workdayRedirectUri!,
|
||||
workdayEnv: isWorkdayEnv(env.WORKDAY_ENV) ? env.WORKDAY_ENV : 'sandbox',
|
||||
port: Number(env.PORT) || 8791,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// The Hanzo call and its request/response shaping over Workday PEOPLE records —
|
||||
// thin over the published `@hanzo/ai` headless client, host-agnostic, and fully
|
||||
// unit-testable (no Workday SDK, no DOM). The panel and the server are the glue
|
||||
// that read a tenant's records and hand them here. Speaks the OpenAI-compatible
|
||||
// /v1 wire protocol the whole Hanzo suite uses (identical to @hanzo/procore) so
|
||||
// the gateway sees one shape from every surface.
|
||||
//
|
||||
// @hanzo/ai@^0.2.0 IS the headless client: createAiClient({ baseUrl, token })
|
||||
// → .chat.completions.create({ model, messages }) + .models.list(). We import it
|
||||
// and do NOT reimplement the transport. This module adds only the people-aware
|
||||
// prompt assembly + the single `ask` primitive the actions layer on.
|
||||
|
||||
import {
|
||||
createAiClient,
|
||||
type AiClient,
|
||||
type ChatCompletion,
|
||||
type ChatCompletionMessage,
|
||||
} from '@hanzo/ai';
|
||||
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
|
||||
import { contextNote, type PeopleContext } from './people.js';
|
||||
|
||||
// SYSTEM_PROMPT grounds every answer in the Workday records and, critically for
|
||||
// HR, guards the personal data they contain. It forbids inventing facts nobody
|
||||
// recorded, forbids inferring protected characteristics / health / performance
|
||||
// beyond the records, and states plainly that the output is informational support
|
||||
// for HR professionals — not an employment, compensation, legal, or disciplinary
|
||||
// decision. A people tool that pretends otherwise is a liability.
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are Hanzo AI, an assistant that helps HR and people teams work with their ' +
|
||||
'Workday tenant — worker profiles, organizations, headcount, time off, and job ' +
|
||||
'requisitions. Work ONLY from the Workday records provided below — never invent ' +
|
||||
'people, titles, dates, compensation, or facts that are not supported by the ' +
|
||||
'records. These records contain personal employee data: handle it discreetly, ' +
|
||||
'never infer or output protected characteristics, health, or performance ' +
|
||||
'conclusions that are not present, and reference workers only as the records ' +
|
||||
'name them. Be concise, precise, and neutral. If the records are marked ' +
|
||||
'truncated and a complete answer needs the omitted part, say so plainly rather ' +
|
||||
'than guessing. You provide informational support to HR professionals — not ' +
|
||||
'employment, compensation, legal, or disciplinary decisions, which require human ' +
|
||||
'judgment and review.';
|
||||
|
||||
// ---- Prompt assembly ------------------------------------------------------
|
||||
|
||||
// Optional structured context about the scope (tenant, organization, subject
|
||||
// worker) the Workday API supplies. Attached as a short header above the records
|
||||
// so answers can reference the scope without the model guessing.
|
||||
export interface PeopleMeta {
|
||||
tenant?: string;
|
||||
organization?: string;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
function peopleHeader(meta: PeopleMeta | undefined): string {
|
||||
if (!meta) return '';
|
||||
const parts: string[] = [];
|
||||
if (meta.subject) parts.push(`Subject: ${meta.subject}`);
|
||||
if (meta.organization) parts.push(`Organization: ${meta.organization}`);
|
||||
if (meta.tenant) parts.push(`Tenant: ${meta.tenant}`);
|
||||
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
|
||||
}
|
||||
|
||||
// buildMessages turns a task (the user's question, or one of the action prompts)
|
||||
// plus the windowed people context + optional metadata into the OpenAI-compatible
|
||||
// message list. The records are fenced so the model treats them as data, not
|
||||
// instructions, and the honest context note rides inside the user turn so it is
|
||||
// never lost. Pure — unit-tested.
|
||||
export function buildMessages(
|
||||
task: string,
|
||||
ctx: PeopleContext,
|
||||
meta?: PeopleMeta,
|
||||
): ChatCompletionMessage[] {
|
||||
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
|
||||
const header = peopleHeader(meta);
|
||||
const user: ChatCompletionMessage = {
|
||||
role: 'user',
|
||||
content:
|
||||
`${header}${contextNote(ctx)}\n\n` +
|
||||
`---- workday records ----\n${ctx.text}\n---- end workday records ----\n\n` +
|
||||
`---- task ----\n${task}`,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
// extractContent pulls the assistant text out of a @hanzo/ai ChatCompletion,
|
||||
// tolerating the string-or-parts content shape the OpenAI schema allows. Throws
|
||||
// on empty content so callers surface the real gateway state. Pure — unit-tested.
|
||||
export function extractContent(res: ChatCompletion): string {
|
||||
const msg = res?.choices?.[0]?.message;
|
||||
const content = msg?.content;
|
||||
let text = '';
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content.map((part) => (part?.type === 'text' ? part.text : '')).join('');
|
||||
}
|
||||
if (text.length === 0) throw new Error('Hanzo API returned no content');
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---- The single call path -------------------------------------------------
|
||||
|
||||
export interface AskOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
token?: string;
|
||||
baseURL?: string;
|
||||
/** Injected client (tests). Defaults to a real createAiClient. */
|
||||
client?: AiClient;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// client resolves the @hanzo/ai client for a call: an injected one (tests) or a
|
||||
// fresh createAiClient pointed at the gateway with the caller's bearer.
|
||||
function client(opts: AskOptions): AiClient {
|
||||
return (
|
||||
opts.client ??
|
||||
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
|
||||
);
|
||||
}
|
||||
|
||||
// ask runs ONE non-streaming completion over the people context and returns the
|
||||
// answer. This is the single path from every Workday surface to the model — the
|
||||
// actions, a freeform question, and any server-side call all funnel here. token
|
||||
// may be empty (the gateway serves anonymous/limited models).
|
||||
export async function ask(
|
||||
task: string,
|
||||
ctx: PeopleContext,
|
||||
meta: PeopleMeta | undefined,
|
||||
opts: AskOptions = {},
|
||||
): Promise<string> {
|
||||
const res = await client(opts).chat.completions.create(
|
||||
{
|
||||
model: opts.model ?? DEFAULT_MODEL,
|
||||
messages: buildMessages(task, ctx, meta),
|
||||
stream: false,
|
||||
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||
},
|
||||
{ signal: opts.signal },
|
||||
);
|
||||
return extractContent(res);
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models via
|
||||
// the headless client. The endpoint is org-scoped by the bearer; an empty token
|
||||
// lists public models.
|
||||
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
|
||||
const models = await client(opts).models.list({ signal: opts.signal });
|
||||
return models.map((m) => m.id);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI for Workday</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="title">Hanzo AI</span>
|
||||
<select id="model" aria-label="Model"></select>
|
||||
<span class="host">Workday</span>
|
||||
</header>
|
||||
|
||||
<div class="row scope">
|
||||
<select id="worker" aria-label="Worker"></select>
|
||||
<button id="load" class="secondary">Load</button>
|
||||
<button id="refresh" class="secondary" title="Reload the worker list">Refresh</button>
|
||||
</div>
|
||||
<div class="records" id="records"></div>
|
||||
|
||||
<!-- One-click actions over the loaded worker/team. Chips are built from the catalog. -->
|
||||
<div class="chips" id="chips"></div>
|
||||
|
||||
<label for="prompt">Ask about these people</label>
|
||||
<textarea id="prompt" placeholder="e.g. Summarize this team and its open roles. · What time-off types is this worker eligible for? · Draft a job description for the open requisition."></textarea>
|
||||
|
||||
<div class="row">
|
||||
<button id="run">Ask Hanzo</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Hanzo API key</summary>
|
||||
<div class="row">
|
||||
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
|
||||
<button id="savekey" class="secondary">Save</button>
|
||||
</div>
|
||||
<div id="authhint" class="hint"></div>
|
||||
</details>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<label for="output">Result</label>
|
||||
<textarea id="output" placeholder="Hanzo's analysis appears here."></textarea>
|
||||
|
||||
<footer>Routed through api.hanzo.ai · grounded in your Workday records · read-only, informational HR support — not an employment, compensation, or legal decision.</footer>
|
||||
|
||||
<script type="module" src="__ENTRY__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
// Public surface of @hanzo/workday: the pure, host-agnostic modules. The browser
|
||||
// panel (app.ts) and the Node service (server.ts) are entry points, not
|
||||
// re-exported here. A consumer embedding the people-analysis pipeline in their own
|
||||
// service imports from here.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './workday-oauth.js';
|
||||
export * from './workday-api.js';
|
||||
export * from './people.js';
|
||||
export * from './hanzo.js';
|
||||
export * from './actions.js';
|
||||
@@ -0,0 +1,138 @@
|
||||
// Browser-side panel helpers — PURE where it counts (launch-context parsing +
|
||||
// proxy-request shaping), so the impure DOM wiring in app.ts stays thin and the
|
||||
// logic is unit-tested. The panel reaches Workday ONLY through the server proxy
|
||||
// (same-origin semantic routes under /proxy/*), which injects the OAuth Bearer +
|
||||
// refreshes it and owns the tenant/host; the browser never holds a Workday token
|
||||
// and never needs to know the tenant name.
|
||||
//
|
||||
// The proxy exposes RESOURCE routes (/proxy/workers, /proxy/organizations, …), not
|
||||
// raw Workday paths, so the tenant address stays server-side. This module maps
|
||||
// each resource route to its response parser.
|
||||
|
||||
import {
|
||||
parseWorkers,
|
||||
parseWorker,
|
||||
parseOrganizations,
|
||||
parseOrganization,
|
||||
parseJobRequisitions,
|
||||
parseAbsenceTypes,
|
||||
parseReportEntries,
|
||||
parseTotal,
|
||||
pageParams,
|
||||
type Worker,
|
||||
type Organization,
|
||||
type JobRequisition,
|
||||
type AbsenceType,
|
||||
type ReportRow,
|
||||
type Page,
|
||||
} from './workday-api.js';
|
||||
|
||||
// The panel launch context: an optional deep-link to a worker or organization in
|
||||
// the URL query. Both are optional — without them the user picks from the list.
|
||||
export interface LaunchContext {
|
||||
workerId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
// parseLaunchContext reads worker_id / organization_id from a location search
|
||||
// string, accepting camelCase fallbacks. Pure — unit-tested with plain strings.
|
||||
export function parseLaunchContext(search: string): LaunchContext {
|
||||
const q = new URLSearchParams(search);
|
||||
return {
|
||||
workerId: q.get('worker_id') ?? q.get('workerId') ?? '',
|
||||
organizationId: q.get('organization_id') ?? q.get('organizationId') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// The panel's Workday client: reads through the same-origin server proxy. Each
|
||||
// call rides the session cookie (credentials: 'include'); the proxy injects the
|
||||
// Bearer + the tenant address. Pure over an injected fetch + base, so request
|
||||
// shaping is unit-testable.
|
||||
export interface ProxyClient {
|
||||
listWorkers(page?: Page): Promise<{ items: Worker[]; total: number }>;
|
||||
getWorker(workerId: number | string): Promise<Worker>;
|
||||
listDirectReports(workerId: number | string, page?: Page): Promise<{ items: Worker[]; total: number }>;
|
||||
listOrganizations(page?: Page): Promise<{ items: Organization[]; total: number }>;
|
||||
getOrganization(orgId: number | string): Promise<Organization>;
|
||||
listRequisitions(page?: Page): Promise<{ items: JobRequisition[]; total: number }>;
|
||||
listAbsenceTypes(workerId: number | string, page?: Page): Promise<{ items: AbsenceType[]; total: number }>;
|
||||
report(owner: string, name: string, params?: Record<string, string>): Promise<ReportRow[]>;
|
||||
}
|
||||
|
||||
export interface ProxyClientOptions {
|
||||
/** Proxy base (defaults to same-origin ''). The resource route is appended after /proxy. */
|
||||
base?: string;
|
||||
/** Injected fetch (tests). Defaults to global fetch. */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
// proxyUrl builds a same-origin proxy URL for a resource route + query. Normalizes
|
||||
// the base (drops a trailing slash) so this is the ONE place base+route joining
|
||||
// happens. Kept small and pure so tests assert the exact shape
|
||||
// (…/proxy/workers?limit=…&offset=…).
|
||||
export function proxyUrl(base: string, route: string, query: Record<string, string> = {}): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') q.set(k, v);
|
||||
const qs = q.toString();
|
||||
return `${base.replace(/\/+$/, '')}/proxy${route}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
// createProxyClient returns a ProxyClient over the same-origin proxy. The cookie
|
||||
// credentials are attached once here so every method stays a one-liner.
|
||||
export function createProxyClient(opts: ProxyClientOptions = {}): ProxyClient {
|
||||
const base = opts.base ?? ''; // proxyUrl normalizes the trailing slash
|
||||
const doFetch = opts.fetch ?? fetch;
|
||||
|
||||
async function getJson(route: string, query: Record<string, string>): Promise<any> {
|
||||
const resp = await doFetch(proxyUrl(base, route, query), { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error(await proxyError(resp));
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
return {
|
||||
async listWorkers(page?) {
|
||||
const data = await getJson('/workers', pageParams(page));
|
||||
return { items: parseWorkers(data), total: parseTotal(data) };
|
||||
},
|
||||
async getWorker(workerId) {
|
||||
return parseWorker(await getJson(`/workers/${enc(workerId)}`, {}));
|
||||
},
|
||||
async listDirectReports(workerId, page?) {
|
||||
const data = await getJson(`/workers/${enc(workerId)}/directReports`, pageParams(page));
|
||||
return { items: parseWorkers(data), total: parseTotal(data) };
|
||||
},
|
||||
async listOrganizations(page?) {
|
||||
const data = await getJson('/organizations', pageParams(page));
|
||||
return { items: parseOrganizations(data), total: parseTotal(data) };
|
||||
},
|
||||
async getOrganization(orgId) {
|
||||
return parseOrganization(await getJson(`/organizations/${enc(orgId)}`, {}));
|
||||
},
|
||||
async listRequisitions(page?) {
|
||||
const data = await getJson('/requisitions', pageParams(page));
|
||||
return { items: parseJobRequisitions(data), total: parseTotal(data) };
|
||||
},
|
||||
async listAbsenceTypes(workerId, page?) {
|
||||
const data = await getJson(`/workers/${enc(workerId)}/absenceTypes`, pageParams(page));
|
||||
return { items: parseAbsenceTypes(data), total: parseTotal(data) };
|
||||
},
|
||||
async report(owner, name, params = {}) {
|
||||
return parseReportEntries(await getJson(`/report/${enc(owner)}/${enc(name)}`, params));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// proxyError extracts a readable message from a non-2xx proxy response.
|
||||
async function proxyError(resp: Response): Promise<string> {
|
||||
const text = await resp.text().catch(() => '');
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
return `Workday proxy ${resp.status}: ${j?.error || j?.message || text.slice(0, 200)}`;
|
||||
} catch {
|
||||
return `Workday proxy ${resp.status}: ${text.slice(0, 200) || 'request failed'}`;
|
||||
}
|
||||
}
|
||||
|
||||
function enc(segment: number | string): string {
|
||||
return encodeURIComponent(String(segment));
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// People-context assembly — PURE, host-agnostic, fully unit-testable. Turns the
|
||||
// typed Workday records (workers, organizations, requisitions, absence types,
|
||||
// time-off report rows) into the plain text an AI action reads, windowed to a
|
||||
// character budget so a large tenant never overflows the model or gets sent
|
||||
// silently truncated.
|
||||
//
|
||||
// There is ONE windowing contract, identical in spirit to @hanzo/procore's
|
||||
// project windowing: render ordered blocks, walk them in order, stop at the
|
||||
// budget, always include at least the first block, and report `truncated`
|
||||
// honestly. The only HR-specific part is how a record renders to a block.
|
||||
|
||||
import type {
|
||||
Worker,
|
||||
Organization,
|
||||
JobRequisition,
|
||||
AbsenceType,
|
||||
ReportRow,
|
||||
} from './workday-api.js';
|
||||
import { PEOPLE_CHAR_BUDGET } from './config.js';
|
||||
|
||||
// ---- Record → text block --------------------------------------------------
|
||||
|
||||
// renderWorker turns one worker into a labelled block. Only records-backed facts;
|
||||
// no compensation or performance is ever rendered (it isn't in the profile). Pure.
|
||||
export function renderWorker(w: Worker): string {
|
||||
const lines: string[] = [w.name || w.id];
|
||||
const head: string[] = [];
|
||||
if (w.businessTitle) head.push(w.businessTitle);
|
||||
else if (w.jobTitle) head.push(w.jobTitle);
|
||||
if (w.organization) head.push(w.organization);
|
||||
if (head.length) lines.push(head.join(' · '));
|
||||
const detail: string[] = [];
|
||||
if (w.jobTitle && w.jobTitle !== w.businessTitle) detail.push(`Job: ${w.jobTitle}`);
|
||||
if (w.location) detail.push(`Location: ${w.location}`);
|
||||
if (w.managerName) detail.push(`Manager: ${w.managerName}`);
|
||||
if (w.email) detail.push(`Email: ${w.email}`);
|
||||
if (w.isManager) detail.push('People manager');
|
||||
if (detail.length) lines.push(detail.join(' · '));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// renderOrganization turns one supervisory org into a compact block.
|
||||
export function renderOrganization(o: Organization): string {
|
||||
const head = `Organization: ${o.name || o.id}`;
|
||||
const meta: string[] = [];
|
||||
if (o.code) meta.push(`Code: ${o.code}`);
|
||||
if (o.type) meta.push(`Type: ${o.type}`);
|
||||
if (o.managerName) meta.push(`Manager: ${o.managerName}`);
|
||||
return meta.length ? `${head}\n${meta.join(' · ')}` : head;
|
||||
}
|
||||
|
||||
// renderJobRequisition turns one requisition into a compact block.
|
||||
export function renderJobRequisition(r: JobRequisition): string {
|
||||
const head = `Requisition ${r.name || r.id}: ${r.title}`.trim();
|
||||
const meta: string[] = [];
|
||||
if (r.status) meta.push(`Status: ${r.status}`);
|
||||
if (r.openings) meta.push(`Openings: ${r.openings}`);
|
||||
if (r.location) meta.push(`Location: ${r.location}`);
|
||||
if (r.startDate) meta.push(`Start: ${r.startDate}`);
|
||||
return meta.length ? `${head}\n${meta.join(' · ')}` : head;
|
||||
}
|
||||
|
||||
// renderAbsenceType turns one eligible absence/time-off type into a line.
|
||||
export function renderAbsenceType(a: AbsenceType): string {
|
||||
return `Absence type: ${a.name || a.id}`;
|
||||
}
|
||||
|
||||
// renderReportRow turns one RaaS Report_Entry row into a single labelled line of
|
||||
// column: value pairs, dropping empty cells. Pure.
|
||||
export function renderReportRow(row: ReportRow): string {
|
||||
const entries = Object.entries(row).filter(([, v]) => v !== '');
|
||||
if (entries.length === 0) return '';
|
||||
return entries.map(([k, v]) => `${k}: ${v}`).join(' · ');
|
||||
}
|
||||
|
||||
// ---- Windowing to a budget ------------------------------------------------
|
||||
|
||||
// A named group of rendered blocks — a section of the people context (e.g.
|
||||
// "Workers", "Direct reports"). The assembler concatenates sections in the order
|
||||
// given and windows the whole thing to the budget.
|
||||
export interface Section {
|
||||
title: string;
|
||||
blocks: string[];
|
||||
}
|
||||
|
||||
// The windowed people context: the rendered text, how many blocks were available
|
||||
// vs included, and whether anything was dropped (so the prompt and UI can say so
|
||||
// honestly).
|
||||
export interface PeopleContext {
|
||||
text: string;
|
||||
totalBlocks: number;
|
||||
includedBlocks: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// buildPeopleContext concatenates sections IN ORDER and caps the rendered text at
|
||||
// `budget` characters. It walks blocks in order (across sections) and stops when
|
||||
// adding the next block would exceed the budget — always including at least the
|
||||
// first block (hard-cut to the budget if that one block alone is over).
|
||||
// `truncated` is true whenever not every available block made it in. Pure and
|
||||
// total: deterministic, no I/O. This is the ONE windowing algorithm for the
|
||||
// package — the same "fit ordered text to a budget" contract as @hanzo/procore.
|
||||
export function buildPeopleContext(
|
||||
sections: Section[],
|
||||
budget: number = PEOPLE_CHAR_BUDGET,
|
||||
): PeopleContext {
|
||||
const totalBlocks = sections.reduce((n, s) => n + s.blocks.length, 0);
|
||||
if (totalBlocks === 0) {
|
||||
return { text: '', totalBlocks: 0, includedBlocks: 0, truncated: false };
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
let included = 0;
|
||||
let hardCut = false;
|
||||
|
||||
outer: for (const section of sections) {
|
||||
if (section.blocks.length === 0) continue;
|
||||
// The section header is charged to the first block that fits under it.
|
||||
let headerPending = `## ${section.title}\n`;
|
||||
for (const block of section.blocks) {
|
||||
const prefix = parts.length === 0 ? '' : '\n\n';
|
||||
const addition = prefix + headerPending + block;
|
||||
if (used + addition.length > budget) {
|
||||
if (parts.length === 0) {
|
||||
// First block alone overflows — hard-cut it to the budget so we always
|
||||
// send something rather than an empty context.
|
||||
const solo = (headerPending + block).slice(0, budget);
|
||||
parts.push(solo);
|
||||
used = solo.length;
|
||||
included = 1;
|
||||
hardCut = true;
|
||||
}
|
||||
break outer;
|
||||
}
|
||||
parts.push(addition);
|
||||
used += addition.length;
|
||||
included += 1;
|
||||
headerPending = ''; // header only precedes the first block of the section
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = included < totalBlocks || hardCut;
|
||||
return { text: parts.join(''), totalBlocks, includedBlocks: included, truncated };
|
||||
}
|
||||
|
||||
// contextNote is the one honest sentence prepended to the people text so the
|
||||
// model (and, echoed in the panel, the user) knows the scope of what it sees.
|
||||
// Never claim the whole tenant was sent when it wasn't. Pure.
|
||||
export function contextNote(ctx: PeopleContext): string {
|
||||
if (ctx.totalBlocks === 0) return 'No Workday records were available.';
|
||||
return ctx.truncated
|
||||
? `Workday records: ${ctx.includedBlocks} of ${ctx.totalBlocks} items (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
|
||||
: `Workday records: all ${ctx.totalBlocks} item${ctx.totalBlocks === 1 ? '' : 's'}.`;
|
||||
}
|
||||
|
||||
// ---- Convenience section builders -----------------------------------------
|
||||
//
|
||||
// These turn a typed record list into a Section, so a caller (server or panel)
|
||||
// assembles a people context in a couple of lines. Kept here (pure) so the same
|
||||
// section shapes feed both surfaces and the tests.
|
||||
|
||||
export function workerSection(workers: Worker[], title = 'Workers'): Section {
|
||||
return { title, blocks: workers.map(renderWorker) };
|
||||
}
|
||||
export function directReportSection(workers: Worker[], title = 'Direct reports'): Section {
|
||||
return { title, blocks: workers.map(renderWorker) };
|
||||
}
|
||||
export function organizationSection(orgs: Organization[], title = 'Organization'): Section {
|
||||
return { title, blocks: orgs.map(renderOrganization) };
|
||||
}
|
||||
export function requisitionSection(reqs: JobRequisition[], title = 'Job requisitions'): Section {
|
||||
return { title, blocks: reqs.map(renderJobRequisition) };
|
||||
}
|
||||
export function absenceTypeSection(types: AbsenceType[], title = 'Absence types'): Section {
|
||||
return { title, blocks: types.map(renderAbsenceType) };
|
||||
}
|
||||
export function timeOffSection(rows: ReportRow[], title = 'Time off'): Section {
|
||||
return { title, blocks: rows.map(renderReportRow).filter((b) => b.length > 0) };
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// The Workday install + API-proxy service. This is the ONLY place the Workday
|
||||
// client secret and the OAuth tokens exist — the secret is read from the
|
||||
// environment (never bundled, never sent to the browser); tokens are minted by the
|
||||
// OAuth flow and kept server-side, refreshed transparently on expiry. It:
|
||||
//
|
||||
// GET /oauth/install → redirect the user to Workday's OAuth consent
|
||||
// GET /oauth/callback → exchange the code for a token, open a session, and
|
||||
// hand the browser a session cookie
|
||||
// GET /proxy/* → forward a browser READ to the Workday REST/RaaS API
|
||||
// with the session's Bearer token, refreshing the
|
||||
// token first if it has expired. The browser never
|
||||
// sees the token, the secret, or the tenant address.
|
||||
// GET /healthz → readiness
|
||||
//
|
||||
// v1 is READ-ONLY: the proxy accepts GET only (405 otherwise). Workday writes are
|
||||
// heavy, kick off business-process approvals, and mutate the system of record — a
|
||||
// gated write-back is a deliberate, documented future addition (see README), not
|
||||
// an accident.
|
||||
//
|
||||
// It is a dependency-free Node http handler built on the pure modules (config,
|
||||
// workday-oauth, workday-api) so it is deployable behind hanzoai/ingress as a
|
||||
// small service at workday.hanzo.ai. The pure logic it wraps is what the tests
|
||||
// cover; the http server is an integration concern.
|
||||
//
|
||||
// node dist/server.js (after build.js bundles it)
|
||||
//
|
||||
// Required environment (see config.readServerConfig):
|
||||
// WORKDAY_HOST, WORKDAY_TENANT, WORKDAY_CLIENT_ID, WORKDAY_CLIENT_SECRET,
|
||||
// WORKDAY_REDIRECT_URI, WORKDAY_ENV (sandbox | production), PORT (default 8791)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readServerConfig, OAUTH_SCOPES, type ServerConfig } from './config.js';
|
||||
import {
|
||||
authorizeUrl,
|
||||
tokenExchange,
|
||||
refreshExchange,
|
||||
parseTokenResponse,
|
||||
carryRefreshToken,
|
||||
isExpired,
|
||||
type WorkdayTokenSet,
|
||||
} from './workday-oauth.js';
|
||||
import {
|
||||
listWorkers,
|
||||
getWorker,
|
||||
listDirectReports,
|
||||
listSupervisoryOrganizations,
|
||||
getSupervisoryOrganization,
|
||||
listJobRequisitions,
|
||||
getJobRequisition,
|
||||
listEligibleAbsenceTypes,
|
||||
timeOffReport,
|
||||
type PreparedGet,
|
||||
type Page,
|
||||
type Scope,
|
||||
} from './workday-api.js';
|
||||
|
||||
// A server-side session: the tokens for one authorized user + when they were last
|
||||
// minted (Workday tokens carry no created_at, so we track the mint wall-clock to
|
||||
// compute expiry). In-memory here keyed by an opaque session id carried in an
|
||||
// HttpOnly cookie; a production deployment persists this to hanzoai/kv (Valkey) so
|
||||
// sessions survive a restart.
|
||||
interface Session {
|
||||
token: WorkdayTokenSet;
|
||||
/** Wall-clock epoch seconds when the token was last minted/refreshed. */
|
||||
mintedAt: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
const SESSION_COOKIE = 'workday_sid';
|
||||
|
||||
// A pending OAuth `state` (CSRF token) minted at /oauth/install and consumed at
|
||||
// /oauth/callback. A production deployment persists these (Valkey, short TTL); a
|
||||
// Set is enough for a single instance.
|
||||
const pendingStates = new Set<string>();
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function log(fields: Record<string, unknown>): void {
|
||||
console.log(JSON.stringify(fields));
|
||||
}
|
||||
|
||||
// sessionIdFromCookie reads the session id from the Cookie header. Minimal parser
|
||||
// — we only need our one cookie.
|
||||
function sessionIdFromCookie(req: IncomingMessage): string | undefined {
|
||||
const raw = req.headers.cookie;
|
||||
if (!raw) return undefined;
|
||||
for (const part of raw.split(';')) {
|
||||
const [k, ...v] = part.trim().split('=');
|
||||
if (k === SESSION_COOKIE) return decodeURIComponent(v.join('='));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// GET /oauth/install → 302 to Workday's consent screen. `state` is a fresh CSRF
|
||||
// token re-checked on callback.
|
||||
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
|
||||
const state = randomUUID();
|
||||
pendingStates.add(state);
|
||||
const url = authorizeUrl({
|
||||
host: cfg.workdayHost,
|
||||
tenant: cfg.workdayTenant,
|
||||
clientId: cfg.workdayClientId,
|
||||
redirectUri: cfg.workdayRedirectUri,
|
||||
scopes: OAUTH_SCOPES,
|
||||
state,
|
||||
});
|
||||
res.writeHead(302, { Location: url });
|
||||
res.end();
|
||||
}
|
||||
|
||||
// GET /oauth/callback?code=…&state=… → verify state, exchange the code for a token
|
||||
// (secret in the server-side Basic header), open a session, and set the cookie.
|
||||
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
if (!code) return json(res, 400, { error: 'missing code' });
|
||||
if (!state || !pendingStates.delete(state)) return json(res, 400, { error: 'invalid state' });
|
||||
|
||||
const exReq = tokenExchange({
|
||||
host: cfg.workdayHost,
|
||||
tenant: cfg.workdayTenant,
|
||||
clientId: cfg.workdayClientId,
|
||||
clientSecret: cfg.workdayClientSecret,
|
||||
code,
|
||||
redirectUri: cfg.workdayRedirectUri,
|
||||
});
|
||||
let token: WorkdayTokenSet;
|
||||
try {
|
||||
const resp = await fetch(exReq.url, { method: 'POST', headers: exReq.headers, body: exReq.body });
|
||||
token = parseTokenResponse(await resp.json().catch(() => ({})));
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'token exchange failed' });
|
||||
}
|
||||
|
||||
const sid = randomUUID();
|
||||
sessions.set(sid, { token, mintedAt: nowSeconds() });
|
||||
log({ msg: 'oauth: session opened', sid });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `${SESSION_COOKIE}=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/`,
|
||||
});
|
||||
res.end(JSON.stringify({ ok: true, installed: true }));
|
||||
}
|
||||
|
||||
// freshToken returns a session's access token, refreshing first if it has expired.
|
||||
// Workday does not rotate the refresh token, so we carry the prior one forward.
|
||||
// Throws if the refresh fails (the caller returns 401 and the user re-installs).
|
||||
async function freshToken(cfg: ServerConfig, sid: string, session: Session): Promise<string> {
|
||||
if (!isExpired(session.token, session.mintedAt, nowSeconds())) return session.token.access_token;
|
||||
const refreshToken = session.token.refresh_token;
|
||||
if (!refreshToken) throw new Error('session expired and no refresh token');
|
||||
const rReq = refreshExchange({
|
||||
host: cfg.workdayHost,
|
||||
tenant: cfg.workdayTenant,
|
||||
clientId: cfg.workdayClientId,
|
||||
clientSecret: cfg.workdayClientSecret,
|
||||
refreshToken,
|
||||
});
|
||||
const resp = await fetch(rReq.url, { method: 'POST', headers: rReq.headers, body: rReq.body });
|
||||
const next = carryRefreshToken(session.token, parseTokenResponse(await resp.json().catch(() => ({}))));
|
||||
sessions.set(sid, { token: next, mintedAt: nowSeconds() });
|
||||
log({ msg: 'oauth: token refreshed', sid });
|
||||
return next.access_token;
|
||||
}
|
||||
|
||||
// pageFromSearch reads limit/offset from a query into a Page (undefined when
|
||||
// absent/non-positive so pageParams stays clean).
|
||||
function pageFromSearch(sp: URLSearchParams): Page {
|
||||
const limit = Number(sp.get('limit'));
|
||||
const offset = Number(sp.get('offset'));
|
||||
return {
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
offset: Number.isFinite(offset) && offset > 0 ? offset : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// searchRecord collects report prompt params (everything but the format we force).
|
||||
function searchRecord(sp: URLSearchParams): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of sp) if (k !== 'format') out[k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
// resolveTarget maps a semantic proxy route (segments after /proxy) to the Workday
|
||||
// request the api layer builds. Returns null for an unknown resource (→ 404). This
|
||||
// is the single place the browser's resource vocabulary maps onto Workday's URLs,
|
||||
// keeping the tenant address server-side.
|
||||
function resolveTarget(scope: Scope, segments: string[], sp: URLSearchParams): PreparedGet | null {
|
||||
const [a, b, c] = segments;
|
||||
if (a === 'workers') {
|
||||
if (!b) return listWorkers(scope, pageFromSearch(sp));
|
||||
if (!c) return getWorker(scope, b);
|
||||
if (c === 'directReports') return listDirectReports(scope, b, pageFromSearch(sp));
|
||||
if (c === 'absenceTypes') return listEligibleAbsenceTypes(scope, b, pageFromSearch(sp));
|
||||
return null;
|
||||
}
|
||||
if (a === 'organizations') {
|
||||
if (!b) return listSupervisoryOrganizations(scope, pageFromSearch(sp));
|
||||
if (!c) return getSupervisoryOrganization(scope, b);
|
||||
return null;
|
||||
}
|
||||
if (a === 'requisitions') {
|
||||
if (!b) return listJobRequisitions(scope, pageFromSearch(sp));
|
||||
if (!c) return getJobRequisition(scope, b);
|
||||
return null;
|
||||
}
|
||||
if (a === 'report' && b && c) return timeOffReport(scope, b, c, searchRecord(sp));
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /proxy/* → forward a READ to Workday. The browser sends a resource route
|
||||
// (/proxy/workers, /proxy/organizations/{id}, /proxy/report/{owner}/{name}, …);
|
||||
// the server resolves it to a REST/RaaS URL, injects the Bearer token (never
|
||||
// exposed to the browser), fetches, and returns the upstream JSON verbatim (the
|
||||
// panel parses it). GET only — v1 is read-only.
|
||||
async function handleProxy(
|
||||
cfg: ServerConfig,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
): Promise<void> {
|
||||
if (req.method !== 'GET') {
|
||||
return json(res, 405, { error: 'read-only: v1 proxy supports GET reads only' });
|
||||
}
|
||||
const sid = sessionIdFromCookie(req);
|
||||
const session = sid ? sessions.get(sid) : undefined;
|
||||
if (!sid || !session) return json(res, 401, { error: 'not authenticated' });
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await freshToken(cfg, sid, session);
|
||||
} catch (e: any) {
|
||||
return json(res, 401, { error: e?.message || 'token refresh failed' });
|
||||
}
|
||||
|
||||
const scope: Scope = { host: cfg.workdayHost, tenant: cfg.workdayTenant, accessToken };
|
||||
const rest = url.pathname.replace(/^\/proxy/, '');
|
||||
const segments = rest.split('/').filter(Boolean).map(decodeURIComponent);
|
||||
const target = resolveTarget(scope, segments, url.searchParams);
|
||||
if (!target) return json(res, 404, { error: 'unknown resource' });
|
||||
|
||||
try {
|
||||
const upstream = await fetch(target.url, { headers: target.headers });
|
||||
const text = await upstream.text();
|
||||
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
|
||||
res.end(text);
|
||||
} catch (e: any) {
|
||||
log({ msg: 'proxy: upstream error', error: e?.message, url: target.url });
|
||||
return json(res, 502, { error: 'upstream request failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// createHandler is the request router. Everything is a small handler over the pure
|
||||
// modules. Exported so a test can drive it with a mock req/res if desired.
|
||||
export function createHandler(cfg: ServerConfig) {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
|
||||
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
|
||||
if (url.pathname === '/proxy' || url.pathname.startsWith('/proxy/')) return await handleProxy(cfg, req, res, url);
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
|
||||
return json(res, 404, { error: 'not found' });
|
||||
} catch (e: any) {
|
||||
log({ msg: 'unhandled error', error: e?.message });
|
||||
return json(res, 500, { error: 'internal error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// main boots the server when run directly. Import-safe: only the direct entry
|
||||
// listens, so tests import the handlers without opening a port.
|
||||
export function main(): void {
|
||||
const cfg = readServerConfig(process.env);
|
||||
const server = createServer(createHandler(cfg));
|
||||
server.listen(cfg.port, () => {
|
||||
log({ msg: 'hanzo workday service up', port: cfg.port, env: cfg.workdayEnv, tenant: cfg.workdayTenant });
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
|
||||
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
|
||||
--ok: #3fb950; --warn: #d29922; --error: #f85149;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
|
||||
max-width: 720px;
|
||||
}
|
||||
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
header .title { font-weight: 600; font-size: 15px; }
|
||||
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
||||
textarea, select, input {
|
||||
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
|
||||
}
|
||||
textarea#prompt { min-height: 56px; resize: vertical; }
|
||||
textarea#output { min-height: 220px; resize: vertical; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.row.scope { margin-top: 4px; }
|
||||
.row.scope select#worker { flex: 1; }
|
||||
button {
|
||||
font: inherit; border: 1px solid var(--line); background: var(--accent);
|
||||
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
|
||||
}
|
||||
button.secondary { background: transparent; color: var(--fg); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.spacer { flex: 1; }
|
||||
.records { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
|
||||
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
|
||||
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
|
||||
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
|
||||
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.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); }
|
||||
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
|
||||
@@ -0,0 +1,353 @@
|
||||
// Workday REST + RaaS — pure request shaping + response parsing. Every function
|
||||
// returns a PreparedGet (url + headers) or parses a response body; none opens a
|
||||
// socket, so the whole API surface is unit-testable without a network. server.ts
|
||||
// is the thin glue that fetches these shapes with the OAuth Bearer access token.
|
||||
//
|
||||
// Two read surfaces, one bearer:
|
||||
// • REST — `${host}/ccx/api/{service}/{version}/{tenant}/…` (Staffing, Recruiting,
|
||||
// Absence). List endpoints paginate with `limit` + `offset` and return a
|
||||
// `{ total, data: [...] }` envelope (parsed by parseTotal / parseRestData).
|
||||
// • RaaS — `${host}/ccx/service/customreport2/{tenant}/{owner}/{report}?format=json`,
|
||||
// a customer-defined report returning `{ Report_Entry: [...] }` rows.
|
||||
// Docs: developer.workday.com (REST API reference) + Workday RaaS.
|
||||
//
|
||||
// v1 is READ-ONLY: Workday writes (e.g. requestTimeOff, business-process
|
||||
// submissions) are heavy, kick off approval workflows, and are the system of
|
||||
// record — so this module exposes reads only. A gated write-back is a documented,
|
||||
// deliberate future addition, not an oversight (see README).
|
||||
|
||||
import {
|
||||
restUrl,
|
||||
raasUrl,
|
||||
STAFFING,
|
||||
RECRUITING,
|
||||
ABSENCE,
|
||||
type WorkdayService,
|
||||
} from './config.js';
|
||||
|
||||
// A prepared GET: url + headers a single fetch needs. Bearer + JSON accept.
|
||||
export interface PreparedGet {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
// The scope every request carries: which tenant (host + name) and the bearer.
|
||||
// Grouping them keeps every wrapper's signature small and the scoping impossible
|
||||
// to forget.
|
||||
export interface Scope {
|
||||
host: string;
|
||||
tenant: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
// headers builds the auth + accept header set common to every request.
|
||||
function headers(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' };
|
||||
}
|
||||
|
||||
// ---- Pagination -----------------------------------------------------------
|
||||
|
||||
// Workday REST paginates with `limit` (page size) + `offset` (0-based row index)
|
||||
// and returns the total in the body. MAX_LIMIT is Workday's documented ceiling.
|
||||
export const MAX_LIMIT = 100;
|
||||
export const DEFAULT_LIMIT = 100;
|
||||
|
||||
export interface Page {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// pageParams renders pagination into query pairs, clamping limit to the
|
||||
// documented maximum and dropping a zero/absent offset (0 is the default). Only
|
||||
// emits params that are set, so an unpaginated call stays clean. Pure.
|
||||
export function pageParams(page: Page | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!page) return out;
|
||||
if (typeof page.limit === 'number' && page.limit > 0) {
|
||||
out.limit = String(Math.min(Math.floor(page.limit), MAX_LIMIT));
|
||||
}
|
||||
if (typeof page.offset === 'number' && page.offset > 0) {
|
||||
out.offset = String(Math.floor(page.offset));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// withQuery appends a query string to a full URL, dropping undefined/empty
|
||||
// values. One place appends every query so shaping stays consistent. Pure.
|
||||
function withQuery(url: string, query: Record<string, string> = {}): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== '') q.set(k, v);
|
||||
}
|
||||
const qs = q.toString();
|
||||
return qs ? `${url}?${qs}` : url;
|
||||
}
|
||||
|
||||
// enc encodes a single path segment (Workday IDs are opaque WID hex strings, but
|
||||
// we encode defensively so a stray value can never break — or traverse — the path).
|
||||
function enc(segment: number | string): string {
|
||||
return encodeURIComponent(String(segment));
|
||||
}
|
||||
|
||||
// ---- REST + RaaS request builders -----------------------------------------
|
||||
|
||||
// restGet shapes a GET against a REST service + sub-path with the Bearer header.
|
||||
export function restGet(
|
||||
scope: Scope,
|
||||
service: WorkdayService,
|
||||
sub: string,
|
||||
query: Record<string, string> = {},
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: withQuery(restUrl(scope.host, scope.tenant, service, sub), query),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// raasGet shapes a GET against a custom report, forcing `format=json` so we always
|
||||
// parse the Report_Entry JSON shape (never the default XML). Extra report prompt
|
||||
// params (e.g. an as-of date) merge in.
|
||||
export function raasGet(
|
||||
scope: Scope,
|
||||
owner: string,
|
||||
report: string,
|
||||
query: Record<string, string> = {},
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: withQuery(raasUrl(scope.host, scope.tenant, owner, report), { format: 'json', ...query }),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Workers (Staffing) ---------------------------------------------------
|
||||
|
||||
// listWorkers — the workers in the tenant the client can see. The panel's picker
|
||||
// uses this to choose which worker to run against.
|
||||
export function listWorkers(scope: Scope, page?: Page): PreparedGet {
|
||||
return restGet(scope, STAFFING, '/workers', pageParams(page));
|
||||
}
|
||||
|
||||
// getWorker — one worker's full profile (name, business title, org, job, manager).
|
||||
// This is what summarize-worker / draft-review run over.
|
||||
export function getWorker(scope: Scope, workerId: number | string): PreparedGet {
|
||||
return restGet(scope, STAFFING, `/workers/${enc(workerId)}`);
|
||||
}
|
||||
|
||||
// listDirectReports — the workers who report to a given worker (team roster).
|
||||
export function listDirectReports(scope: Scope, workerId: number | string, page?: Page): PreparedGet {
|
||||
return restGet(scope, STAFFING, `/workers/${enc(workerId)}/directReports`, pageParams(page));
|
||||
}
|
||||
|
||||
// ---- Organizations (Staffing) ---------------------------------------------
|
||||
|
||||
// listSupervisoryOrganizations — the supervisory orgs (the manager→team tree).
|
||||
export function listSupervisoryOrganizations(scope: Scope, page?: Page): PreparedGet {
|
||||
return restGet(scope, STAFFING, '/supervisoryOrganizations', pageParams(page));
|
||||
}
|
||||
|
||||
// getSupervisoryOrganization — one org in full (manager, code, type).
|
||||
export function getSupervisoryOrganization(scope: Scope, orgId: number | string): PreparedGet {
|
||||
return restGet(scope, STAFFING, `/supervisoryOrganizations/${enc(orgId)}`);
|
||||
}
|
||||
|
||||
// ---- Job requisitions (Recruiting) ----------------------------------------
|
||||
|
||||
// listJobRequisitions — the open/closed requisitions (headcount demand).
|
||||
export function listJobRequisitions(scope: Scope, page?: Page): PreparedGet {
|
||||
return restGet(scope, RECRUITING, '/jobRequisitions', pageParams(page));
|
||||
}
|
||||
|
||||
// getJobRequisition — one requisition in full.
|
||||
export function getJobRequisition(scope: Scope, reqId: number | string): PreparedGet {
|
||||
return restGet(scope, RECRUITING, `/jobRequisitions/${enc(reqId)}`);
|
||||
}
|
||||
|
||||
// ---- Time off / absence (Absence Management) ------------------------------
|
||||
|
||||
// listEligibleAbsenceTypes — the absence/time-off types a worker is eligible for
|
||||
// (the REST view of time off). Richer time-off history is typically pulled via a
|
||||
// RaaS report (timeOffReport) since it is customer-configured.
|
||||
export function listEligibleAbsenceTypes(
|
||||
scope: Scope,
|
||||
workerId: number | string,
|
||||
page?: Page,
|
||||
): PreparedGet {
|
||||
return restGet(scope, ABSENCE, `/workers/${enc(workerId)}/eligibleAbsenceTypes`, pageParams(page));
|
||||
}
|
||||
|
||||
// timeOffReport — a customer-defined time-off report via RaaS. `owner` is the
|
||||
// report owner's username, `report` the report name; extra prompt params pass
|
||||
// through (e.g. an as-of date). Returns Report_Entry rows (parseReportEntries).
|
||||
export function timeOffReport(
|
||||
scope: Scope,
|
||||
owner: string,
|
||||
report: string,
|
||||
params: Record<string, string> = {},
|
||||
): PreparedGet {
|
||||
return raasGet(scope, owner, report, params);
|
||||
}
|
||||
|
||||
// ---- Response parsing -----------------------------------------------------
|
||||
//
|
||||
// Workday REST responses use `descriptor` for the human label and `id` for the
|
||||
// WID, with nested references as `{ id, descriptor }`. We normalize the fields we
|
||||
// surface into small typed shapes at this one boundary so nothing downstream
|
||||
// touches the wire.
|
||||
|
||||
// ref renders a Workday reference (or bare string) to its display label.
|
||||
function ref(x: any): string {
|
||||
if (!x) return '';
|
||||
if (typeof x === 'string') return x;
|
||||
return String(x.descriptor ?? x.name ?? '');
|
||||
}
|
||||
|
||||
// parseTotal reads the `total` count Workday returns in a list envelope body.
|
||||
// Returns 0 for a missing/unparseable value. Pure.
|
||||
export function parseTotal(data: any): number {
|
||||
const n = Number(data?.total);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
// parseRestData reads the `data` array from a `{ total, data }` envelope, also
|
||||
// tolerating a bare array. Pure.
|
||||
export function parseRestData(data: any): any[] {
|
||||
return Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// One worker as surfaced from getWorker / listWorkers.
|
||||
export interface Worker {
|
||||
id: string;
|
||||
name: string;
|
||||
businessTitle: string;
|
||||
email: string;
|
||||
isManager: boolean;
|
||||
jobTitle: string;
|
||||
organization: string;
|
||||
location: string;
|
||||
managerName: string;
|
||||
}
|
||||
|
||||
export function parseWorker(d: any): Worker {
|
||||
const primaryJob = d?.primaryJob ?? {};
|
||||
return {
|
||||
id: String(d?.id ?? ''),
|
||||
name: ref(d),
|
||||
businessTitle: String(d?.businessTitle ?? ''),
|
||||
email: String(d?.primaryWorkEmail ?? ''),
|
||||
isManager: d?.isManager === true,
|
||||
jobTitle: ref(primaryJob?.jobProfile) || ref(primaryJob),
|
||||
organization: ref(d?.primarySupervisoryOrganization) || ref(d?.supervisoryOrganization),
|
||||
location: ref(primaryJob?.location) || ref(d?.location),
|
||||
managerName: ref(primaryJob?.manager) || ref(d?.manager),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkers(data: any): Worker[] {
|
||||
return parseRestData(data)
|
||||
.map(parseWorker)
|
||||
.filter((w: Worker) => w.id.length > 0);
|
||||
}
|
||||
|
||||
// One supervisory organization.
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
managerName: string;
|
||||
}
|
||||
|
||||
export function parseOrganization(d: any): Organization {
|
||||
return {
|
||||
id: String(d?.id ?? ''),
|
||||
name: ref(d),
|
||||
code: String(d?.code ?? d?.organizationCode ?? d?.referenceID ?? ''),
|
||||
type: ref(d?.organizationType) || String(d?.type ?? ''),
|
||||
managerName: ref(d?.manager),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOrganizations(data: any): Organization[] {
|
||||
return parseRestData(data)
|
||||
.map(parseOrganization)
|
||||
.filter((o: Organization) => o.id.length > 0);
|
||||
}
|
||||
|
||||
// One job requisition.
|
||||
export interface JobRequisition {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
status: string;
|
||||
openings: number;
|
||||
location: string;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export function parseJobRequisition(d: any): JobRequisition {
|
||||
return {
|
||||
id: String(d?.id ?? ''),
|
||||
name: ref(d),
|
||||
title: String(d?.jobPostingTitle ?? d?.title ?? ''),
|
||||
status: ref(d?.jobRequisitionStatus) || String(d?.status ?? ''),
|
||||
openings: Number(d?.numberOfOpenings ?? 0) || 0,
|
||||
location: ref(d?.primaryLocation) || ref(d?.location),
|
||||
startDate: String(d?.recruitingStartDate ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJobRequisitions(data: any): JobRequisition[] {
|
||||
return parseRestData(data)
|
||||
.map(parseJobRequisition)
|
||||
.filter((r: JobRequisition) => r.id.length > 0);
|
||||
}
|
||||
|
||||
// One eligible absence/time-off type.
|
||||
export interface AbsenceType {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function parseAbsenceTypes(data: any): AbsenceType[] {
|
||||
return parseRestData(data)
|
||||
.map((d: any) => ({ id: String(d?.id ?? ''), name: ref(d) }))
|
||||
.filter((a: AbsenceType) => a.name.length > 0 || a.id.length > 0);
|
||||
}
|
||||
|
||||
// ---- RaaS Report_Entry ----------------------------------------------------
|
||||
|
||||
// A report row: a flat map of column label → string value. RaaS columns are
|
||||
// report-defined, so rows are generic rather than typed.
|
||||
export type ReportRow = Record<string, string>;
|
||||
|
||||
// stringifyCell renders one RaaS cell to a string, taking the Descriptor/ID off a
|
||||
// nested reference object and joining arrays. Pure.
|
||||
function stringifyCell(v: any): string {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
if (Array.isArray(v)) return v.map(stringifyCell).filter((s) => s.length > 0).join(', ');
|
||||
if (typeof v === 'object') {
|
||||
return String(v.Descriptor ?? v.descriptor ?? v.ID ?? v.id ?? v.value ?? JSON.stringify(v));
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// flattenEntry turns one Report_Entry object into a flat ReportRow. Pure.
|
||||
export function flattenEntry(row: any): ReportRow {
|
||||
const out: ReportRow = {};
|
||||
if (!row || typeof row !== 'object') return out;
|
||||
for (const [k, v] of Object.entries(row)) out[k] = stringifyCell(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
// parseReportEntries reads a RaaS `{ Report_Entry: [...] }` body (or a bare array)
|
||||
// into flat rows. Pure.
|
||||
export function parseReportEntries(data: any): ReportRow[] {
|
||||
const rows = Array.isArray(data?.Report_Entry)
|
||||
? data.Report_Entry
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
return rows.map(flattenEntry);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Workday OAuth 2.0 (Authorization Code Grant) — pure request shaping. The client
|
||||
// secret is held by the server (server.ts) and never reaches the browser; these
|
||||
// functions build the exact URL/body/headers of each OAuth call so the wire shape
|
||||
// is unit-testable without a network round-trip. The token exchange and refresh
|
||||
// are each one fetch in the server.
|
||||
//
|
||||
// Workday differs from Procore in two ways that matter, and both are modelled
|
||||
// here rather than glossed over:
|
||||
// 1. The token endpoint authenticates the client with HTTP Basic
|
||||
// (Authorization: Basic base64(client_id:client_secret)) — the secret rides
|
||||
// in the header, NOT the form body. developer.workday.com's OAuth guide.
|
||||
// 2. Workday does NOT rotate the refresh token on refresh and the refresh
|
||||
// response usually omits refresh_token — so the caller carries the prior one
|
||||
// forward (carryRefreshToken).
|
||||
//
|
||||
// Endpoints hang off the tenant OAuth root `${host}/ccx/oauth2/{tenant}`:
|
||||
// authorize → …/authorize token → …/token
|
||||
|
||||
import { oauthBaseUrl } from './config.js';
|
||||
|
||||
// basicAuthHeader builds the `Authorization: Basic …` value Workday's token
|
||||
// endpoint requires for client authentication. Server-side only (it encodes the
|
||||
// secret). Pure — unit-tested. Uses Node's Buffer where available (the server and
|
||||
// tests), falling back to btoa for completeness.
|
||||
export function basicAuthHeader(clientId: string, clientSecret: string): string {
|
||||
const raw = `${clientId}:${clientSecret}`;
|
||||
const b64 =
|
||||
typeof Buffer !== 'undefined' ? Buffer.from(raw, 'utf8').toString('base64') : btoa(raw);
|
||||
return `Basic ${b64}`;
|
||||
}
|
||||
|
||||
// authorizeUrl is where the install flow sends the user's browser to grant the
|
||||
// API Client. `state` is the CSRF token the server generates and re-checks on
|
||||
// callback. `scope` is only appended when non-empty (a standard client is scoped
|
||||
// by its registered functional areas in the tenant).
|
||||
export function authorizeUrl(args: {
|
||||
host: string;
|
||||
tenant: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes?: readonly string[];
|
||||
state: string;
|
||||
}): string {
|
||||
const params: Record<string, string> = {
|
||||
response_type: 'code',
|
||||
client_id: args.clientId,
|
||||
redirect_uri: args.redirectUri,
|
||||
state: args.state,
|
||||
};
|
||||
if (args.scopes && args.scopes.length > 0) params.scope = args.scopes.join(' ');
|
||||
const q = new URLSearchParams(params);
|
||||
return `${oauthBaseUrl(args.host, args.tenant)}/authorize?${q.toString()}`;
|
||||
}
|
||||
|
||||
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a test
|
||||
// asserts the header + body without opening a socket. Workday's token endpoint
|
||||
// takes application/x-www-form-urlencoded with Basic client auth.
|
||||
export interface PreparedRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// tokenUrl is the OAuth token endpoint for a tenant.
|
||||
export function tokenUrl(host: string, tenant: string): string {
|
||||
return `${oauthBaseUrl(host, tenant)}/token`;
|
||||
}
|
||||
|
||||
// tokenExchange builds the code→token request against the tenant's /token. The
|
||||
// grant params (code + the SAME redirect_uri used on /authorize, which Workday
|
||||
// requires to match) ride in the form body; the client credentials ride in the
|
||||
// Basic Authorization header, so the secret never appears in the body.
|
||||
export function tokenExchange(args: {
|
||||
host: string;
|
||||
tenant: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: tokenUrl(args.host, args.tenant),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(args.clientId, args.clientSecret),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: args.code,
|
||||
redirect_uri: args.redirectUri,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// refreshExchange builds the refresh-token→token request. Workday access tokens
|
||||
// are short-lived (~1h) and the refresh token is long-lived and non-rotating, so
|
||||
// the response usually returns only a new access_token (see carryRefreshToken).
|
||||
export function refreshExchange(args: {
|
||||
host: string;
|
||||
tenant: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: tokenUrl(args.host, args.tenant),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(args.clientId, args.clientSecret),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: args.refreshToken,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// The token response we care about. Workday returns access_token (+ refresh_token
|
||||
// on the initial exchange, expires_in, token_type). No created_at — the caller
|
||||
// records the mint time. parseTokenResponse validates the one required field and
|
||||
// surfaces Workday's own error otherwise.
|
||||
export interface WorkdayTokenSet {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
export function parseTokenResponse(data: any): WorkdayTokenSet {
|
||||
if (!data || typeof data !== 'object') throw new Error('empty token response');
|
||||
if (data.error) {
|
||||
const reason = data.error_description || data.message || data.error;
|
||||
throw new Error(`Workday OAuth error: ${reason}`);
|
||||
}
|
||||
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
||||
throw new Error('Workday OAuth response missing access_token');
|
||||
}
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
||||
token_type: typeof data.token_type === 'string' ? data.token_type : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// carryRefreshToken keeps the prior refresh token when a refresh response omits
|
||||
// one (Workday does not rotate it). Returns `next` unchanged when it already
|
||||
// carries a refresh token. Pure — the ONE place refresh-token continuity lives.
|
||||
export function carryRefreshToken(prev: WorkdayTokenSet, next: WorkdayTokenSet): WorkdayTokenSet {
|
||||
return next.refresh_token ? next : { ...next, refresh_token: prev.refresh_token };
|
||||
}
|
||||
|
||||
// tokenExpiresAt computes the absolute epoch-seconds expiry from a token set and
|
||||
// the epoch-seconds mint time, applying a safety skew so a request is never sent
|
||||
// with a token about to expire mid-flight. Workday tokens carry no created_at, so
|
||||
// the caller (server) supplies mintedAt. Pure.
|
||||
export function tokenExpiresAt(token: WorkdayTokenSet, mintedAt: number, skewSeconds = 60): number {
|
||||
const ttl = typeof token.expires_in === 'number' ? token.expires_in : 0;
|
||||
return mintedAt + ttl - skewSeconds;
|
||||
}
|
||||
|
||||
// isExpired reports whether a token set is at/past its (skew-adjusted) expiry as
|
||||
// of `now`, given when it was minted. The server checks this before each API call
|
||||
// and refreshes when true. Pure.
|
||||
export function isExpired(
|
||||
token: WorkdayTokenSet,
|
||||
mintedAt: number,
|
||||
now: number,
|
||||
skewSeconds = 60,
|
||||
): boolean {
|
||||
return now >= tokenExpiresAt(token, mintedAt, skewSeconds);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
|
||||
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
|
||||
import { buildPeopleContext } from '../src/people.js';
|
||||
|
||||
const ctx = buildPeopleContext([{ title: 'Workers', blocks: ['Logan McNeil — VP, HR'] }]);
|
||||
|
||||
function mockClient(text: string) {
|
||||
const reply: ChatCompletion = {
|
||||
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
||||
};
|
||||
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
|
||||
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
|
||||
}
|
||||
|
||||
describe('actions: catalog', () => {
|
||||
it('exposes exactly the five documented actions', () => {
|
||||
expect(Object.keys(ACTIONS).sort()).toEqual(
|
||||
['answerHrQuestion', 'draftJobDescription', 'draftReviewFeedback', 'summarizeOrg', 'summarizeWorker'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('every action has a non-empty label and prompt', () => {
|
||||
for (const [id, a] of Object.entries(ACTIONS)) {
|
||||
expect(a.label.length, id).toBeGreaterThan(0);
|
||||
expect(a.prompt.length, id).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('actionList mirrors the catalog order and shape', () => {
|
||||
const list = actionList();
|
||||
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
|
||||
expect(list[0]).toEqual({ id: 'summarizeWorker', label: ACTIONS.summarizeWorker.label });
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions: prompt intent + HR guardrails', () => {
|
||||
it('summarizeWorker forbids inferring compensation or protected characteristics', () => {
|
||||
const p = actionPrompt('summarizeWorker');
|
||||
expect(p).toMatch(/compensation/i);
|
||||
expect(p).toMatch(/protected characteristic/i);
|
||||
});
|
||||
it('draftJobDescription asks for inclusive, bias-free language', () => {
|
||||
const p = actionPrompt('draftJobDescription');
|
||||
expect(p).toMatch(/inclusive|bias-free/i);
|
||||
expect(p).toMatch(/requisition/i);
|
||||
});
|
||||
it('summarizeOrg asks for headcount and open requisitions', () => {
|
||||
const p = actionPrompt('summarizeOrg');
|
||||
expect(p).toMatch(/headcount/i);
|
||||
expect(p).toMatch(/requisition/i);
|
||||
});
|
||||
it('answerHrQuestion says the answer is informational and may need review', () => {
|
||||
expect(actionPrompt('answerHrQuestion')).toMatch(/informational/i);
|
||||
expect(actionPrompt('answerHrQuestion')).toMatch(/review/i);
|
||||
});
|
||||
it('draftReviewFeedback notes the records lack performance ratings', () => {
|
||||
expect(actionPrompt('draftReviewFeedback')).toMatch(/performance rating/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions: isActionId / actionPrompt guards', () => {
|
||||
it('narrows known ids and rejects unknown', () => {
|
||||
expect(isActionId('summarizeWorker')).toBe(true);
|
||||
expect(isActionId('nope')).toBe(false);
|
||||
});
|
||||
it('actionPrompt throws on an unknown id', () => {
|
||||
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions: runAction', () => {
|
||||
it('routes the resolved prompt through ask (one code path to the model)', async () => {
|
||||
const { client, create } = mockClient('summary');
|
||||
const out = await runAction('summarizeWorker', ctx, { subject: 'Logan McNeil' }, { client });
|
||||
expect(out).toBe('summary');
|
||||
const user = String(create.mock.calls[0][0].messages[1].content);
|
||||
expect(user).toContain(ACTIONS.summarizeWorker.prompt);
|
||||
expect(user).toContain('Logan McNeil');
|
||||
});
|
||||
|
||||
it('rejects (not synchronously throws) on an unknown id', async () => {
|
||||
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HANZO_API_BASE_URL,
|
||||
DEFAULT_MODEL,
|
||||
APIKEY_STORAGE_KEY,
|
||||
PEOPLE_CHAR_BUDGET,
|
||||
pickBearer,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
normHost,
|
||||
oauthBaseUrl,
|
||||
restPath,
|
||||
restUrl,
|
||||
raasPath,
|
||||
raasUrl,
|
||||
STAFFING,
|
||||
RECRUITING,
|
||||
ABSENCE,
|
||||
COMMON,
|
||||
isWorkdayEnv,
|
||||
isSandboxHost,
|
||||
readServerConfig,
|
||||
} from '../src/config.js';
|
||||
|
||||
const HOST = 'https://wd2-impl-services1.workday.com';
|
||||
const TENANT = 'acme';
|
||||
|
||||
describe('config: gateway constants', () => {
|
||||
it('points at api.hanzo.ai with /v1 paths and never /api/', () => {
|
||||
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(chatCompletionsURL()).not.toContain('/api/');
|
||||
});
|
||||
|
||||
it('defaults to a zen model and a sane budget', () => {
|
||||
expect(DEFAULT_MODEL).toBe('zen5');
|
||||
expect(PEOPLE_CHAR_BUDGET).toBeGreaterThan(10_000);
|
||||
expect(APIKEY_STORAGE_KEY).toContain('workday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: pickBearer', () => {
|
||||
it('prefers a pasted key over an oauth token', () => {
|
||||
expect(pickBearer('hk-abc', 'oauth-xyz')).toBe('hk-abc');
|
||||
});
|
||||
it('falls back to the oauth token, trimming whitespace', () => {
|
||||
expect(pickBearer(' ', ' tok ')).toBe('tok');
|
||||
});
|
||||
it('returns empty when both are blank (anonymous)', () => {
|
||||
expect(pickBearer('', '')).toBe('');
|
||||
expect(pickBearer(' ', '')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: normHost', () => {
|
||||
it('strips one or many trailing slashes', () => {
|
||||
expect(normHost('https://x.workday.com/')).toBe('https://x.workday.com');
|
||||
expect(normHost('https://x.workday.com///')).toBe('https://x.workday.com');
|
||||
expect(normHost('https://x.workday.com')).toBe('https://x.workday.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: service descriptors', () => {
|
||||
it('pins each Workday REST service + version', () => {
|
||||
expect(STAFFING).toEqual({ api: 'staffing', version: 'v6' });
|
||||
expect(RECRUITING).toEqual({ api: 'recruiting', version: 'v4' });
|
||||
expect(ABSENCE).toEqual({ api: 'absenceManagement', version: 'v1' });
|
||||
expect(COMMON).toEqual({ api: 'common', version: 'v1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: OAuth + REST + RaaS URL builders', () => {
|
||||
it('builds the tenant OAuth root', () => {
|
||||
expect(oauthBaseUrl(HOST, TENANT)).toBe(
|
||||
'https://wd2-impl-services1.workday.com/ccx/oauth2/acme',
|
||||
);
|
||||
});
|
||||
it('normalizes a trailing slash on the host in every builder', () => {
|
||||
expect(oauthBaseUrl(HOST + '/', TENANT)).toBe(oauthBaseUrl(HOST, TENANT));
|
||||
expect(restUrl(HOST + '/', TENANT, STAFFING, '/workers')).toBe(
|
||||
restUrl(HOST, TENANT, STAFFING, '/workers'),
|
||||
);
|
||||
});
|
||||
it('builds a host-relative REST path with service + version + tenant', () => {
|
||||
expect(restPath(TENANT, STAFFING, '/workers')).toBe('/ccx/api/staffing/v6/acme/workers');
|
||||
expect(restPath(TENANT, RECRUITING, '/jobRequisitions')).toBe(
|
||||
'/ccx/api/recruiting/v4/acme/jobRequisitions',
|
||||
);
|
||||
expect(restPath(TENANT, ABSENCE)).toBe('/ccx/api/absenceManagement/v1/acme');
|
||||
});
|
||||
it('builds an absolute REST url', () => {
|
||||
expect(restUrl(HOST, TENANT, STAFFING, '/workers/123')).toBe(
|
||||
'https://wd2-impl-services1.workday.com/ccx/api/staffing/v6/acme/workers/123',
|
||||
);
|
||||
});
|
||||
it('builds the RaaS custom-report path + url', () => {
|
||||
expect(raasPath(TENANT, 'lmcneil', 'Team_Time_Off')).toBe(
|
||||
'/ccx/service/customreport2/acme/lmcneil/Team_Time_Off',
|
||||
);
|
||||
expect(raasUrl(HOST, TENANT, 'lmcneil', 'Team_Time_Off')).toBe(
|
||||
'https://wd2-impl-services1.workday.com/ccx/service/customreport2/acme/lmcneil/Team_Time_Off',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: isWorkdayEnv', () => {
|
||||
it('accepts the two environments', () => {
|
||||
expect(isWorkdayEnv('production')).toBe(true);
|
||||
expect(isWorkdayEnv('sandbox')).toBe(true);
|
||||
});
|
||||
it('rejects anything else', () => {
|
||||
expect(isWorkdayEnv('impl')).toBe(false);
|
||||
expect(isWorkdayEnv(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: isSandboxHost', () => {
|
||||
it('detects an implementation host by -impl', () => {
|
||||
expect(isSandboxHost('https://wd2-impl-services1.workday.com')).toBe(true);
|
||||
expect(isSandboxHost('https://impl.workday.com')).toBe(false);
|
||||
expect(isSandboxHost('https://services1.myworkday.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: readServerConfig', () => {
|
||||
const base = {
|
||||
WORKDAY_HOST: HOST,
|
||||
WORKDAY_TENANT: TENANT,
|
||||
WORKDAY_CLIENT_ID: 'cid',
|
||||
WORKDAY_CLIENT_SECRET: 'secret',
|
||||
WORKDAY_REDIRECT_URI: 'https://workday.hanzo.ai/oauth/callback',
|
||||
};
|
||||
|
||||
it('reads a full config, defaulting env to sandbox and port to 8791', () => {
|
||||
const cfg = readServerConfig(base);
|
||||
expect(cfg.workdayHost).toBe(HOST);
|
||||
expect(cfg.workdayTenant).toBe('acme');
|
||||
expect(cfg.workdayClientId).toBe('cid');
|
||||
expect(cfg.workdayClientSecret).toBe('secret');
|
||||
expect(cfg.workdayRedirectUri).toBe('https://workday.hanzo.ai/oauth/callback');
|
||||
expect(cfg.workdayEnv).toBe('sandbox');
|
||||
expect(cfg.port).toBe(8791);
|
||||
});
|
||||
|
||||
it('normalizes a trailing slash on the host', () => {
|
||||
const cfg = readServerConfig({ ...base, WORKDAY_HOST: HOST + '/' });
|
||||
expect(cfg.workdayHost).toBe(HOST);
|
||||
});
|
||||
|
||||
it('honours WORKDAY_ENV and PORT', () => {
|
||||
const cfg = readServerConfig({ ...base, WORKDAY_ENV: 'production', PORT: '9002' });
|
||||
expect(cfg.workdayEnv).toBe('production');
|
||||
expect(cfg.port).toBe(9002);
|
||||
});
|
||||
|
||||
it('coerces an unknown WORKDAY_ENV to sandbox', () => {
|
||||
expect(readServerConfig({ ...base, WORKDAY_ENV: 'staging' }).workdayEnv).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('throws listing every missing required value', () => {
|
||||
expect(() => readServerConfig({})).toThrow(/WORKDAY_HOST/);
|
||||
expect(() => readServerConfig({})).toThrow(/WORKDAY_TENANT/);
|
||||
expect(() => readServerConfig({})).toThrow(/WORKDAY_CLIENT_ID/);
|
||||
expect(() => readServerConfig({})).toThrow(/WORKDAY_CLIENT_SECRET/);
|
||||
expect(() => readServerConfig({})).toThrow(/WORKDAY_REDIRECT_URI/);
|
||||
expect(() => readServerConfig({ WORKDAY_HOST: HOST })).toThrow(
|
||||
/WORKDAY_TENANT, WORKDAY_CLIENT_ID, WORKDAY_CLIENT_SECRET, WORKDAY_REDIRECT_URI/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
buildMessages,
|
||||
extractContent,
|
||||
ask,
|
||||
listModels,
|
||||
type PeopleMeta,
|
||||
} from '../src/hanzo.js';
|
||||
import { buildPeopleContext, type PeopleContext } from '../src/people.js';
|
||||
|
||||
const ctx: PeopleContext = buildPeopleContext([{ title: 'Workers', blocks: ['Logan McNeil — VP, HR'] }]);
|
||||
const meta: PeopleMeta = { tenant: 'acme', organization: 'Human Resources', subject: 'Logan McNeil' };
|
||||
|
||||
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
|
||||
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
|
||||
return {
|
||||
id: 'c1',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model: 'zen5',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
|
||||
};
|
||||
}
|
||||
|
||||
// mockClient captures the params the code sends and returns a canned completion.
|
||||
function mockClient(reply: ChatCompletion, models: Model[] = []) {
|
||||
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
|
||||
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
|
||||
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
|
||||
return { client, create, list };
|
||||
}
|
||||
|
||||
describe('hanzo: buildMessages', () => {
|
||||
it('produces a grounded system prompt + a fenced user turn with meta + context note', () => {
|
||||
const msgs = buildMessages('Who manages this team?', ctx, meta);
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].role).toBe('system');
|
||||
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
|
||||
const user = String(msgs[1].content);
|
||||
expect(user).toContain('Subject: Logan McNeil');
|
||||
expect(user).toContain('Organization: Human Resources');
|
||||
expect(user).toContain('Tenant: acme');
|
||||
expect(user).toContain('---- workday records ----');
|
||||
expect(user).toContain('Logan McNeil — VP, HR');
|
||||
expect(user).toContain('---- task ----');
|
||||
expect(user).toContain('Who manages this team?');
|
||||
});
|
||||
|
||||
it('omits the header block entirely when meta is undefined', () => {
|
||||
const user = String(buildMessages('q', ctx).at(1)!.content);
|
||||
expect(user).not.toContain('Subject:');
|
||||
expect(user).not.toContain('Tenant:');
|
||||
expect(user).toContain('---- task ----');
|
||||
});
|
||||
|
||||
it('SYSTEM_PROMPT forbids inventing records, guards PII, and disclaims decisions', () => {
|
||||
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
|
||||
expect(SYSTEM_PROMPT).toMatch(/protected characteristics/i);
|
||||
expect(SYSTEM_PROMPT).toMatch(/not.*(employment|disciplinary).*decisions/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hanzo: extractContent', () => {
|
||||
it('reads a plain string content', () => {
|
||||
expect(extractContent(completion('hello'))).toBe('hello');
|
||||
});
|
||||
it('concatenates text parts of an array content', () => {
|
||||
const parts = [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'image_url', image_url: { url: 'x' } },
|
||||
{ type: 'text', text: 'b' },
|
||||
] as any;
|
||||
expect(extractContent(completion(parts))).toBe('ab');
|
||||
});
|
||||
it('throws on empty content', () => {
|
||||
expect(() => extractContent(completion(''))).toThrow(/no content/);
|
||||
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hanzo: ask', () => {
|
||||
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
|
||||
const { client, create } = mockClient(completion('Joy Banks manages HR.'));
|
||||
const out = await ask('Who manages this team?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
|
||||
expect(out).toBe('Joy Banks manages HR.');
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
const [params, options] = create.mock.calls[0];
|
||||
expect(params.model).toBe('zen5');
|
||||
expect(params.stream).toBe(false);
|
||||
expect(params.temperature).toBe(0.2);
|
||||
expect(params.messages[0].role).toBe('system');
|
||||
expect(options).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits temperature from the wire when not provided', async () => {
|
||||
const { client, create } = mockClient(completion('ok'));
|
||||
await ask('q', ctx, undefined, { client });
|
||||
expect('temperature' in create.mock.calls[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults the model to zen5', async () => {
|
||||
const { client, create } = mockClient(completion('ok'));
|
||||
await ask('q', ctx, undefined, { client });
|
||||
expect(create.mock.calls[0][0].model).toBe('zen5');
|
||||
});
|
||||
|
||||
it('propagates an abort signal to the client', async () => {
|
||||
const { client, create } = mockClient(completion('ok'));
|
||||
const controller = new AbortController();
|
||||
await ask('q', ctx, undefined, { client, signal: controller.signal });
|
||||
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hanzo: listModels', () => {
|
||||
it('maps the model catalog to ids', async () => {
|
||||
const models: Model[] = [
|
||||
{ id: 'zen5', object: 'model' },
|
||||
{ id: 'zen5-pro', object: 'model' },
|
||||
];
|
||||
const { client } = mockClient(completion('x'), models);
|
||||
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
basicAuthHeader,
|
||||
authorizeUrl,
|
||||
tokenUrl,
|
||||
tokenExchange,
|
||||
refreshExchange,
|
||||
parseTokenResponse,
|
||||
carryRefreshToken,
|
||||
tokenExpiresAt,
|
||||
isExpired,
|
||||
} from '../src/workday-oauth.js';
|
||||
|
||||
const HOST = 'https://wd2-impl-services1.workday.com';
|
||||
const TENANT = 'acme';
|
||||
|
||||
describe('oauth: basicAuthHeader', () => {
|
||||
it('base64-encodes client_id:client_secret as HTTP Basic', () => {
|
||||
const h = basicAuthHeader('cid', 'sec');
|
||||
expect(h).toBe(`Basic ${Buffer.from('cid:sec').toString('base64')}`);
|
||||
// Decodes back to the pair.
|
||||
const decoded = Buffer.from(h.replace('Basic ', ''), 'base64').toString('utf8');
|
||||
expect(decoded).toBe('cid:sec');
|
||||
});
|
||||
it('never leaks the raw secret in the header value', () => {
|
||||
expect(basicAuthHeader('cid', 'SUPERSECRET')).not.toContain('SUPERSECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: authorizeUrl', () => {
|
||||
it('builds the tenant consent URL with the standard params', () => {
|
||||
const url = new URL(
|
||||
authorizeUrl({
|
||||
host: HOST,
|
||||
tenant: TENANT,
|
||||
clientId: 'cid',
|
||||
redirectUri: 'https://workday.hanzo.ai/oauth/callback',
|
||||
state: 'st-1',
|
||||
}),
|
||||
);
|
||||
expect(url.origin).toBe('https://wd2-impl-services1.workday.com');
|
||||
expect(url.pathname).toBe('/ccx/oauth2/acme/authorize');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('client_id')).toBe('cid');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('https://workday.hanzo.ai/oauth/callback');
|
||||
expect(url.searchParams.get('state')).toBe('st-1');
|
||||
});
|
||||
|
||||
it('omits scope when empty', () => {
|
||||
const url = new URL(
|
||||
authorizeUrl({ host: HOST, tenant: TENANT, clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
|
||||
);
|
||||
expect(url.searchParams.has('scope')).toBe(false);
|
||||
});
|
||||
|
||||
it('space-joins scopes when present', () => {
|
||||
const url = new URL(
|
||||
authorizeUrl({ host: HOST, tenant: TENANT, clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: ['Staffing', 'Time_Off'] }),
|
||||
);
|
||||
expect(url.searchParams.get('scope')).toBe('Staffing Time_Off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: tokenUrl', () => {
|
||||
it('resolves the token endpoint on the tenant OAuth root', () => {
|
||||
expect(tokenUrl(HOST, TENANT)).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: tokenExchange', () => {
|
||||
it('puts the client credentials in the Basic header, NOT the body', () => {
|
||||
const req = tokenExchange({
|
||||
host: HOST,
|
||||
tenant: TENANT,
|
||||
clientId: 'cid',
|
||||
clientSecret: 'sec',
|
||||
code: 'the-code',
|
||||
redirectUri: 'https://workday.hanzo.ai/oauth/callback',
|
||||
});
|
||||
expect(req.url).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
|
||||
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(req.headers.Authorization).toBe(basicAuthHeader('cid', 'sec'));
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code')).toBe('the-code');
|
||||
expect(body.get('redirect_uri')).toBe('https://workday.hanzo.ai/oauth/callback');
|
||||
// Secret + id are NOT in the body (they ride in the Basic header).
|
||||
expect(body.has('client_secret')).toBe(false);
|
||||
expect(body.has('client_id')).toBe(false);
|
||||
});
|
||||
|
||||
it('never puts the secret in the body string', () => {
|
||||
const req = tokenExchange({ host: HOST, tenant: TENANT, clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
|
||||
expect(req.body).not.toContain('SUPER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: refreshExchange', () => {
|
||||
it('form-encodes the refresh grant with Basic client auth', () => {
|
||||
const req = refreshExchange({ host: HOST, tenant: TENANT, clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
|
||||
expect(req.url).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
|
||||
expect(req.headers.Authorization).toBe(basicAuthHeader('cid', 'sec'));
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('refresh_token');
|
||||
expect(body.get('refresh_token')).toBe('r-1');
|
||||
expect(body.has('code')).toBe(false);
|
||||
expect(body.has('client_secret')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: parseTokenResponse', () => {
|
||||
it('parses a full token set (no created_at on Workday)', () => {
|
||||
const t = parseTokenResponse({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
expect(t.access_token).toBe('at');
|
||||
expect(t.refresh_token).toBe('rt');
|
||||
expect(t.expires_in).toBe(3600);
|
||||
expect(t.token_type).toBe('Bearer');
|
||||
});
|
||||
|
||||
it('throws on a Workday error payload with the description', () => {
|
||||
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
|
||||
});
|
||||
|
||||
it('throws when access_token is missing or empty', () => {
|
||||
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
|
||||
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
|
||||
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
|
||||
});
|
||||
|
||||
it('drops non-string/non-number optional fields defensively', () => {
|
||||
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
|
||||
expect(t.refresh_token).toBeUndefined();
|
||||
expect(t.expires_in).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: carryRefreshToken (Workday does not rotate)', () => {
|
||||
it('keeps the prior refresh token when the refresh response omits one', () => {
|
||||
const prev = { access_token: 'old', refresh_token: 'keep-me', expires_in: 3600 };
|
||||
const next = { access_token: 'new', expires_in: 3600 };
|
||||
expect(carryRefreshToken(prev, next).refresh_token).toBe('keep-me');
|
||||
expect(carryRefreshToken(prev, next).access_token).toBe('new');
|
||||
});
|
||||
it('keeps a rotated refresh token when the response does carry one', () => {
|
||||
const prev = { access_token: 'old', refresh_token: 'old-r' };
|
||||
const next = { access_token: 'new', refresh_token: 'new-r' };
|
||||
expect(carryRefreshToken(prev, next).refresh_token).toBe('new-r');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: expiry math (mint-time based)', () => {
|
||||
it('computes expiry from mintedAt + expires_in minus skew', () => {
|
||||
const t = { access_token: 'a', expires_in: 3600 };
|
||||
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 3600 - 60);
|
||||
});
|
||||
|
||||
it('treats a missing expires_in as ttl 0', () => {
|
||||
const t = { access_token: 'a' };
|
||||
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000);
|
||||
});
|
||||
|
||||
it('reports not-expired before, expired at/after the skewed boundary', () => {
|
||||
const t = { access_token: 'a', expires_in: 3600 };
|
||||
const minted = 1000;
|
||||
const boundary = minted + 3600 - 60;
|
||||
expect(isExpired(t, minted, boundary - 1, 60)).toBe(false);
|
||||
expect(isExpired(t, minted, boundary, 60)).toBe(true);
|
||||
expect(isExpired(t, minted, boundary + 100, 60)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
|
||||
|
||||
describe('panel: parseLaunchContext', () => {
|
||||
it('reads snake_case worker/organization ids', () => {
|
||||
expect(parseLaunchContext('?worker_id=w1&organization_id=o2')).toEqual({
|
||||
workerId: 'w1',
|
||||
organizationId: 'o2',
|
||||
});
|
||||
});
|
||||
it('accepts camelCase fallbacks and defaults to empty', () => {
|
||||
expect(parseLaunchContext('?workerId=w7')).toEqual({ workerId: 'w7', organizationId: '' });
|
||||
expect(parseLaunchContext('')).toEqual({ workerId: '', organizationId: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel: proxyUrl', () => {
|
||||
it('prefixes /proxy and appends a query, dropping empties', () => {
|
||||
expect(proxyUrl('', '/workers', { limit: '100', offset: '' })).toBe('/proxy/workers?limit=100');
|
||||
});
|
||||
it('strips a trailing slash from the base', () => {
|
||||
expect(proxyUrl('https://workday.hanzo.ai/', '/workers')).toBe('https://workday.hanzo.ai/proxy/workers');
|
||||
});
|
||||
});
|
||||
|
||||
// jsonResponse builds a fetch Response-like from a body object.
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
|
||||
// (url, init) arg tuple TypeScript expects.
|
||||
function fetchMockOf(responder: () => Response) {
|
||||
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
|
||||
}
|
||||
|
||||
const workerEnvelope = {
|
||||
total: 137,
|
||||
data: [
|
||||
{
|
||||
id: 'w1',
|
||||
descriptor: 'Logan McNeil',
|
||||
businessTitle: 'VP, HR',
|
||||
primarySupervisoryOrganization: { descriptor: 'Human Resources' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('panel: createProxyClient — routes, cookie, parsing', () => {
|
||||
it('lists workers with credentials and parses items + total', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse(workerEnvelope));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
const { items, total } = await client.listWorkers({ limit: 100, offset: 200 });
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe('Logan McNeil');
|
||||
expect(items[0].organization).toBe('Human Resources');
|
||||
expect(total).toBe(137);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('/proxy/workers?limit=100&offset=200');
|
||||
expect((init as RequestInit).credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('gets one worker and parses it', async () => {
|
||||
const fetchMock = fetchMockOf(() =>
|
||||
jsonResponse({ id: 'w1', descriptor: 'Logan McNeil', primaryWorkEmail: 'l@x.co' }),
|
||||
);
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
const worker = await client.getWorker('w1');
|
||||
expect(worker.name).toBe('Logan McNeil');
|
||||
expect(worker.email).toBe('l@x.co');
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/workers/w1');
|
||||
});
|
||||
|
||||
it('lists direct reports on the worker route', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse({ total: 0, data: [] }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await client.listDirectReports('w1', { limit: 50 });
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/workers/w1/directReports?limit=50');
|
||||
});
|
||||
|
||||
it('lists organizations and requisitions on their routes', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse({ total: 0, data: [] }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await client.listOrganizations();
|
||||
await client.listRequisitions({ offset: 10 });
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/organizations');
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe('/proxy/requisitions?offset=10');
|
||||
});
|
||||
|
||||
it('lists eligible absence types on the worker absenceTypes route', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse({ total: 0, data: [] }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await client.listAbsenceTypes('w1');
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/workers/w1/absenceTypes');
|
||||
});
|
||||
|
||||
it('runs a RaaS report and parses Report_Entry rows', async () => {
|
||||
const fetchMock = fetchMockOf(() =>
|
||||
jsonResponse({ Report_Entry: [{ Worker: { Descriptor: 'Amy' }, Balance: '12' }] }),
|
||||
);
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
const rows = await client.report('lmcneil', 'Team_Time_Off', { asOf: '2026-07-01' });
|
||||
expect(rows).toEqual([{ Worker: 'Amy', Balance: '12' }]);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/report/lmcneil/Team_Time_Off?asOf=2026-07-01');
|
||||
});
|
||||
|
||||
it('surfaces a proxy error with status + message', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse({ error: 'not authenticated' }, 401));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await expect(client.listWorkers()).rejects.toThrow(/401.*not authenticated/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseTotal,
|
||||
parseRestData,
|
||||
parseWorker,
|
||||
parseWorkers,
|
||||
parseOrganization,
|
||||
parseOrganizations,
|
||||
parseJobRequisition,
|
||||
parseJobRequisitions,
|
||||
parseAbsenceTypes,
|
||||
flattenEntry,
|
||||
parseReportEntries,
|
||||
} from '../src/workday-api.js';
|
||||
|
||||
// A realistic Workday Staffing v6 worker instance (trimmed to fields we surface).
|
||||
const workerFixture = {
|
||||
id: '0e44c92412d34b01ace61e80a47aaf6d',
|
||||
descriptor: 'Logan McNeil',
|
||||
primaryWorkEmail: 'lmcneil@workday.net',
|
||||
businessTitle: 'Vice President, Human Resources',
|
||||
isManager: true,
|
||||
primarySupervisoryOrganization: { id: 'org-1', descriptor: 'Human Resources' },
|
||||
primaryJob: {
|
||||
id: 'job-1',
|
||||
descriptor: 'P-00001 Vice President, Human Resources',
|
||||
jobProfile: { id: 'jp-1', descriptor: 'Vice President, Human Resources' },
|
||||
location: { id: 'loc-1', descriptor: 'San Francisco' },
|
||||
manager: { id: 'mgr-1', descriptor: 'Joy Banks' },
|
||||
},
|
||||
};
|
||||
|
||||
// A `{ total, data }` list envelope, as Workday REST returns.
|
||||
const workerList = { total: 137, data: [workerFixture, { descriptor: 'no id' }] };
|
||||
|
||||
describe('parse: envelope helpers', () => {
|
||||
it('parseTotal reads the body total, 0 when missing/bad', () => {
|
||||
expect(parseTotal({ total: 42 })).toBe(42);
|
||||
expect(parseTotal({})).toBe(0);
|
||||
expect(parseTotal({ total: 'many' })).toBe(0);
|
||||
expect(parseTotal({ total: -3 })).toBe(0);
|
||||
});
|
||||
it('parseRestData reads the data array or a bare array', () => {
|
||||
expect(parseRestData({ data: [1, 2] })).toEqual([1, 2]);
|
||||
expect(parseRestData([3, 4])).toEqual([3, 4]);
|
||||
expect(parseRestData({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: worker', () => {
|
||||
it('reads name (descriptor), title, email, org, job, location, manager', () => {
|
||||
const w = parseWorker(workerFixture);
|
||||
expect(w.id).toBe('0e44c92412d34b01ace61e80a47aaf6d');
|
||||
expect(w.name).toBe('Logan McNeil');
|
||||
expect(w.businessTitle).toBe('Vice President, Human Resources');
|
||||
expect(w.email).toBe('lmcneil@workday.net');
|
||||
expect(w.isManager).toBe(true);
|
||||
expect(w.jobTitle).toBe('Vice President, Human Resources'); // prefers jobProfile
|
||||
expect(w.organization).toBe('Human Resources');
|
||||
expect(w.location).toBe('San Francisco');
|
||||
expect(w.managerName).toBe('Joy Banks');
|
||||
});
|
||||
|
||||
it('falls back jobTitle to the primaryJob descriptor and tolerates missing fields', () => {
|
||||
const w = parseWorker({ id: 'w', descriptor: 'Amy', primaryJob: { descriptor: 'Recruiter' } });
|
||||
expect(w.jobTitle).toBe('Recruiter');
|
||||
expect(w.email).toBe('');
|
||||
expect(w.isManager).toBe(false);
|
||||
expect(w.organization).toBe('');
|
||||
});
|
||||
|
||||
it('parseWorkers reads the envelope and drops idless rows', () => {
|
||||
const rows = parseWorkers(workerList);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('Logan McNeil');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: organization', () => {
|
||||
it('reads a supervisory org reference shape', () => {
|
||||
const o = parseOrganization({
|
||||
id: 'org-1',
|
||||
descriptor: 'Human Resources',
|
||||
code: 'HR',
|
||||
organizationType: { descriptor: 'Supervisory' },
|
||||
manager: { descriptor: 'Logan McNeil' },
|
||||
});
|
||||
expect(o).toEqual({
|
||||
id: 'org-1',
|
||||
name: 'Human Resources',
|
||||
code: 'HR',
|
||||
type: 'Supervisory',
|
||||
managerName: 'Logan McNeil',
|
||||
});
|
||||
});
|
||||
it('parseOrganizations drops idless rows', () => {
|
||||
expect(parseOrganizations({ data: [{ id: 'o', descriptor: 'X' }, { descriptor: 'no id' }] })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: job requisition', () => {
|
||||
it('reads title, status, openings, location, start date', () => {
|
||||
const r = parseJobRequisition({
|
||||
id: 'R-1',
|
||||
descriptor: 'R-1 Senior Recruiter',
|
||||
jobPostingTitle: 'Senior Recruiter',
|
||||
jobRequisitionStatus: { descriptor: 'Open' },
|
||||
numberOfOpenings: 3,
|
||||
primaryLocation: { descriptor: 'Remote - USA' },
|
||||
recruitingStartDate: '2026-06-01',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
id: 'R-1',
|
||||
name: 'R-1 Senior Recruiter',
|
||||
title: 'Senior Recruiter',
|
||||
status: 'Open',
|
||||
openings: 3,
|
||||
location: 'Remote - USA',
|
||||
startDate: '2026-06-01',
|
||||
});
|
||||
});
|
||||
it('parseJobRequisitions defaults openings to 0 and drops idless rows', () => {
|
||||
const rows = parseJobRequisitions({ data: [{ id: 'R', descriptor: 'x' }, { title: 'no id' }] });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].openings).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: absence types', () => {
|
||||
it('reads id + descriptor and keeps rows with either', () => {
|
||||
const types = parseAbsenceTypes({
|
||||
total: 2,
|
||||
data: [
|
||||
{ id: 'a1', descriptor: 'Sick' },
|
||||
{ id: 'a2', descriptor: 'Vacation' },
|
||||
{ id: '', descriptor: '' },
|
||||
],
|
||||
});
|
||||
expect(types).toEqual([
|
||||
{ id: 'a1', name: 'Sick' },
|
||||
{ id: 'a2', name: 'Vacation' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: RaaS Report_Entry', () => {
|
||||
it('flattenEntry stringifies scalars, references, and arrays', () => {
|
||||
const row = flattenEntry({
|
||||
Worker: { Descriptor: 'Logan McNeil', ID: 'wid' },
|
||||
Days: 3,
|
||||
Approved: true,
|
||||
Types: [{ Descriptor: 'Sick' }, { Descriptor: 'Vacation' }],
|
||||
Empty: null,
|
||||
});
|
||||
expect(row.Worker).toBe('Logan McNeil');
|
||||
expect(row.Days).toBe('3');
|
||||
expect(row.Approved).toBe('true');
|
||||
expect(row.Types).toBe('Sick, Vacation');
|
||||
expect(row.Empty).toBe('');
|
||||
});
|
||||
|
||||
it('parseReportEntries reads the Report_Entry array (or a bare array)', () => {
|
||||
const rows = parseReportEntries({
|
||||
Report_Entry: [
|
||||
{ Worker: { Descriptor: 'Amy' }, Time_Off_Balance: '12' },
|
||||
{ Worker: { Descriptor: 'Bo' }, Time_Off_Balance: '4' },
|
||||
],
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({ Worker: 'Amy', Time_Off_Balance: '12' });
|
||||
expect(parseReportEntries([{ A: 1 }])).toEqual([{ A: '1' }]);
|
||||
expect(parseReportEntries({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
renderWorker,
|
||||
renderOrganization,
|
||||
renderJobRequisition,
|
||||
renderAbsenceType,
|
||||
renderReportRow,
|
||||
buildPeopleContext,
|
||||
contextNote,
|
||||
workerSection,
|
||||
directReportSection,
|
||||
organizationSection,
|
||||
requisitionSection,
|
||||
absenceTypeSection,
|
||||
timeOffSection,
|
||||
} from '../src/people.js';
|
||||
import type { Worker, Organization, JobRequisition, AbsenceType } from '../src/workday-api.js';
|
||||
|
||||
const worker: Worker = {
|
||||
id: 'w1',
|
||||
name: 'Logan McNeil',
|
||||
businessTitle: 'Vice President, Human Resources',
|
||||
email: 'lmcneil@workday.net',
|
||||
isManager: true,
|
||||
jobTitle: 'Vice President, Human Resources',
|
||||
organization: 'Human Resources',
|
||||
location: 'San Francisco',
|
||||
managerName: 'Joy Banks',
|
||||
};
|
||||
|
||||
describe('people: record rendering', () => {
|
||||
it('renders a worker with title, org, and details, no invented fields', () => {
|
||||
const text = renderWorker(worker);
|
||||
expect(text).toContain('Logan McNeil');
|
||||
expect(text).toContain('Vice President, Human Resources · Human Resources');
|
||||
expect(text).toContain('Location: San Francisco');
|
||||
expect(text).toContain('Manager: Joy Banks');
|
||||
expect(text).toContain('Email: lmcneil@workday.net');
|
||||
expect(text).toContain('People manager');
|
||||
});
|
||||
|
||||
it('shows a distinct Job line only when jobTitle differs from businessTitle', () => {
|
||||
const w: Worker = { ...worker, jobTitle: 'Recruiter' };
|
||||
expect(renderWorker(w)).toContain('Job: Recruiter');
|
||||
expect(renderWorker(worker)).not.toContain('Job: Vice President');
|
||||
});
|
||||
|
||||
it('renders an org, requisition, absence type, and report row compactly', () => {
|
||||
const org: Organization = { id: 'o1', name: 'Human Resources', code: 'HR', type: 'Supervisory', managerName: 'Logan McNeil' };
|
||||
expect(renderOrganization(org)).toContain('Organization: Human Resources');
|
||||
expect(renderOrganization(org)).toContain('Code: HR · Type: Supervisory · Manager: Logan McNeil');
|
||||
|
||||
const req: JobRequisition = { id: 'R-1', name: 'R-1', title: 'Senior Recruiter', status: 'Open', openings: 3, location: 'Remote', startDate: '2026-06-01' };
|
||||
expect(renderJobRequisition(req)).toContain('Requisition R-1: Senior Recruiter');
|
||||
expect(renderJobRequisition(req)).toContain('Status: Open · Openings: 3 · Location: Remote · Start: 2026-06-01');
|
||||
|
||||
const abs: AbsenceType = { id: 'a1', name: 'Sick' };
|
||||
expect(renderAbsenceType(abs)).toBe('Absence type: Sick');
|
||||
|
||||
expect(renderReportRow({ Worker: 'Amy', Balance: '12', Empty: '' })).toBe('Worker: Amy · Balance: 12');
|
||||
expect(renderReportRow({ Empty: '' })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('people: buildPeopleContext', () => {
|
||||
it('returns empty for no blocks', () => {
|
||||
expect(buildPeopleContext([{ title: 'Workers', blocks: [] }])).toEqual({
|
||||
text: '',
|
||||
totalBlocks: 0,
|
||||
includedBlocks: 0,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes all blocks under section headers when within budget', () => {
|
||||
const ctx = buildPeopleContext([
|
||||
{ title: 'Workers', blocks: ['a', 'b'] },
|
||||
{ title: 'Orgs', blocks: ['c'] },
|
||||
]);
|
||||
expect(ctx.totalBlocks).toBe(3);
|
||||
expect(ctx.includedBlocks).toBe(3);
|
||||
expect(ctx.truncated).toBe(false);
|
||||
expect(ctx.text).toContain('## Workers');
|
||||
expect(ctx.text).toContain('## Orgs');
|
||||
expect(ctx.text.indexOf('## Workers')).toBeLessThan(ctx.text.indexOf('## Orgs'));
|
||||
});
|
||||
|
||||
it('windows in order and reports truncation when the budget is exceeded', () => {
|
||||
const blocks = ['x'.repeat(30), 'y'.repeat(30), 'z'.repeat(30)];
|
||||
const ctx = buildPeopleContext([{ title: 'Workers', blocks }], 60);
|
||||
expect(ctx.totalBlocks).toBe(3);
|
||||
expect(ctx.includedBlocks).toBeLessThan(3);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text).toContain('xxx');
|
||||
expect(ctx.text).not.toContain('zzz');
|
||||
expect(ctx.text.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('always includes at least the first block, hard-cutting it if it alone overflows', () => {
|
||||
const huge = 'q'.repeat(500);
|
||||
const ctx = buildPeopleContext([{ title: 'Workers', blocks: [huge, 'next'] }], 50);
|
||||
expect(ctx.includedBlocks).toBe(1);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text.length).toBe(50);
|
||||
expect(ctx.text.startsWith('## Workers')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips empty sections without charging a header', () => {
|
||||
const ctx = buildPeopleContext([
|
||||
{ title: 'Empty', blocks: [] },
|
||||
{ title: 'Workers', blocks: ['a'] },
|
||||
]);
|
||||
expect(ctx.text).not.toContain('## Empty');
|
||||
expect(ctx.text).toContain('## Workers');
|
||||
expect(ctx.includedBlocks).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('people: contextNote', () => {
|
||||
it('is honest about no records', () => {
|
||||
expect(contextNote({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false })).toMatch(/No Workday records/);
|
||||
});
|
||||
it('reports full coverage', () => {
|
||||
expect(contextNote({ text: 'x', totalBlocks: 3, includedBlocks: 3, truncated: false })).toMatch(/all 3 items/);
|
||||
});
|
||||
it('reports truncation with the counts', () => {
|
||||
expect(contextNote({ text: 'x', totalBlocks: 10, includedBlocks: 4, truncated: true })).toMatch(/4 of 10 items/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('people: section builders assemble a real context', () => {
|
||||
it('turns typed records into a windowed context end to end', () => {
|
||||
const ctx = buildPeopleContext([
|
||||
workerSection([worker]),
|
||||
directReportSection([{ ...worker, id: 'w2', name: 'Amy Wong', businessTitle: 'Recruiter', isManager: false }]),
|
||||
organizationSection([{ id: 'o1', name: 'Human Resources', code: 'HR', type: 'Supervisory', managerName: 'Logan McNeil' }]),
|
||||
requisitionSection([{ id: 'R-1', name: 'R-1', title: 'Senior Recruiter', status: 'Open', openings: 2, location: '', startDate: '' }]),
|
||||
absenceTypeSection([{ id: 'a1', name: 'Sick' }]),
|
||||
timeOffSection([{ Worker: 'Amy Wong', Balance: '12' }, { Empty: '' }]),
|
||||
]);
|
||||
expect(ctx.totalBlocks).toBe(6); // the all-empty time-off row is dropped by timeOffSection
|
||||
expect(ctx.truncated).toBe(false);
|
||||
expect(ctx.text).toContain('## Workers');
|
||||
expect(ctx.text).toContain('Logan McNeil');
|
||||
expect(ctx.text).toContain('## Direct reports');
|
||||
expect(ctx.text).toContain('Amy Wong');
|
||||
expect(ctx.text).toContain('## Organization');
|
||||
expect(ctx.text).toContain('## Job requisitions');
|
||||
expect(ctx.text).toContain('## Absence types');
|
||||
expect(ctx.text).toContain('## Time off');
|
||||
expect(ctx.text).toContain('Worker: Amy Wong · Balance: 12');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MAX_LIMIT,
|
||||
pageParams,
|
||||
restGet,
|
||||
raasGet,
|
||||
listWorkers,
|
||||
getWorker,
|
||||
listDirectReports,
|
||||
listSupervisoryOrganizations,
|
||||
getSupervisoryOrganization,
|
||||
listJobRequisitions,
|
||||
getJobRequisition,
|
||||
listEligibleAbsenceTypes,
|
||||
timeOffReport,
|
||||
type Scope,
|
||||
} from '../src/workday-api.js';
|
||||
import { STAFFING } from '../src/config.js';
|
||||
|
||||
const scope: Scope = {
|
||||
host: 'https://wd2-impl-services1.workday.com',
|
||||
tenant: 'acme',
|
||||
accessToken: 'at-123',
|
||||
};
|
||||
|
||||
function q(url: string): URLSearchParams {
|
||||
return new URL(url).searchParams;
|
||||
}
|
||||
function path(url: string): string {
|
||||
return new URL(url).pathname;
|
||||
}
|
||||
|
||||
describe('api: pageParams', () => {
|
||||
it('emits nothing for no page', () => {
|
||||
expect(pageParams(undefined)).toEqual({});
|
||||
});
|
||||
it('emits limit + offset when set', () => {
|
||||
expect(pageParams({ limit: 50, offset: 100 })).toEqual({ limit: '50', offset: '100' });
|
||||
});
|
||||
it('clamps limit to the documented max', () => {
|
||||
expect(pageParams({ limit: 500 }).limit).toBe(String(MAX_LIMIT));
|
||||
});
|
||||
it('drops a zero/absent offset (0 is the default) and non-positive limit', () => {
|
||||
expect(pageParams({ offset: 0 })).toEqual({});
|
||||
expect(pageParams({ limit: 0, offset: -5 })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: auth headers', () => {
|
||||
it('every read carries the Bearer token and a JSON Accept', () => {
|
||||
for (const req of [
|
||||
listWorkers(scope),
|
||||
getWorker(scope, 'w1'),
|
||||
listDirectReports(scope, 'w1'),
|
||||
listSupervisoryOrganizations(scope),
|
||||
getSupervisoryOrganization(scope, 'o1'),
|
||||
listJobRequisitions(scope),
|
||||
getJobRequisition(scope, 'r1'),
|
||||
listEligibleAbsenceTypes(scope, 'w1'),
|
||||
timeOffReport(scope, 'lmcneil', 'Team_Time_Off'),
|
||||
]) {
|
||||
expect(req.headers.Authorization).toBe('Bearer at-123');
|
||||
expect(req.headers.Accept).toBe('application/json');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: restGet service routing', () => {
|
||||
it('routes staffing v6 with tenant in the path', () => {
|
||||
const req = restGet(scope, STAFFING, '/workers', { limit: '10' });
|
||||
expect(path(req.url)).toBe('/ccx/api/staffing/v6/acme/workers');
|
||||
expect(q(req.url).get('limit')).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: workers (staffing)', () => {
|
||||
it('lists workers with pagination', () => {
|
||||
const req = listWorkers(scope, { limit: 100, offset: 200 });
|
||||
expect(path(req.url)).toBe('/ccx/api/staffing/v6/acme/workers');
|
||||
expect(q(req.url).get('limit')).toBe('100');
|
||||
expect(q(req.url).get('offset')).toBe('200');
|
||||
});
|
||||
it('gets one worker by id', () => {
|
||||
expect(path(getWorker(scope, 'abc123').url)).toBe('/ccx/api/staffing/v6/acme/workers/abc123');
|
||||
});
|
||||
it('lists direct reports under the worker path', () => {
|
||||
expect(path(listDirectReports(scope, 'abc123').url)).toBe(
|
||||
'/ccx/api/staffing/v6/acme/workers/abc123/directReports',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: organizations (staffing)', () => {
|
||||
it('lists supervisory organizations', () => {
|
||||
expect(path(listSupervisoryOrganizations(scope).url)).toBe(
|
||||
'/ccx/api/staffing/v6/acme/supervisoryOrganizations',
|
||||
);
|
||||
});
|
||||
it('gets one organization by id', () => {
|
||||
expect(path(getSupervisoryOrganization(scope, 'o9').url)).toBe(
|
||||
'/ccx/api/staffing/v6/acme/supervisoryOrganizations/o9',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: job requisitions (recruiting)', () => {
|
||||
it('lists requisitions on the recruiting v4 service', () => {
|
||||
expect(path(listJobRequisitions(scope).url)).toBe('/ccx/api/recruiting/v4/acme/jobRequisitions');
|
||||
});
|
||||
it('gets one requisition by id', () => {
|
||||
expect(path(getJobRequisition(scope, 'R-42').url)).toBe(
|
||||
'/ccx/api/recruiting/v4/acme/jobRequisitions/R-42',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: absence (time off)', () => {
|
||||
it('lists eligible absence types on the absenceManagement v1 service', () => {
|
||||
expect(path(listEligibleAbsenceTypes(scope, 'w1').url)).toBe(
|
||||
'/ccx/api/absenceManagement/v1/acme/workers/w1/eligibleAbsenceTypes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: RaaS report', () => {
|
||||
it('targets the customreport2 path and forces format=json', () => {
|
||||
const req = timeOffReport(scope, 'lmcneil', 'Team_Time_Off', { asOf: '2026-07-01' });
|
||||
expect(path(req.url)).toBe('/ccx/service/customreport2/acme/lmcneil/Team_Time_Off');
|
||||
expect(q(req.url).get('format')).toBe('json');
|
||||
expect(q(req.url).get('asOf')).toBe('2026-07-01');
|
||||
});
|
||||
it('defaults format=json even without extra params', () => {
|
||||
expect(q(raasGet(scope, 'owner', 'Rep').url).get('format')).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: path segment encoding', () => {
|
||||
it('encodes ids defensively so a stray value cannot traverse the path', () => {
|
||||
expect(getWorker(scope, 'a/b c').url).toContain('/workers/a%2Fb%20c');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -31,6 +31,7 @@ packages:
|
||||
- 'packages/teams'
|
||||
- 'packages/tools'
|
||||
- 'packages/vscode'
|
||||
- 'packages/workday'
|
||||
- 'packages/zendesk'
|
||||
- 'apps/*'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user