Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
35 changes: 35 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,31 @@ field, so storage keys, cookie names, CSS class prefixes, state keys, etc. shoul
in `@thunderid/javascript`, re-exported via `@thunderid/browser`/`@thunderid/node`) instead of hardcoding a
literal or repeating an inline `vendor ?? 'thunderid'` fallback. That default belongs in one place.

## Adding or changing a component

The [SDK development specification](https://github.com/thunder-id/thunderid/blob/main/docs-internals/sdk-development/spec.md) (UI) is the contract. In practice, every component:

1. **Lives in its functional group:** `auth/` (sign-in, sign-up, recovery, invites, callback, with `buttons/`, `flow/` and `guards/` inside), `account/` (the signed-in user's own data), `admin/` (management), or `common/` (shared across groups, such as the language switcher and branding). Primitives and the data layer keep their own folders.
2. **Ships a styled variant and a `Base` variant.** `Base` takes its data and callbacks as parameters, with no SDK state, so an app can drive it itself (e.g. `BaseUserList`).
3. **Accepts a custom render on both variants.** Passing it replaces the built-in UI, while the component keeps its data loading, state and actions. The mechanism is the platform's own:
- React and Next.js: a `children` function, the same pattern as `SignIn`.
- Vue and Nuxt: the default scoped slot.
It receives everything needed to draw the component: rows, columns, paging (`limit`, `offset`, `totalResults`, page change) and selection for a list; values, setters, submit and validity for a form; the record and the actions the app has allowed for a details, members, assignments or permissions view; plus loading and error state. Behaviour the specification requires still holds under a custom render: data-changing actions are off unless the app turns them on, a delete asks for confirmation, and server errors appear as translated messages.
4. **Takes every string from the translation bundle**, in every shipped locale.
5. **Tags elements rendered from a server-described flow step** with `thunderid-field-<id>` and `thunderid-action-<ref>`.
6. **Has tests** for the built-in UI and for a custom render (Vitest and Testing Library).
7. **Has a playground story,** including a **Custom rendering** variant drawn with the platform's own UI toolkit, and a capability check if it calls the server.

A component that changes here usually needs the same change in the sibling SDKs. See the parity rules in the specification.

## Samples

The quickstarts and integration samples live in [thunder-id/samples](https://github.com/thunder-id/samples), and each one pins a released version of this SDK. A change here therefore can't break a sample until the sample bumps its version, which is exactly when a break would surface for a real app. Before raising a pull request, check every sample that uses this SDK:

- **A sample stops compiling or behaving the same** (a renamed type or package, a changed signature, a moved default): raise a samples pull request that bumps the SDK version and fixes the sample. Keep it as a draft until the SDK release it needs is out.
- **The change adds something a sample exists to show** (a self-care capability for `self-care/account`, a management component or operation for `b2b/manage`, a new sign-in option for the quickstarts): update that sample in the same samples pull request, so it demonstrates the new capability rather than working around its absence.
- Link the samples pull request from this pull request's **Related PRs**, or say why no sample is affected.

## Code style

- TypeScript, ESLint via `@thunderid/eslint-plugin`, Prettier.
Expand All @@ -116,3 +141,13 @@ packages/
playgrounds/ Playground apps that exercise the SDKs from source (not published)
tests/e2e/ Playwright suite driving the quickstarts against a real ThunderID server
```

Inside a package, code is grouped by function, not by kind:

- Components (`react`, `vue`, `nextjs`, `nuxt`): `auth/` (sign-in, sign-up, recovery, invites, callback, plus
`buttons/`, `flow/` for the flow renderers and `guards/` for `SignedIn`/`SignedOut`/`Loading`), `account/` (the
signed-in user's own components), `admin/` (management UI), `common/` (`Attribution`, `LanguageSwitcher`) and
`primitives/`.
- Data layer (`javascript/src/api`, `react/src/{api,hooks}`, `vue/src/{api,composables}`, `nextjs/src/server/actions`):
`auth/` for auth flows, `account/` for the `/users/me` endpoints, `management/` for the management APIs. Generic
helpers stay at the root of their folder.
27 changes: 27 additions & 0 deletions packages/express/src/ThunderIDExpressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import {
ThunderIDNodeClient,
ThunderIDAuthException,
ThunderIDNodeManagementClient,
ThunderIDRuntimeError,
Storage,
TokenResponse,
User,
Expand Down Expand Up @@ -49,6 +51,31 @@ class ThunderIDExpressClient<T extends ExpressClientConfig = ExpressClientConfig
return this.getUser(sessionId);
}

/**
* Returns the management operations bound to the session named by the request's session cookie,
* so a route handler can administer users, groups, roles, and agents as the signed-in user. The
* session's access token stays on the server and is read on every call.
*
* ```ts
* app.get('/admin/users', protect(), async (req, res) => {
* const admin = req.thunderIDAuth.managementFromRequest(req);
* res.json(await admin.users.list({limit: 20}));
* });
* ```
*
* @throws {ThunderIDRuntimeError} `EXPRESS-CLIENT-MFR-NF01` when the request carries no session cookie.
*/
public managementFromRequest(req: express.Request): ThunderIDNodeManagementClient {
const cookies = req.cookies as Record<string, string | undefined> | undefined;
const sessionId: string | undefined = cookies?.[this.getSessionCookieName()];

if (!sessionId) {
throw new ThunderIDRuntimeError('No session cookie found in the request.', 'EXPRESS-CLIENT-MFR-NF01', 'express');
}

return this.management(sessionId);
}

public async updateUserCredentialsFromRequest(req: express.Request, payload: Record<string, string>): Promise<void> {
const cookies = req.cookies as Record<string, string | undefined> | undefined;
const sessionId: string | undefined = cookies?.[this.getSessionCookieName()];
Expand Down
41 changes: 41 additions & 0 deletions packages/express/src/__tests__/ThunderIDExpressClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,44 @@ describe('ThunderIDExpressClient', () => {
});
});
});

describe('ThunderIDExpressClient managementFromRequest', () => {
it('binds the management client to the session cookie and sends that session token', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({id: 'u1'}), {headers: {'Content-Type': 'application/json'}, status: 200}),
),
);
vi.stubGlobal('fetch', fetchMock);

try {
const client = new ThunderIDExpressClient();
await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'test-client'});
const getAccessToken = vi.spyOn(client, 'getAccessToken').mockResolvedValue('session-token');

const req = {cookies: {[client.getSessionCookieName()]: 'session-cookie-123'}} as unknown as express.Request;

await client.managementFromRequest(req).users.update('u1', {attributes: {name: 'A'}} as never);

const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(getAccessToken).toHaveBeenCalledWith('session-cookie-123');
expect(url).toBe('https://auth.example.com/users/u1');
expect(init.method).toBe('PUT');
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer session-token');
expect(JSON.parse(init.body as string)).toEqual({attributes: {name: 'A'}});
} finally {
vi.unstubAllGlobals();
}
});

it('throws a runtime error when the request has no session cookie', async () => {
const client = new ThunderIDExpressClient();
await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'test-client'});

expect(() => client.managementFromRequest({cookies: {}} as unknown as express.Request)).toThrowError(
expect.objectContaining({code: 'EXPRESS-CLIENT-MFR-NF01'}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect, vi} from 'vitest';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';
import getUsersMe from '../getUsersMe';

// Mock user data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import {describe, it, expect, vi} from 'vitest';
import getUsersMeMeta from '../getUsersMeMeta';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';

describe('getUsersMeMeta', () => {
it('fetches user schema metadata successfully with custom fetcher', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import {Mock, beforeEach, describe, expect, it, vi} from 'vitest';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';
import updateMeCredentials from '../updateMeCredentials';

describe('updateMeCredentials', (): void => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import {Mock, beforeEach, describe, expect, it, vi} from 'vitest';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import type {User} from '../../models/user';
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';
import type {User} from '../../../models/user';
import updateMeProfile from '../updateMeProfile';

describe('updateMeProfile', (): void => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {User} from '../models/user';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {User} from '../../models/user';

/**
* Configuration for the getUsersMe request
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';

/**
* Attribute schema metadata returned by GET /users/me/meta
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';

/**
* Configuration for the updateMeCredentials request
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {User} from '../models/user';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {User} from '../../models/user';

/**
* Configuration for the updateMeProfile request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow';
import logger from '../../utils/logger';
import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../../models/embedded-signin-flow';
import logger from '../../../utils/logger';
import executeEmbeddedSignInFlow from '../executeEmbeddedSignInFlow';

const URL = 'https://localhost:8090/flow/execute';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../models/embedded-signup-flow';
import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../../models/embedded-signup-flow';
import executeEmbeddedSignUpFlow from '../executeEmbeddedSignUpFlow';

const URL = 'https://localhost:8090/flow/execute';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect, vi, beforeEach} from 'vitest';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {User} from '../../models/user';
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';
import {User} from '../../../models/user';
import getUserInfo from '../getUserInfo';

describe('getUserInfo', (): void => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow';
import {EmbeddedRecoveryFlowResponse} from '../models/embedded-recovery-flow';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow';
import {EmbeddedRecoveryFlowResponse} from '../../models/embedded-recovery-flow';

/**
* Executes an embedded recovery flow by sending a request to the flow execution endpoint.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow';
import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../models/embedded-signin-flow';
import injectRequestedPermissions from '../utils/injectRequestedPermissions';
import logger from '../utils/logger';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow';
import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow';
import injectRequestedPermissions from '../../utils/injectRequestedPermissions';
import logger from '../../utils/logger';

const readErrorAssertion = (body: string): string | undefined => {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow';
import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../models/embedded-signup-flow';
import injectRequestedPermissions from '../utils/injectRequestedPermissions';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow';
import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../models/embedded-signup-flow';
import injectRequestedPermissions from '../../utils/injectRequestedPermissions';

const executeEmbeddedSignUpFlow = async ({
url,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {EmbeddedFlowType, EmbeddedFlowExecuteRequestConfig, FlowExecutionError} from '../models/embedded-flow';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedFlowType, EmbeddedFlowExecuteRequestConfig, FlowExecutionError} from '../../models/embedded-flow';

/**
* Response from the user onboarding flow execution.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {ApiFilteringParams, ManagementRequestConfig} from '../../models/api';
import {ManagedFlowListResponse} from '../../models/managed-flow';
import requestManagementResource from '../../utils/requestManagementResource';
import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api';
import {ManagedFlowListResponse} from '../../../models/managed-flow';
import requestManagementResource from '../../../utils/requestManagementResource';

/**
* Configuration for the getFlows request.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../models/flow-meta';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../../models/flow-meta';

/**
* Fetches aggregated flow metadata from the `GET /flow/meta` endpoint.
Expand All @@ -22,7 +22,7 @@ import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../models/flow-met
*
* @example
* ```typescript
* import getFlowMeta from './api/getFlowMeta';
* import getFlowMeta from './api/auth/getFlowMeta';
* import { FlowMetaType } from './models/flow-meta';
*
* const meta = await getFlowMeta({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';
import {User} from '../models/user';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {User} from '../../models/user';

/**
* Retrieves the user information from the specified OIDC userinfo endpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect, vi, type Mock} from 'vitest';
import {ApiFetcher} from '../../models/api';
import {ApiFetcher} from '../../../models/api';
import getFlows from '../../auth/flows/getFlows';
import createAgent from '../agents/createAgent';
import deleteAgent from '../agents/deleteAgent';
import getAgent from '../agents/getAgent';
Expand All @@ -13,7 +14,6 @@ import deleteApplication from '../applications/deleteApplication';
import getApplication from '../applications/getApplication';
import getApplications from '../applications/getApplications';
import updateApplication from '../applications/updateApplication';
import getFlows from '../flows/getFlows';
import getUserTypes from '../user-types/getUserTypes';
import createUser from '../users/createUser';
import deleteUser from '../users/deleteUser';
Expand Down
Loading
Loading