Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/chilly-sheep-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@forgerock/storage': major
'@forgerock/sdk-oidc': major
'@forgerock/journey-client': major
'@forgerock/oidc-client': major
---
Comment on lines +1 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

git diff --name-only | grep -E "(storage|changeset)" | head -30

Repository: ForgeRock/ping-javascript-sdk

Length of output: 55


🏁 Script executed:

# Check for storage package modifications across all package paths
fd --type f -e ts -e json . packages/ 2>/dev/null | grep -i storage | grep -v node_modules | head -30

Repository: ForgeRock/ping-javascript-sdk

Length of output: 497


🏁 Script executed:

# Look at the changeset file context and any related storage changes
cat .changeset/chilly-sheep-follow.md 2>/dev/null || echo "File not found"

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1877


🏁 Script executed:

# Search for GenericError in storage packages to understand the API change
rg "GenericError" --type ts packages/ 2>/dev/null | grep -E "(storage|sdk-effects)" | head -20

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1706


🏁 Script executed:

# Check if storage files were modified in this PR
git diff --name-only HEAD | grep -i storage

Repository: ForgeRock/ping-javascript-sdk

Length of output: 55


🏁 Script executed:

# Check the actual storage.effects.ts file to see the current API
cat -n packages/sdk-effects/storage/src/lib/storage.effects.ts | head -50

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1695


🏁 Script executed:

# Search for any recent changes or TODOs related to storage error handling
rg -n "TODO|FIXME|GenericError" packages/sdk-effects/storage/src/lib/storage.effects.ts -B2 -A2

Repository: ForgeRock/ping-javascript-sdk

Length of output: 1229


🏁 Script executed:

# Check if there are multiple changeset entries for storage
find .changeset -type f -name "*.md" ! -name ".gitkeep" -exec grep -l "storage" {} \;

Repository: ForgeRock/ping-javascript-sdk

Length of output: 134


🏁 Script executed:

cat .changeset/hip-owls-rule.md

Repository: ForgeRock/ping-javascript-sdk

Length of output: 522


🏁 Script executed:

# Also verify if storage package has any actual code changes
rg -n "export" packages/sdk-effects/storage/src/lib/storage.effects.ts | head -20

Repository: ForgeRock/ping-javascript-sdk

Length of output: 361


Remove @forgerock/storage from this changeset.

The changeset marks @forgerock/storage as a major version bump, but the storage package has no changes in this PR. The storage API's transition to return GenericError | null was already completed in SDKS-4361 and is documented in the separate hip-owls-rule.md changeset. The breaking changes described here apply only to journey-client methods, not storage.

🤖 Prompt for AI Agents
In @.changeset/chilly-sheep-follow.md around lines 1 - 6, Remove the
'@forgerock/storage' entry from this changeset header so it no longer lists a
major bump; edit the top YAML list to include only '@forgerock/sdk-oidc',
'@forgerock/journey-client', and '@forgerock/oidc-client' (leaving their
versions as-is) because the storage package has no changes in this PR and its
API changes were already handled in the separate changeset referenced by
SDKS-4361/hip-owls-rule.md.


**BREAKING CHANGE**: Journey client methods now return `GenericError` instead of `undefined` for error cases.

## What Changed

The `start`, `next`, `resume`, and `terminate` methods now return a `GenericError` object instead of `undefined` when encountering unknown step types or error conditions. This aligns the journey client with the DaVinci client's error handling patterns.

### Return Type Changes

| Method | Before | After |
| ----------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `start` | `JourneyStep \| JourneyLoginSuccess \| JourneyLoginFailure \| undefined` | `JourneyStep \| JourneyLoginSuccess \| JourneyLoginFailure \| GenericError` |
| `next` | `JourneyStep \| JourneyLoginSuccess \| JourneyLoginFailure \| undefined` | `JourneyStep \| JourneyLoginSuccess \| JourneyLoginFailure \| GenericError` |
| `resume` | `JourneyStep \| JourneyLoginSuccess \| JourneyLoginFailure \| undefined` | `JourneyStep \| JourneyLoginSuccess \| JourneyLoginFailure \| GenericError` |
| `terminate` | `void` | `void \| GenericError` |

