npm package: @ogcio/sag-client
Description: Framework-agnostic client for the Secure API Gateway — authentication, health checks, and authenticated fetching
Author: OGCIO
Latest version: 0.5.0

# @ogcio/sag-client

Framework-agnostic TypeScript client for the **Secure API Gateway**. Handles authentication, health checks, and authenticated data fetching with built-in error handling for session expiry (401) and service unavailability (503).

## Installation

```bash
pnpm add @ogcio/sag-client
# or
npm install @ogcio/sag-client
```

For React hooks, also install peer dependencies:

```bash
pnpm add react swr
```

### Publishing

The package is published to npm as `@ogcio/sag-client` via [release-please](https://github.com/googleapis/release-please) and Azure DevOps Pipelines.

**How it works:**

1. Use [Conventional Commits](https://www.conventionalcommits.org/) when committing changes to `packages/sag-client/`:
   - `feat: ...` — bumps a minor version
   - `fix: ...` — bumps a patch version
   - `feat!: ...` or `BREAKING CHANGE:` — bumps a major version (once past v1)
2. When changes are merged to `main`, the pipeline runs release-please, which opens (or updates) a release PR that bumps the version in `package.json`, updates `CHANGELOG.md`, and updates `.release-please-manifest.json`
3. When the release PR is merged, release-please creates a GitHub release and tag, and the pipeline publishes the package to npm

**Configuration files (repo root):**
- `release-please-config.json` — release-please package configuration
- `.release-please-manifest.json` — current version tracking
- `.azure/pipeline-sag-client.yaml` — Azure DevOps pipeline (CI + release)

## Entry Points

| Import path | Description | Dependencies |
|---|---|---|
| `@ogcio/sag-client` | Framework-agnostic core (SagClient class, auth functions, fetcher) | None (built-in `fetch`) |
| `@ogcio/sag-client/react` | React hooks and provider | `react` >= 19, `swr` >= 2 (peer deps) |

## Core API (`@ogcio/sag-client`)

### SagClient class

```typescript
import { SagClient } from "@ogcio/sag-client"

const client = new SagClient({
  gatewayUrl: "http://localhost:3333",
  appName: "cars",
  // Optional: custom handler for session expiry (default: redirect to sign-in)
  onSessionExpired: () => console.log("Session expired"),
})

// Auth
const status = await client.checkAuth()     // { authenticated, user, app, claims }
const health = await client.checkHealth()   // { available: boolean }
client.signIn()                             // POST-redirect to sign-in
client.signIn({ connector: "social:mygovid" })  // Direct sign-in via social connector
client.signIn({ redirectUrl: "/dashboard" })     // Explicit post-login redirect
client.signOut()                            // POST-redirect to sign-out
await client.invalidateSession()            // Clear server session (e.g. after onboarding)

// Organizations
const orgs = await client.fetchOrganizations()     // [{ id, name, description?, roles }]
await client.selectOrganization("org_xxx")          // Set current org (signed cookie)
const orgId = await client.getSelectedOrganization() // Get current org ID or null
await client.clearSelectedOrganization()             // Clear org selection

// Fetching
const { data } = await client.fetch<Car[]>("/cars")
const { data: orgData } = await client.fetch<Car[]>("/cars", { organizationId: "org_xxx" })

// Or create a reusable fetcher (for SWR, TanStack Query, etc.)
const fetcher = client.createFetcher<Car[]>()
const result = await fetcher("http://localhost:3333/cars")
```

### Standalone functions

For one-off calls without creating a client instance:

```typescript
import {
  checkAuth, checkHealth, signIn, signOut, invalidateSession,
  fetchOrganizations, selectOrganization, getSelectedOrganization, clearSelectedOrganization,
  createGatewayFetcher,
} from "@ogcio/sag-client"

const status = await checkAuth("http://localhost:3333")
const health = await checkHealth("http://localhost:3333")
signIn("http://localhost:3333", "cars")
signIn("http://localhost:3333", "cars", { connector: "social:mygovid" })
signOut("http://localhost:3333", "cars")
await invalidateSession("http://localhost:3333")

// Organization management
const orgs = await fetchOrganizations("http://localhost:3333")
await selectOrganization("http://localhost:3333", "org_xxx")
const orgId = await getSelectedOrganization("http://localhost:3333")
await clearSelectedOrganization("http://localhost:3333")
```

### Error handling

The fetcher throws `SagFetchError` with `status` and `code` properties:

```typescript
import { SagFetchError } from "@ogcio/sag-client"

try {
  await client.fetch("/cars")
} catch (err) {
  if (err instanceof SagFetchError) {
    console.log(err.status)  // 401, 503, etc.
    console.log(err.code)    // "SESSION_EXPIRED", "LOGTO_UNAVAILABLE", etc.
    console.log(err.message) // User-friendly message from the gateway
  }
}
```

### M2M (Machine-to-Machine) requests

When the gateway service is configured for M2M, you can request that the gateway uses `client_credentials` instead of the user's session token by passing `actorType: "m2m"`:

```typescript
// Fetch with M2M token via SagClient
const { data } = await client.fetch<Notification[]>("/notifications", {
  actorType: "m2m",
})

// Or via the standalone fetcher factory
const fetcher = createGatewayFetcher("http://localhost:3333", "cars", undefined, {
  actorType: "m2m",
})
```

This sends the `X-Request-Actor-Type: m2m` header. The user must still be authenticated (M2M is not anonymous). The header name is also available as a constant:

```typescript
import { ACTOR_TYPE_HEADER } from "@ogcio/sag-client"
// ACTOR_TYPE_HEADER === "X-Request-Actor-Type"
```

### Organization-Scoped Requests

When the gateway has a selected organization (via cookie or explicit header), it uses `getOrganizationToken(orgId)` instead of the default `getAccessToken(resource)`. You can pass `organizationId` to any fetch call:

```typescript
// Via SagClient
const { data } = await client.fetch<Message[]>("/messaging", {
  organizationId: "org_xxx",
})

// Via standalone fetcher
const fetcher = createGatewayFetcher("http://localhost:3333", "messaging", undefined, {
  organizationId: "org_xxx",
})
```

This sends the `X-Organization-Id: org_xxx` header. The header name is available as a constant:

```typescript
import { ORGANIZATION_ID_HEADER } from "@ogcio/sag-client"
// ORGANIZATION_ID_HEADER === "X-Organization-Id"
```

### Role Detection Utilities

Pure functions for determining citizen / public-servant / onboarding status from Logto ID-token claims:

```typescript
import {
  isCitizen,
  isPublicServant,
  isInactivePublicServant,
  isCitizenOnboarded,
  CONNECTOR_MYGOVID,
  CONNECTOR_ENTRAID,
  ALLOWED_SIGNIN_METHODS,
  DEFAULT_PUBLIC_SERVANT_ROLES,  // ["Organisation Admin", "Organisation Member"]
  ORG_ROLE_ADMIN,                // "Organisation Admin"
  ORG_ROLE_MEMBER,               // "Organisation Member"
} from "@ogcio/sag-client"

// After checking auth:
const status = await client.checkAuth()
if (status.authenticated) {
  const { organization_roles, roles } = status.claims

  // Use the platform defaults for citizen-facing apps:
  const citizen = isCitizen(organization_roles, DEFAULT_PUBLIC_SERVANT_ROLES)
  const publicServant = isPublicServant(organization_roles, DEFAULT_PUBLIC_SERVANT_ROLES)

  // Or use custom roles for admin apps:
  const isMessagingPS = isPublicServant(organization_roles, ["Messaging Public Servant"])

  const inactive = isInactivePublicServant(organization_roles)
  const onboarded = isCitizenOnboarded(roles)
}
```

### Onboarding Helpers

Build redirect URLs for the onboarding and wrong-login-method flows:

```typescript
import { buildOnboardingRedirectUrl, buildWrongLoginMethodRedirect } from "@ogcio/sag-client"

// Redirect to onboarding (after invalidating the session)
const url = buildOnboardingRedirectUrl({
  profileUrl: "https://profile.example.com",
  currentPath: "/messages/123",
  gatewayUrl: "http://localhost:3333",
  appName: "messaging",
  appBaseUrl: "https://messaging.example.com",  // where the user lands after auth
  connector: "social:mygovid",  // optional
})
window.location.href = url

// Redirect to wrong-login-method error page
const errorUrl = buildWrongLoginMethodRedirect({
  profileUrl: "https://profile.example.com",
  currentUrl: "https://messaging.example.com/messages/123",
})
```

### Onboarding Flow (End-to-End)

For citizen-facing **React** applications, the recommended approach is the `useOnboardingGuard` hook (see the [React API](#react-api-ogciosag-clientreact) section below). It encapsulates all role checks, session invalidation, redirect URL construction, and infinite-loop prevention in a single call.

For **non-React** or server-side consumers, you can implement the same logic manually with the standalone helpers:

```typescript
import {
  ALLOWED_SIGNIN_METHODS,
  CONNECTOR_MYGOVID,
  buildOnboardingRedirectUrl,
  buildWrongLoginMethodRedirect,
  isCitizen,
  isCitizenOnboarded,
} from "@ogcio/sag-client"

// After checking auth (e.g. in a page guard):
const status = await client.checkAuth()
if (!status.authenticated) return

const { roles, organization_roles } = status.claims

// 1. Wrong sign-in method → redirect to error page
const signinMethod = status.claims.signinMethod  // if available
if (signinMethod && !ALLOWED_SIGNIN_METHODS.includes(signinMethod)) {
  window.location.href = buildWrongLoginMethodRedirect({
    profileUrl: PROFILE_URL,
    currentUrl: window.location.href,
  })
  return
}

// 2. Not a citizen → skip (or redirect to admin app)
if (!isCitizen(organization_roles, PUBLIC_SERVANT_ROLES)) {
  return
}

// 3. Citizen but not onboarded → invalidate session and redirect
if (!isCitizenOnboarded(roles)) {
  await client.invalidateSession()
  window.location.href = buildOnboardingRedirectUrl({
    profileUrl: PROFILE_URL,
    currentPath: window.location.pathname,
    gatewayUrl: GATEWAY_URL,
    appName: APP_NAME,
    appBaseUrl: APP_BASE_URL,  // e.g. "http://localhost:3000"
    connector: CONNECTOR_MYGOVID,
  })
  return
}

// 4. Citizen is onboarded → proceed normally
```

The redirect chain is:

```
app → profile-service/onboarding?source=<gateway-sign-in-url>
   → (user completes onboarding, gains "Onboarded citizen" role)
   → GET gateway/auth/sign-in?app=<name>&redirectUrl=<app-url>&connector=social:mygovid
   → Logto OIDC flow (fresh claims)
   → gateway/auth/callback
   → app (original page, now with updated roles)
```

> **Note:** `appBaseUrl` is the consuming application's URL (e.g. `http://localhost:3000`), not the gateway URL. This ensures the user lands back on the correct app page after the post-onboarding sign-in completes.

### Types

```typescript
import type {
  ActorType,                    // "user" | "m2m"
  AuthClaims,                   // { roles, organizations, organization_roles, signinMethod? }
  AuthStatus,
  AuthUser,
  GatewayFetchOptions,          // { actorType?, organizationId? }
  OrganizationInfo,             // { id, name, description?, roles }
  SagClientConfig,
  SignInOptions,                // { connector?, redirectUrl? }
  UseAuthResult,
  UseOnboardingGuardOptions,
  UseOnboardingGuardResult,
  UsePublicServantGuardOptions,
  UsePublicServantGuardResult,
  OnboardingRedirectParams,
  WrongLoginMethodParams,
} from "@ogcio/sag-client"
```

## React API (`@ogcio/sag-client/react`)

### Provider

Wrap your app with `SagClientProvider`:

```tsx
import { SagClientProvider } from "@ogcio/sag-client/react"

export function Providers({ children }) {
  return (
    <SagClientProvider gatewayUrl="http://localhost:3333" appName="cars">
      {children}
    </SagClientProvider>
  )
}
```

### Hooks

#### `useAuth`

```tsx
import {
  useAuth,
  useSagClient,
  CONNECTOR_MYGOVID,
} from "@ogcio/sag-client/react"

function MyComponent() {
  const {
    authenticated,
    user,
    claims,           // { roles, organizations, organization_roles, signinMethod? }
    loading,
    logtoAvailable,
    signIn,           // accepts optional SignInOptions
    signOut,
    invalidateSession,
    refresh,
  } = useAuth()

  // Sign in with a specific connector
  const handleCitizenSignIn = () => signIn({ connector: CONNECTOR_MYGOVID })

  // Direct client access (advanced)
  const client = useSagClient()
}
```

#### `useGatewayFetch`

```tsx
import { useGatewayFetch } from "@ogcio/sag-client/react"

// SWR-based data fetching
const { data, error, isLoading, refresh } = useGatewayFetch<Car[]>("/cars")

// Conditional fetching
const { data: detail } = useGatewayFetch<Car>(`/cars/${id}`, { enabled: !!id })

// M2M fetching (sends X-Request-Actor-Type: m2m header)
const { data: notifications } = useGatewayFetch<Notification[]>("/notifications", {
  actorType: "m2m",
})
```

#### `useOnboardingGuard`

Encapsulates the full citizen onboarding check for React applications. Must be used inside a `SagClientProvider`. Reads `gatewayUrl` and `appName` from the provider context automatically.

```tsx
import {
  useOnboardingGuard,
  useAuth,
  CONNECTOR_MYGOVID,
} from "@ogcio/sag-client/react"

function ShellContent({ children }) {
  // publicServantRoles defaults to DEFAULT_PUBLIC_SERVANT_ROLES
  // (["Organisation Admin", "Organisation Member"])
  const { resolved } = useOnboardingGuard({
    profileUrl: "http://localhost:3001",
    appBaseUrl: "http://localhost:3000",
    connector: CONNECTOR_MYGOVID,   // optional
    // publicServantRoles: ["Custom Role"],  // override for admin apps
    // debounceMs: 30_000,                   // optional, default: 30000
  })

  const { user, signIn, signOut } = useAuth()

  if (!resolved) return <Loading />

  return user ? <App>{children}</App> : <SignInButton />
}
```

**Options:**

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `profileUrl` | `string` | Yes | - | Profile service base URL |
| `appBaseUrl` | `string` | Yes | - | Consuming application's base URL (for post-auth redirect) |
| `publicServantRoles` | `string[]` | No | `DEFAULT_PUBLIC_SERVANT_ROLES` | Organisation roles that identify a public servant |
| `connector` | `string` | No | `undefined` | Logto `directSignIn` connector (e.g. `"social:mygovid"`) |
| `debounceMs` | `number` | No | `30000` | Time window (ms) to prevent redirect loops |

**Return value:**

| Property | Type | Description |
|----------|------|-------------|
| `resolved` | `boolean` | `true` when the onboarding check has passed; `false` while loading or redirecting |

**Behaviour:**

1. User is onboarded (`isCitizenOnboarded`) → `resolved = true`
2. User is not a citizen → `resolved = true` (let them through)
3. Recent redirect within `debounceMs` → `resolved = true` (prevent loops)
4. Wrong sign-in method → redirect to profile service error page
5. Citizen not onboarded → `invalidateSession()` then redirect to profile service onboarding page

> **Important:** Do not render data-fetching children (e.g. `useGatewayFetch`) until `resolved` is `true` to prevent API requests from racing with session invalidation.

#### `usePublicServantGuard`

Access guard hook for **public-servant-facing** (admin) applications. Checks if the authenticated user is an active public servant and optionally redirects unauthorized users.

```tsx
import { usePublicServantGuard, useAuth } from "@ogcio/sag-client/react"

function AdminShell({ children }) {
  const { resolved, authorized, isInactive } = usePublicServantGuard({
    // Redirect citizens to the citizen-facing app (optional)
    unauthorizedRedirectUrl: "https://citizen-app.example.com",
    // Redirect inactive PS to a specific page (optional)
    inactiveRedirectUrl: "https://admin.example.com/inactive",
    // publicServantRoles: ["Custom Admin Role"],  // override defaults
  })

  const { user } = useAuth()

  if (!resolved) return <Loading />
  if (!authorized) return <AccessDenied />
  return <>{children}</>
}
```

**Options:**

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `publicServantRoles` | `string[]` | No | `DEFAULT_PUBLIC_SERVANT_ROLES` | Organisation roles that identify an active public servant |
| `inactiveRedirectUrl` | `string` | No | `undefined` | URL to redirect inactive PS users to. If omitted, they pass through with `isInactive: true`. |
| `unauthorizedRedirectUrl` | `string` | No | `undefined` | URL to redirect non-PS users to. If omitted, they pass through with `authorized: false`. |

**Return value:**

| Property | Type | Description |
|----------|------|-------------|
| `resolved` | `boolean` | `true` once the guard check has completed |
| `authorized` | `boolean` | `true` when the user is an active public servant |
| `isInactive` | `boolean` | `true` when the user is an inactive public servant |

#### `useGatewayFetch` with organization scope

```tsx
import { useGatewayFetch } from "@ogcio/sag-client/react"

// Fetch with an organization-scoped token
const { data } = useGatewayFetch<Message[]>("/messaging", {
  organizationId: "org_xxx",
})
```

#### Role detection utilities

Role detection functions are also re-exported from the React entry point for convenience:

```tsx
import {
  isCitizen,
  isCitizenOnboarded,
  isPublicServant,
  CONNECTOR_MYGOVID,
  ALLOWED_SIGNIN_METHODS,
  DEFAULT_PUBLIC_SERVANT_ROLES,
  ORG_ROLE_ADMIN,
  ORG_ROLE_MEMBER,
  ONBOARDING_PATH,
  ONBOARDING_SOURCE_PARAM,
  WRONG_LOGIN_METHOD_PATH,
  WRONG_LOGIN_RETURN_URL_PARAM,
} from "@ogcio/sag-client/react"
```

## Package Structure

```
src/
  index.ts            # Core entry point
  client.ts           # SagClient class
  auth.ts             # Standalone auth functions (signIn, signOut, invalidateSession, etc.)
  fetcher.ts          # Gateway fetcher factory
  roles.ts            # Role detection utilities (isCitizen, isPublicServant, etc.)
  onboarding.ts       # Onboarding & wrong-login-method URL builders
  types.ts            # All type definitions
  react/
    index.ts          # React entry point (re-exports roles & onboarding for convenience)
    provider.tsx      # SagClientProvider + useSagClient
    use-auth.ts       # useAuth hook (with claims, connector-aware signIn, invalidateSession)
    use-gateway-fetch.ts          # useGatewayFetch hook
    use-onboarding-guard.ts       # useOnboardingGuard hook (citizen onboarding guard)
    use-public-servant-guard.ts   # usePublicServantGuard hook (admin app access guard)
```