diff --git a/.eslintrc.json b/.eslintrc.json index d56654ad3..09821ff5c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,39 @@ { "root": true, - "plugins": ["@typescript-eslint", "@nx"], + "plugins": ["@typescript-eslint", "@nx", "header"], "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "excludedFiles": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.spec.jsx", + "**/*.test.js", + "**/*.test.jsx", + "**/*.test.mjs", + "**/*.spec.mjs", + "**/vite*.config.ts", + "**/vitest.setup.ts", + "**/playwright.config.ts", + "**/_polyfills/**", + "tools/**" + ], + "rules": { + "header/header": [ + "warn", + "block", + [ + { + "pattern": "[\\s\\S]*Copyright[\\s\\S]*Ping Identity[\\s\\S]*", + "template": "\n * Copyright (c) Ping Identity Corporation. All rights reserved.\n * This software may be modified and distributed under the terms\n * of the MIT license. See the LICENSE file for details.\n " + } + ] + ] + } + }, { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { diff --git a/.husky/pre-commit b/.husky/pre-commit index e75ce0cb6..6a163a88e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged && npx nx affected:lint && npx nx affected:build +node tools/copyright/sync-header-years.mjs && npx lint-staged && npx nx affected:lint && npx nx affected:build diff --git a/contributing_docs/releases.md b/contributing_docs/releases.md index ec5c738b6..d7f2dad32 100644 --- a/contributing_docs/releases.md +++ b/contributing_docs/releases.md @@ -4,6 +4,15 @@ We use changesets to handle publishing of all packages in the repository. Please see the changesets repository for documentation on how to use changesets. Below will be a brief summary. +## Table of Contents + +- [Adding a changeset](#adding-a-changeset) +- [Versioning](#versioning) +- [Adding a package to the repository](#adding-a-package-to-the-repository) +- [Testing a package publish](#testing-a-package-publish) +- [First time releasing a package](#first-time-releasing-a-package) +- [Publishing a beta](#publishing-a-beta) + ## Adding a changeset You can run `pnpm changeset` in order to add a changeset. You then @@ -55,46 +64,72 @@ This is common for `e2e` related applications. We don't version or care about publishing them. You will see in the `.changesets/config.json` these are listed in the `ignore` field, and they will all have `private:true` in the package.json +## Testing a package publish + +In order to test a package publish, you should use `verdaccio`. + +We provide verdaccio two ways: + +1. `pnpm nx run local-registry`. This command will spawn a private npm registry. It also _should_ update your local `.npmrc` file to point here. + + You can then publish your package like so: + + ```bash + pnpm changeset version + pnpm publish packages/{your_package} --dry-run --no-git-checks --registry=http://localhost:4873 + ``` + + **Notes**: + + - The `changeset` command will version your packages before the test release. To version them as a beta add `--snapshot beta` to the changeset command + - I am including the `dry-run` flag here so if you copy paste it, you will "dry-run" the publish. + - I also like to add the `registry` flag, as a secondary check to make sure I publish to this registry. + - The `-r` flag is necessary if your package requires other workspace packages to be published. This command runs `publish` recursively via pnpm's topological graph. To publish all packages, include the `-r` flag and remove `packages/{yourpackage}` from the publish command. + - Include the `--no-git-checks` flag to ignore the changes made by the versioning command + - To test publish a beta, add `--tag beta` + - If you are publishing from a branch other than `main`, add `--publish-branch {branch-name}` + +2. Publishing to a hosted private registry: Please message `@ryan.basmajian` on Slack. + ## First time releasing a package -If your package is ready to be released, and has never been released before, -(the package.json `name` field does not exist on `npm`), then it is critical that -your `{packageRoot}/package.json` has the following: +If your package is ready to be released, and has never been released before, (the package.json `name` field does not exist on `npm`), then it is critical that the package be published manually as a beta first. -``` +First ensure that the `{packageRoot}/package.json` has the following: + +```json "publishConfig": { - "access": true + "access": "public" } ``` -If your package does not contain this information, your package publishing **WILL** -break the publish pipeline. +When the package is officially ready for release, you should also delete the `private: true` from the `{projectRoot}/package.json`. -This is because all packages in this repository are published with `npm provenance`. -You can read about the requirements [here](https://docs.npmjs.com/generating-provenance-statements#prerequisites). +Then publish the package to npm: -## Testing a package publish - -In order to test a package publish, you should use `verdaccio`. +```bash +# Version packages for beta +pnpm changeset version --snapshot beta +# Check that the beta tag is correct in a dry run +pnpm publish --tag beta --no-git-checks --access public --dry-run +# Publish beta for the first time +pnpm publish --tag beta --no-git-checks --access public +``` -We provide verdaccio two ways: +If you do not do this, your package publishing **WILL** break the publish pipeline. Publishing manually first prevents the package being published as the default private. -- `pnpm nx run local-registry`. This command will spawn a private npm registry. - It also _should_ update your local `.npmrc` file to point here. +Next set up provenance and trusted publishing. With trusted publishing enabled, provenance attestations will be generated automatically. Learn more [here](https://docs.npmjs.com/trusted-publishers#automatic-provenance-generation). - You can then publish your package like so: +To set up trusted publishing, follow the instructions [here](https://docs.npmjs.com/trusted-publishers#for-github-actions). Configure the following fields: - ```bash - pnpm publish packages/{your_package} --dry-run --registry=http://localhost:4873 - ``` +- **Publisher**: GitHub Actions +- **Organization**: ForgeRock +- **Repository**: ping-javascript-sdk +- **Workflow filename**: publish.yml - Notes: - I am including the `dry-run` flag here so if you copy paste it, - you will "dry-run" the publish. - I also like to add the `registry` flag, as a secondary check to - make sure i publish to this registry. - The `-r` flag is necessary if your package requires other workspace packages - to be published. This command runs `publish` recursively via pnpm's - topological graph. +Additionally, set the publishing access to `Require two-factor authentication and disallow tokens`. -- Publishing to a hosted private registry: Please message @ryanbas21 on slack. +You should now be able to publish with provenance from GitHub Actions. To learn how to publish a beta from GitHub Actions see the next section [Publishing a beta](#publishing-a-beta) below. ## Publishing a beta diff --git a/e2e/autoscript-apps/src/index.ts b/e2e/autoscript-apps/src/index.ts index be15a9f01..4d153b792 100644 --- a/e2e/autoscript-apps/src/index.ts +++ b/e2e/autoscript-apps/src/index.ts @@ -1,2 +1,11 @@ +/* + * @forgerock/javascript-sdk + * + * index.ts + * + * Copyright (c) 2021 - 2026 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ import 'core-js/stable'; import 'regenerator-runtime'; diff --git a/e2e/autoscript-suites/config.ts b/e2e/autoscript-suites/config.ts index 97fcc1e94..3d9c6bd98 100644 --- a/e2e/autoscript-suites/config.ts +++ b/e2e/autoscript-suites/config.ts @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2021 - 2026 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ import { workspaceRoot } from '@nx/devkit'; import { PlaywrightTestConfig } from '@playwright/test'; import { baseConfig } from './playwright.config'; diff --git a/e2e/mock-api/src/environments/environment.prod.ts b/e2e/mock-api/src/environments/environment.prod.ts index c9669790b..95d059420 100644 --- a/e2e/mock-api/src/environments/environment.prod.ts +++ b/e2e/mock-api/src/environments/environment.prod.ts @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2021 - 2026 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ export const environment = { production: true, }; diff --git a/e2e/mock-api/src/environments/environment.ts b/e2e/mock-api/src/environments/environment.ts index a24f6ba2c..cd0e59d7b 100644 --- a/e2e/mock-api/src/environments/environment.ts +++ b/e2e/mock-api/src/environments/environment.ts @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2021 - 2026 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ export const environment = { AM_URL: 'https://openam-crbrl-01.forgeblocks.com/am/', REALM_PATH: 'alpha', diff --git a/e2e/token-vault-suites/teardown.ts b/e2e/token-vault-suites/teardown.ts index 09b49e70b..4290bcfb2 100644 --- a/e2e/token-vault-suites/teardown.ts +++ b/e2e/token-vault-suites/teardown.ts @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2021 - 2026 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ export default () => { console.log('tests finished'); }; diff --git a/package.json b/package.json index 206527043..88aa9c8b7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "ci:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm nx format:write --uncommitted", "changeset": "changeset", "commit": "git cz", + "copyright:check": "node ./tools/copyright/sync-header-years.mjs --check", + "copyright:sync": "node ./tools/copyright/sync-header-years.mjs", "docs": "nx affected --target=typedoc", "e2e": "CI=true nx affected:e2e", "format:staged": "pretty-quick --staged", @@ -83,6 +85,7 @@ "esbuild": "^0.19.2", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", + "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "2.27.5", "eslint-plugin-playwright": "^1.5.1", "eslint-plugin-prettier": "^5.1.3", diff --git a/packages/javascript-sdk/CHANGELOG.md b/packages/javascript-sdk/CHANGELOG.md index 33cae9e0c..084b11eb9 100644 --- a/packages/javascript-sdk/CHANGELOG.md +++ b/packages/javascript-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 4.9.1 + +### Patch Changes + +- [#587](https://github.com/ForgeRock/forgerock-javascript-sdk/pull/587) [`d14d301`](https://github.com/ForgeRock/forgerock-javascript-sdk/commit/d14d301349bb08040363be5dafc01e100fb5862d) Thanks [@ForgeRockEmma](https://github.com/ForgeRockEmma)! - fix: move getAuthenticationCredential back inside try/catch so that WebAuthn cancellation errors (e.g. NotAllowedError) are written to the HiddenValueCallback before re-throwing + ## 4.9.0 ### Minor Changes diff --git a/packages/javascript-sdk/package.json b/packages/javascript-sdk/package.json index d820b2f02..e0082d51e 100644 --- a/packages/javascript-sdk/package.json +++ b/packages/javascript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@forgerock/javascript-sdk", - "version": "4.9.0", + "version": "4.9.1", "description": "ForgeRock JavaScript SDK", "author": "ForgeRock", "license": "MIT", diff --git a/packages/javascript-sdk/src/device-client/mock-data/msw-mock-data.ts b/packages/javascript-sdk/src/device-client/mock-data/msw-mock-data.ts index c204c0fa8..118e2f5d5 100644 --- a/packages/javascript-sdk/src/device-client/mock-data/msw-mock-data.ts +++ b/packages/javascript-sdk/src/device-client/mock-data/msw-mock-data.ts @@ -1,3 +1,12 @@ +/* + * @forgerock/javascript-sdk + * + * msw-mock-data.ts + * + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ import { GeneralResponse } from '../services/index.js'; import type { OathResponse, diff --git a/packages/javascript-sdk/src/fr-recovery-codes/script-text.mock.data.ts b/packages/javascript-sdk/src/fr-recovery-codes/script-text.mock.data.ts index f5eda7b2a..a9ceb18ec 100644 --- a/packages/javascript-sdk/src/fr-recovery-codes/script-text.mock.data.ts +++ b/packages/javascript-sdk/src/fr-recovery-codes/script-text.mock.data.ts @@ -1,13 +1,13 @@ -/* eslint-disable no-useless-escape */ /* * @forgerock/javascript-sdk * * script-text.mock.data.ts * - * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +/* eslint-disable no-useless-escape */ import type { CallbackType } from '../auth/enums'; diff --git a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts index 15bce073a..188b7447c 100644 --- a/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts +++ b/packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts @@ -8,7 +8,7 @@ * of the MIT license. See the LICENSE file for details. */ -import { WebAuthnStepType } from './enums'; +import { WebAuthnOutcome, WebAuthnStepType } from './enums'; import FRWebAuthn from './index'; import { webAuthnRegJSCallback653, @@ -23,6 +23,7 @@ import { webAuthnAuthMetaCallback70StoredUsername, webAuthnAuthConditionalMetaCallback, } from './fr-webauthn.mock.data'; +import { CallbackType } from '../auth/enums'; import FRStep from '../fr-auth/fr-step'; import Config from '../config'; @@ -245,3 +246,62 @@ describe('Test FRWebAuthn class with Conditional UI', () => { expect(Array.from(idArray)).toEqual([1, 2, 3, 4]); }); }); + +describe('Test FRWebAuthn class with cancellation error handling', () => { + beforeEach(() => { + Object.defineProperty(global.navigator, 'credentials', { + value: { + get: vi.fn(), + create: vi.fn(), + }, + writable: true, + }); + Object.defineProperty(window, 'PublicKeyCredential', { + value: { + // Mocked as supported so conditional mediation checks pass through to the credential call + isConditionalMediationAvailable: vi.fn().mockResolvedValue(true), + }, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should write NotAllowedError to HiddenValueCallback when user cancels conditional authentication', async () => { + const cancelError = new Error('The operation either timed out or was not allowed.'); + cancelError.name = 'NotAllowedError'; + vi.spyOn(navigator.credentials, 'get').mockRejectedValue(cancelError); + + const step = new FRStep(webAuthnAuthConditionalMetaCallback as any); + + await expect(FRWebAuthn.authenticate(step)).rejects.toMatchObject({ + name: 'NotAllowedError', + }); + + const hiddenCallback = step.getCallbacksOfType(CallbackType.HiddenValueCallback)[0]; + expect(hiddenCallback).toBeDefined(); + expect(hiddenCallback.getInputValue()).toBe( + `${WebAuthnOutcome.Error}::NotAllowedError:The operation either timed out or was not allowed.`, + ); + }); + + it('should write NotAllowedError to HiddenValueCallback when user cancels standard authentication', async () => { + const cancelError = new Error('The operation either timed out or was not allowed.'); + cancelError.name = 'NotAllowedError'; + vi.spyOn(navigator.credentials, 'get').mockRejectedValue(cancelError); + + const step = new FRStep(webAuthnAuthMetaCallback70 as any); + + await expect(FRWebAuthn.authenticate(step)).rejects.toMatchObject({ + name: 'NotAllowedError', + }); + + const hiddenCallback = step.getCallbacksOfType(CallbackType.HiddenValueCallback)[0]; + expect(hiddenCallback).toBeDefined(); + expect(hiddenCallback.getInputValue()).toBe( + `${WebAuthnOutcome.Error}::NotAllowedError:The operation either timed out or was not allowed.`, + ); + }); +}); diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts index ee1ec5ec5..00714391e 100644 --- a/packages/javascript-sdk/src/fr-webauthn/index.ts +++ b/packages/javascript-sdk/src/fr-webauthn/index.ts @@ -199,6 +199,26 @@ abstract class FRWebAuthn { } else { throw new Error('No Credential found from Public Key'); } + const credential: PublicKeyCredential | null = await this.getAuthenticationCredential( + optionsTransformer(options), + ); + const outcome: ReturnType = + this.getAuthenticationOutcome(credential); + + if (metadataCallback) { + const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; + if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) { + hiddenCallback.setInputValue( + JSON.stringify({ + authenticatorAttachment: credential.authenticatorAttachment, + legacyData: outcome, + }), + ); + return step; + } + } + hiddenCallback.setInputValue(outcome); + return step; } catch (error) { if (!(error instanceof Error)) throw error; // NotSupportedError is a special case @@ -209,27 +229,6 @@ abstract class FRWebAuthn { hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`); throw error; } - - const credential: PublicKeyCredential | null = await this.getAuthenticationCredential( - optionsTransformer(options), - ); - const outcome: ReturnType = - this.getAuthenticationOutcome(credential); - - if (metadataCallback) { - const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata; - if (meta?.supportsJsonResponse && credential && 'authenticatorAttachment' in credential) { - hiddenCallback.setInputValue( - JSON.stringify({ - authenticatorAttachment: credential.authenticatorAttachment, - legacyData: outcome, - }), - ); - return step; - } - } - hiddenCallback.setInputValue(outcome); - return step; } else { const e = new Error('Incorrect callbacks for WebAuthn authentication'); e.name = WebAuthnOutcomeType.DataError; diff --git a/packages/javascript-sdk/src/fr-webauthn/script-parser.ts b/packages/javascript-sdk/src/fr-webauthn/script-parser.ts index 2b58927c1..997dc76ef 100644 --- a/packages/javascript-sdk/src/fr-webauthn/script-parser.ts +++ b/packages/javascript-sdk/src/fr-webauthn/script-parser.ts @@ -1,13 +1,13 @@ -/* eslint-disable no-useless-escape */ /* * @forgerock/javascript-sdk * * script-parser.ts * - * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ +/* eslint-disable no-useless-escape */ import { WebAuthnOutcomeType } from './enums'; import { ensureArray, getIndexOne, parsePubKeyArray, parseCredentials } from './helpers'; diff --git a/packages/javascript-sdk/src/oauth2-client/state-pkce.ts b/packages/javascript-sdk/src/oauth2-client/state-pkce.ts index fb800558a..0f34a1cee 100644 --- a/packages/javascript-sdk/src/oauth2-client/state-pkce.ts +++ b/packages/javascript-sdk/src/oauth2-client/state-pkce.ts @@ -46,13 +46,14 @@ export function generateAndStoreAuthUrlValues(options: GenerateAndStoreAuthUrlVa /** * @function getStoredAuthUrlValues - Retrieve stored authorization options from sessionStorage - * @param { string } storageKey - Key to retrieve stored values from sessionStorage - * @returns { GetAuthorizationUrlOptions } + * @param { string } clientId - Client ID + * @param { string } [prefix] - Prefix for storage key + * @returns { GetAuthorizationUrlOptions | null } */ export function getStoredAuthUrlValues( clientId: string, prefix?: string, -): GetAuthorizationUrlOptions { +): GetAuthorizationUrlOptions | null { const storageKey = getStorageKey(clientId, prefix); const storedString = sessionStorage.getItem(storageKey); sessionStorage.removeItem(storageKey); diff --git a/packages/javascript-sdk/src/token-manager/index.ts b/packages/javascript-sdk/src/token-manager/index.ts index a1fc73025..999f6272d 100644 --- a/packages/javascript-sdk/src/token-manager/index.ts +++ b/packages/javascript-sdk/src/token-manager/index.ts @@ -128,7 +128,7 @@ abstract class TokenManager { * and return acquired tokens */ if (options?.query?.code && options?.query?.state) { - const { state, verifier } = getStoredAuthUrlValues(clientId, prefix); + const { state, verifier } = getStoredAuthUrlValues(clientId, prefix) ?? {}; if (state === undefined || verifier === undefined) { throw new Error( diff --git a/packages/ping-protect/CHANGELOG.md b/packages/ping-protect/CHANGELOG.md index ee4b27207..cda6321d0 100644 --- a/packages/ping-protect/CHANGELOG.md +++ b/packages/ping-protect/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 4.7.1 + +### Patch Changes + +- Updated dependencies [[`d14d301`](https://github.com/ForgeRock/forgerock-javascript-sdk/commit/d14d301349bb08040363be5dafc01e100fb5862d)]: + - @forgerock/javascript-sdk@4.9.1 + ## 4.7.0 ### Minor Changes diff --git a/packages/ping-protect/package.json b/packages/ping-protect/package.json index 07631944a..e46eade24 100644 --- a/packages/ping-protect/package.json +++ b/packages/ping-protect/package.json @@ -1,6 +1,6 @@ { "name": "@forgerock/ping-protect", - "version": "4.7.0", + "version": "4.7.1", "private": false, "type": "module", "files": ["./dist/*"], diff --git a/packages/token-vault/CHANGELOG.md b/packages/token-vault/CHANGELOG.md index 7b6183a85..f15f9bd55 100644 --- a/packages/token-vault/CHANGELOG.md +++ b/packages/token-vault/CHANGELOG.md @@ -1,5 +1,12 @@ ### [4.2.0] - 2023-09-11 +## 4.2.2 + +### Patch Changes + +- Updated dependencies [[`d14d301`](https://github.com/ForgeRock/forgerock-javascript-sdk/commit/d14d301349bb08040363be5dafc01e100fb5862d)]: + - @forgerock/javascript-sdk@4.9.1 + ## 4.2.1 ### Patch Changes diff --git a/packages/token-vault/package.json b/packages/token-vault/package.json index e31917d57..441457cb4 100644 --- a/packages/token-vault/package.json +++ b/packages/token-vault/package.json @@ -1,6 +1,6 @@ { "name": "@forgerock/token-vault", - "version": "4.2.1", + "version": "4.2.2", "private": false, "type": "module", "files": ["dist/*"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a7c1b66..9ff8dc354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: eslint-config-prettier: specifier: 9.1.0 version: 9.1.0(eslint@8.57.0) + eslint-plugin-header: + specifier: ^3.1.1 + version: 3.1.1(eslint@8.57.0) eslint-plugin-import: specifier: 2.27.5 version: 2.27.5(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0) @@ -6515,6 +6518,14 @@ packages: eslint-import-resolver-webpack: optional: true + eslint-plugin-header@3.1.1: + resolution: + { + integrity: sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==, + } + peerDependencies: + eslint: '>=7.7.0' + eslint-plugin-import@2.27.5: resolution: { @@ -7252,6 +7263,7 @@ packages: integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==, } engines: { node: '>=16' } + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -7279,6 +7291,7 @@ packages: { integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==, } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@6.0.4: @@ -7286,14 +7299,14 @@ packages: { integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==, } - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: { integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, } - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: @@ -12911,6 +12924,7 @@ packages: integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==, } engines: { node: '>=12' } + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: @@ -17695,6 +17709,10 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-header@3.1.1(eslint@8.57.0): + dependencies: + eslint: 8.57.0 + eslint-plugin-import@2.27.5(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0): dependencies: array-includes: 3.1.8 diff --git a/tools/copyright/sync-header-years.mjs b/tools/copyright/sync-header-years.mjs new file mode 100644 index 000000000..d1831af3f --- /dev/null +++ b/tools/copyright/sync-header-years.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +function isCliExecution() { + return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +} + +function run() { + const args = new Set(process.argv.slice(2)); + const checkOnly = args.has('--check'); + const currentYear = new Date().getFullYear(); + + const stagedFiles = getStagedFiles(); + const stagedFileData = []; + const invalidFiles = []; + const changedFiles = []; + + for (const file of stagedFiles) { + if (!isFile(file) || isExcluded(file)) { + continue; + } + const absolutePath = resolve(process.cwd(), file); + const original = safeReadUtf8(absolutePath); + if (original === null) { + continue; + } + stagedFileData.push({ file, absolutePath, original }); + if (hasInvalidPingCopyrightHeader(original)) { + invalidFiles.push(file); + } + } + + if (invalidFiles.length > 0) { + console.error('Invalid Ping copyright header year format in staged files:'); + for (const file of invalidFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } + + const missingHeaderFiles = []; + for (const { file, original } of stagedFileData) { + if (SOURCE_FILE_PATTERN.test(file) && !hasPingCopyrightHeader(original)) { + missingHeaderFiles.push(file); + } + } + + if (missingHeaderFiles.length > 0) { + console.error('Missing Ping copyright header in staged files:'); + for (const file of missingHeaderFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } + + for (const { file, absolutePath, original } of stagedFileData) { + const updated = updateCopyrightYears(original, currentYear); + if (updated === original) { + continue; + } + changedFiles.push(file); + if (!checkOnly) { + writeFileSync(absolutePath, updated, 'utf8'); + } + } + + if (!checkOnly && changedFiles.length > 0) { + execFileSync('git', ['add', '--', ...changedFiles], { stdio: 'inherit' }); + } + + if (checkOnly && changedFiles.length > 0) { + console.error('Stale Ping copyright years found in staged files:'); + for (const file of changedFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } +} + +function getStagedFiles() { + const output = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { + encoding: 'utf8', + }).trim(); + + if (!output) { + return []; + } + return output.split('\n').filter(Boolean); +} + +export function isExcluded(filePath) { + return EXCLUDE_PATTERNS.some((pattern) => pattern.test(filePath)); +} + +const EXCLUDE_PATTERNS = [ + /\.test\.[cm]?[jt]sx?$/i, + /\.spec\.[cm]?[jt]sx?$/i, + /(^|[/\\])dist[/\\]/, + /(^|[/\\])vendor[/\\]/, + /(^|[/\\])node_modules[/\\]/, + /(^|[/\\])tools[/\\]/, + /(^|[/\\])_polyfills[/\\]/, + /(^|[/\\])vite[^/\\]*\.config\.[cm]?[jt]sx?$/i, + /(^|[/\\])vitest\.setup\.[cm]?[jt]sx?$/i, + /(^|[/\\])playwright\.config\.[cm]?[jt]sx?$/i, +]; + +function isFile(filePath) { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +function safeReadUtf8(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return null; + } +} + +export function updateCopyrightYears(content, year) { + const regex = + /(^.*(?:©\s*|©\s*)?Copyright(?:\s*\(c\))?\s+)(\d{4})(?:([ \t]*-[ \t]*)(\d{4}))?(\s+Ping Identity(?: Corporation)?\b.*$)/gim; + + return content.replace(regex, (_, prefix, startYear, separator, endYear, suffix) => { + const start = Number.parseInt(startYear, 10); + const end = endYear ? Number.parseInt(endYear, 10) : start; + + if (Number.isNaN(start) || Number.isNaN(end)) { + return `${prefix}${startYear}${endYear ? `${separator}${endYear}` : ''}${suffix}`; + } + + const resolvedEnd = end >= year ? end : year; + + if (!endYear) { + // Single year already current — no range needed + if (resolvedEnd === start) { + return `${prefix}${startYear}${suffix}`; + } + return `${prefix}${startYear} - ${resolvedEnd}${suffix}`; + } + + // Always normalize separator to ' - ' and bump end year when stale + return `${prefix}${startYear} - ${resolvedEnd}${suffix}`; + }); +} + +export function hasInvalidPingCopyrightHeader(content) { + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (!MAYBE_PING_COPYRIGHT_LINE_REGEX.test(line)) { + continue; + } + if (!HEADER_COMMENT_LINE_REGEX.test(line)) { + continue; + } + if (!VALID_PING_COPYRIGHT_LINE_REGEX.test(line)) { + return true; + } + } + return false; +} + +export function hasPingCopyrightHeader(content) { + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (MAYBE_PING_COPYRIGHT_LINE_REGEX.test(line) && HEADER_COMMENT_LINE_REGEX.test(line)) { + return true; + } + } + return false; +} + +const SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/i; + +const MAYBE_PING_COPYRIGHT_LINE_REGEX = + /(?:©\s*|©\s*)?Copyright(?:\s*\(c\))?.*Ping Identity(?: Corporation)?/i; +const HEADER_COMMENT_LINE_REGEX = /^\s*(?:\/\*+|\*+|\/\/+|#+|', + ].join('\n'); + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + [ + '/* © Copyright 2020 - 2026 Ping Identity. */', + '', + ].join('\n'), + ); +}); + +test('does not update non-Ping headers', () => { + const input = '/* Copyright 2020-2025 Example Corp. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal(actual, input); +}); + +test('updates Ping Identity Corporation ranges with spaces and (c)', () => { + const input = '/* Copyright (c) 2023 - 2024 Ping Identity Corporation. All right reserved. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */', + ); +}); + +test('expands stale single year with (c) to a range for Ping Identity Corporation', () => { + const input = '/* Copyright (c) 2023 Ping Identity Corporation. All right reserved. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */', + ); +}); + +test('flags Ping headers without a valid year', () => { + const input = '/* Copyright Ping Identity Corporation. All right reserved. */'; + assert.equal(hasInvalidPingCopyrightHeader(input), true); +}); + +test('flags Ping headers with a placeholder', () => { + const input = + '/* Copyright (c) Ping Identity Corporation. All rights reserved. */'; + assert.equal(hasInvalidPingCopyrightHeader(input), true); +}); + +test('does not flag valid Ping headers', () => { + const input = '/* Copyright (c) 2020 - 2026 Ping Identity Corporation. */'; + assert.equal(hasInvalidPingCopyrightHeader(input), false); +}); + +test('does not flag non-header Ping copyright text', () => { + const input = 'This document is Copyright Ping Identity Corporation.'; + assert.equal(hasInvalidPingCopyrightHeader(input), false); +}); + +test('excludes test files from processing', () => { + assert.equal(isExcluded('src/foo.test.ts'), true); + assert.equal(isExcluded('src/foo.test.mjs'), true); + assert.equal(isExcluded('src/foo.spec.js'), true); +}); + +test('excludes dist and vendor paths from processing', () => { + assert.equal(isExcluded('dist/foo.js'), true); + assert.equal(isExcluded('vendor/lib.js'), true); +}); + +test('excludes vite.config and vitest.setup files from processing', () => { + assert.equal(isExcluded('vite.config.ts'), true); + assert.equal(isExcluded('packages/foo/vite.config.ts'), true); + assert.equal(isExcluded('e2e/token-vault-app/vite.interceptor.config.ts'), true); + assert.equal(isExcluded('vitest.setup.ts'), true); + assert.equal(isExcluded('packages/foo/vitest.setup.ts'), true); +}); + +test('excludes playwright.config files from processing', () => { + assert.equal(isExcluded('e2e/autoscript-suites/playwright.config.ts'), true); + assert.equal(isExcluded('e2e/token-vault-suites/playwright.config.ts'), true); +}); + +test('excludes _polyfills/ directory from processing', () => { + assert.equal(isExcluded('e2e/autoscript-apps/src/_polyfills/fast-text-encoder.js'), true); +}); + +test('excludes tools/ directory from processing', () => { + assert.equal(isExcluded('tools/copyright/sync-header-years.mjs'), true); + assert.equal(isExcluded('tools/release/local.mjs'), true); +}); + +test('does not exclude regular source files', () => { + assert.equal(isExcluded('src/foo.ts'), false); + assert.equal(isExcluded('packages/sdk/src/index.ts'), false); +}); + +test('hasPingCopyrightHeader detects valid block comment header', () => { + const input = '/* Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. */'; + assert.equal(hasPingCopyrightHeader(input), true); +}); + +test('hasPingCopyrightHeader detects header in multi-line block comment', () => { + const input = `/* + * @forgerock/javascript-sdk + * + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. + */`; + assert.equal(hasPingCopyrightHeader(input), true); +}); + +test('hasPingCopyrightHeader returns false when no Ping copyright present', () => { + const input = `/* + * Some other library header + */ +export const x = 1;`; + assert.equal(hasPingCopyrightHeader(input), false); +}); + +test('hasPingCopyrightHeader returns false for non-comment Ping copyright text', () => { + const input = 'This document is Copyright Ping Identity Corporation.'; + assert.equal(hasPingCopyrightHeader(input), false); +});