## Migration Guide

Before:

```ts
const step = await client.start();
if (step) {
// Use step
}

After: const result = await client.start();
if ('error' in result) {
// Handle error
console.error(result.message);
} else {
// Use step
}
```
Comment on lines +25 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Migration guide has a formatting issue.

The "After:" section on line 33 is incorrectly placed inside the code block. This will render incorrectly in the changeset documentation.

📝 Suggested fix
 Before:
 
 ```ts
 const step = await client.start();
 if (step) {
   // Use step
 }
+```
 
-After: const result = await client.start();
+After:
+
+```ts
+const result = await client.start();
 if ('error' in result) {
   // Handle error
   console.error(result.message);
 } else {
   // Use step
 }
 ```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Before:
```ts
const step = await client.start();
if (step) {
// Use step
}
After: const result = await client.start();
if ('error' in result) {
// Handle error
console.error(result.message);
} else {
// Use step
}
```
Before:
🤖 Prompt for AI Agents
In @.changeset/chilly-sheep-follow.md around lines 25 - 40, The "After:" label
and the following TypeScript example are accidentally inside the fenced code
block; update the changeset so the initial code block ends before the "After:"
label, place "After:" on its own line, then open a new ```ts fenced block
containing the result example (the const result = await client.start() snippet
and its if/else handling) and close that fence—ensure the original "Before" code
block (const step = await client.start();) remains unchanged and that both
fences are properly balanced around the examples.

31 changes: 25 additions & 6 deletions packages/journey-client/src/lib/client.store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
import { callbackType } from '@forgerock/sdk-types';
import { afterEach, describe, expect, test, vi } from 'vitest';

import type { Step } from '@forgerock/sdk-types';
import type { GenericError, Step } from '@forgerock/sdk-types';

import { journey } from './client.store.js';
import { createJourneyStep } from './step.utils.js';
import { JourneyClientConfig } from './config.types.js';

/**
* Type guard to check if a result is a GenericError
*/
function isGenericError(result: unknown): result is GenericError {
return typeof result === 'object' && result !== null && 'error' in result && 'type' in result;
}
Comment on lines +17 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent type guard implementations.

This type guard checks for 'error' in result && 'type' in result, while the production code in client.store.ts (lines 142-143) checks for 'error' in obj && 'message' in obj. Consider aligning both guards to check the same properties for consistency.

♻️ Suggested alignment
 function isGenericError(result: unknown): result is GenericError {
-  return typeof result === 'object' && result !== null && 'error' in result && 'type' in result;
+  return typeof result === 'object' && result !== null && 'error' in result && 'message' in result;
 }

Or consider extracting a shared isGenericError utility that both test and production code can import.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Type guard to check if a result is a GenericError
*/
function isGenericError(result: unknown): result is GenericError {
return typeof result === 'object' && result !== null && 'error' in result && 'type' in result;
}
/**
* Type guard to check if a result is a GenericError
*/
function isGenericError(result: unknown): result is GenericError {
return typeof result === 'object' && result !== null && 'error' in result && 'message' in result;
}
🤖 Prompt for AI Agents
In `@packages/journey-client/src/lib/client.store.test.ts` around lines 17 - 22,
The test's type guard isGenericError currently checks for 'error' and 'type' but
production's guard checks for 'error' and 'message'; update isGenericError in
client.store.test.ts to check for 'error' in result && 'message' in result to
match the production check (or refactor by extracting a shared isGenericError
utility and import it into both the test and client.store.ts so both use the
identical implementation).


// Create a singleton mock instance for storage
const mockStorageInstance = {
get: vi.fn(),
Expand Down Expand Up @@ -65,13 +72,16 @@ describe('journey-client', () => {
const client = await journey({ config: mockConfig });
const step = await client.start();
expect(step).toBeDefined();
expect(isGenericError(step)).toBe(false);

expect(mockFetch).toHaveBeenCalledTimes(1);
const request = mockFetch.mock.calls[0][0] as Request;
// TODO: This should be /journeys?_action=start, but the current implementation calls /authenticate
expect(request.url).toBe('https://test.com/json/realms/root/authenticate');
expect(step).toHaveProperty('type', 'Step');
expect(step && step.payload).toEqual(mockStepResponse);
if (!isGenericError(step)) {
expect(step.payload).toEqual(mockStepResponse);
}
});

test('next() should send the current step and return the next step', async () => {
Expand Down Expand Up @@ -101,6 +111,7 @@ describe('journey-client', () => {
const client = await journey({ config: mockConfig });
const nextStep = await client.next(initialStep, {});
expect(nextStep).toBeDefined();
expect(isGenericError(nextStep)).toBe(false);

expect(mockFetch).toHaveBeenCalledTimes(1);
const request = mockFetch.mock.calls[0][0] as Request;
Expand All @@ -109,7 +120,9 @@ describe('journey-client', () => {
expect(request.method).toBe('POST');
expect(await request.json()).toEqual(initialStep.payload);
expect(nextStep).toHaveProperty('type', 'Step');
expect(nextStep && nextStep.payload).toEqual(nextStepPayload);
if (!isGenericError(nextStep)) {
expect(nextStep.payload).toEqual(nextStepPayload);
}
});

test('redirect() should store the step and call location.assign', async () => {
Expand Down Expand Up @@ -169,7 +182,9 @@ describe('journey-client', () => {
expect(request.method).toBe('POST');
expect(await request.json()).toEqual(previousStepPayload);
expect(step).toHaveProperty('type', 'Step');
expect(step && step.payload).toEqual(nextStepPayload);
if (!isGenericError(step)) {
expect(step.payload).toEqual(nextStepPayload);
}
});

test('should correctly resume with a plain Step object from storage', async () => {
Expand Down Expand Up @@ -198,7 +213,9 @@ describe('journey-client', () => {
expect(request.method).toBe('POST');
expect(await request.json()).toEqual(plainStepPayload); // Expect the plain payload to be sent
expect(step).toHaveProperty('type', 'Step'); // The returned step should still be an JourneyStep instance
expect(step && step.payload).toEqual(nextStepPayload);
if (!isGenericError(step)) {
expect(step.payload).toEqual(nextStepPayload);
}
});

test('should throw an error if a previous step is required but not found', async () => {
Expand Down Expand Up @@ -234,7 +251,9 @@ describe('journey-client', () => {
const url = new URL(request.url);
expect(url.origin + url.pathname).toBe('https://test.com/json/realms/root/authenticate');
expect(step).toHaveProperty('type', 'Step');
expect(step && step.payload).toEqual(mockStepResponse);
if (!isGenericError(step)) {
expect(step.payload).toEqual(mockStepResponse);
}
});
});

Expand Down
46 changes: 37 additions & 9 deletions packages/journey-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,36 @@ export async function journey({
const self = {
start: async (options?: StartParam) => {
const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
return data ? createJourneyObject(data) : undefined;
if (!data) {
const error: GenericError = {
error: 'no_response_data',
message: 'No data received from server when starting journey',
type: 'unknown_error',
};
return error;
}
return createJourneyObject(data);
},

/**
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
* The `step` to be submitted is provided within the `options` object.
*
* @param options An object containing the current Step payload and optional JourneyClientConfig.
* @returns A Promise that resolves to the next JourneyStep in the journey, or undefined if the journey ends.
* @param step The current JourneyStep containing user input to submit.
* @param options Optional configuration for the request.
* @returns A Promise that resolves to a JourneyStep, JourneyLoginSuccess, JourneyLoginFailure, or GenericError.
*/
next: async (step: JourneyStep, options?: NextOptions) => {
const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options }));
return data ? createJourneyObject(data) : undefined;
if (!data) {
const error: GenericError = {
error: 'no_response_data',
message: 'No data received from server when submitting step',
type: 'unknown_error',
};
return error;
}
return createJourneyObject(data);
},

// TODO: Remove the actual redirect from this method and just return the URL to the caller
Expand All @@ -108,7 +125,7 @@ export async function journey({
resume: async (
url: string,
options?: ResumeOptions,
): Promise<ReturnType<typeof createJourneyObject> | undefined> => {
): Promise<ReturnType<typeof createJourneyObject>> => {
const parsedUrl = new URL(url);
const code = parsedUrl.searchParams.get('code');
const state = parsedUrl.searchParams.get('state');
Expand Down Expand Up @@ -183,11 +200,22 @@ export async function journey({
* This will invalidate the current session and clean up any server-side state.
*
* @param options Optional StepOptions containing query parameters.
* @returns A Promise that resolves when the session is successfully ended.
* @returns A Promise that resolves to void on success, or GenericError on failure.
*/
terminate: async (options?: { query?: Record<string, string> }) => {
const { data } = await store.dispatch(journeyApi.endpoints.terminate.initiate(options));
return data;
terminate: async (options?: {
query?: Record<string, string>;
}): Promise<void | GenericError> => {
const { error } = await store.dispatch(journeyApi.endpoints.terminate.initiate(options));
if (error) {
return {
error: 'terminate_failed',
message:
'status' in error
? `Failed to terminate session: ${error.status}`
: 'Failed to terminate session',
type: 'unknown_error',
};
}
},
};
return self;
Expand Down
4 changes: 2 additions & 2 deletions packages/journey-client/src/lib/device/device-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
import { vi, expect, describe, it, afterEach, beforeEach, SpyInstance } from 'vitest';
import { vi, expect, describe, it, afterEach, beforeEach, MockInstance } from 'vitest';

import { Device } from './device-profile.js';

Expand Down Expand Up @@ -86,7 +86,7 @@ describe('Test DeviceProfile', () => {
});

describe('logLevel tests', () => {
let warnSpy: SpyInstance;
let warnSpy: MockInstance;
const originalNavigator = global.navigator;

beforeEach(() => {
Expand Down
18 changes: 15 additions & 3 deletions packages/journey-client/src/lib/journey.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { StepType } from '@forgerock/sdk-types';

import type { Step } from '@forgerock/sdk-types';
import type { GenericError, Step } from '@forgerock/sdk-types';

import { createJourneyLoginSuccess } from './login-success.utils.js';
import { createJourneyLoginFailure } from './login-failure.utils.js';
Expand All @@ -17,9 +17,17 @@ import type { JourneyStep } from './step.utils.js';
import type { JourneyLoginFailure } from './login-failure.utils.js';
import type { JourneyLoginSuccess } from './login-success.utils.js';

/**
* Creates a journey object from a raw Step response.
* Determines the step type based on the presence of authId or successUrl properties
* and returns the appropriate journey object type.
*
* @param step - The raw Step response from the authentication API
* @returns A JourneyStep, JourneyLoginSuccess, JourneyLoginFailure, or GenericError if the step type cannot be determined
*/
function createJourneyObject(
step: Step,
): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | undefined {
): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError {
let type;
if (step.authId) {
type = StepType.Step;
Expand All @@ -37,7 +45,11 @@ function createJourneyObject(
case StepType.Step:
return createJourneyStep(step);
default:
return undefined;
return {
error: 'unknown_step_type',
message: 'Unable to determine step type from server response',
type: 'unknown_error',
};
}
}

Expand Down
Loading