From 44801eda26d0dfa9be6894965a45b1b240b399d6 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 19 Oct 2026 16:39:29 +0100 Subject: [PATCH] Convert security to TypeScript Publish declarations alongside the existing CommonJS entry points so typed consumers can use security helpers without import suppressions. Keep root loading lazy and preserve lib subpaths and token behavior. --- packages/security/.gitignore | 4 + packages/security/README.md | 7 ++ packages/security/index.js | 25 ------ packages/security/index.ts | 25 ++++++ .../lib/{identifier.js => identifier.ts} | 14 ++- .../security/lib/{password.js => password.ts} | 10 +-- .../security/lib/{secret.js => secret.ts} | 10 +-- .../security/lib/{string.js => string.ts} | 6 +- .../security/lib/{tokens.js => tokens.ts} | 86 ++++++++++--------- packages/security/lib/{url.js => url.ts} | 8 +- packages/security/package.json | 20 ++++- packages/security/test/commonjs.test.js | 24 ++++++ packages/security/test/secret.test.js | 5 ++ packages/security/test/tokens.test.js | 12 +++ packages/security/test/types.ts | 40 +++++++++ packages/security/tsconfig.json | 9 ++ packages/security/tsconfig.test.json | 7 ++ packages/security/types/tryghost-string.d.ts | 7 ++ packages/security/vitest.config.ts | 14 +++ pnpm-lock.yaml | 7 ++ 20 files changed, 247 insertions(+), 93 deletions(-) create mode 100644 packages/security/.gitignore delete mode 100644 packages/security/index.js create mode 100644 packages/security/index.ts rename packages/security/lib/{identifier.js => identifier.ts} (67%) rename packages/security/lib/{password.js => password.ts} (56%) rename packages/security/lib/{secret.js => secret.ts} (76%) rename packages/security/lib/{string.js => string.ts} (53%) rename packages/security/lib/{tokens.js => tokens.ts} (56%) rename packages/security/lib/{url.js => url.ts} (76%) create mode 100644 packages/security/test/commonjs.test.js create mode 100644 packages/security/test/types.ts create mode 100644 packages/security/tsconfig.json create mode 100644 packages/security/tsconfig.test.json create mode 100644 packages/security/types/tryghost-string.d.ts create mode 100644 packages/security/vitest.config.ts diff --git a/packages/security/.gitignore b/packages/security/.gitignore new file mode 100644 index 000000000..ec30f97f2 --- /dev/null +++ b/packages/security/.gitignore @@ -0,0 +1,4 @@ +index.js +index.d.ts +lib/*.js +lib/*.d.ts diff --git a/packages/security/README.md b/packages/security/README.md index bfa46df96..b3115d4a2 100644 --- a/packages/security/README.md +++ b/packages/security/README.md @@ -33,6 +33,13 @@ const isMatch = await security.password.compare('super-secret', hash); - `security.string`: safe slug-style string normalization. - `security.identifier`: legacy identifier helper (`uid`, deprecated). +## Development + +Source lives in `index.ts` and `lib/*.ts`. `pnpm build` emits CommonJS JavaScript +and declarations alongside the sources; only compiled files are published. + +Run `pnpm test` for the build, type checks, runtime tests, coverage, and lint. + # Copyright & License Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/packages/security/index.js b/packages/security/index.js deleted file mode 100644 index d244283ca..000000000 --- a/packages/security/index.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - get url() { - return require('./lib/url'); - }, - - get tokens() { - return require('./lib/tokens'); - }, - - get string() { - return require('./lib/string'); - }, - - get identifier() { - return require('./lib/identifier'); - }, - - get password() { - return require('./lib/password'); - }, - - get secret() { - return require('./lib/secret'); - }, -}; diff --git a/packages/security/index.ts b/packages/security/index.ts new file mode 100644 index 000000000..0cafc4809 --- /dev/null +++ b/packages/security/index.ts @@ -0,0 +1,25 @@ +export = { + get url(): typeof import('./lib/url') { + return require('./lib/url'); + }, + + get tokens(): typeof import('./lib/tokens') { + return require('./lib/tokens'); + }, + + get string(): typeof import('./lib/string') { + return require('./lib/string'); + }, + + get identifier(): typeof import('./lib/identifier') { + return require('./lib/identifier'); + }, + + get password(): typeof import('./lib/password') { + return require('./lib/password'); + }, + + get secret(): typeof import('./lib/secret') { + return require('./lib/secret'); + }, +}; diff --git a/packages/security/lib/identifier.js b/packages/security/lib/identifier.ts similarity index 67% rename from packages/security/lib/identifier.js rename to packages/security/lib/identifier.ts index f8dcc9f00..bb9c4a7d0 100644 --- a/packages/security/lib/identifier.js +++ b/packages/security/lib/identifier.ts @@ -1,8 +1,6 @@ -let _private = {}; - -_private.getRandomInt = function (min, max) { +function getRandomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; -}; +} /** * Return a unique identifier with the given `len`. @@ -11,15 +9,15 @@ _private.getRandomInt = function (min, max) { * @param {Number} maxLength * @return {String} */ -module.exports.uid = function uid(maxLength) { - const buf = []; +export function uid(maxLength: number): string { + const buf: string[] = []; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charLength = chars.length; let i; for (i = 0; i < maxLength; i = i + 1) { - buf.push(chars[_private.getRandomInt(0, charLength - 1)]); + buf.push(chars[getRandomInt(0, charLength - 1)]); } return buf.join(''); -}; +} diff --git a/packages/security/lib/password.js b/packages/security/lib/password.ts similarity index 56% rename from packages/security/lib/password.js rename to packages/security/lib/password.ts index 0bf906481..28ba49f7c 100644 --- a/packages/security/lib/password.js +++ b/packages/security/lib/password.ts @@ -1,4 +1,4 @@ -const bcrypt = require('bcryptjs'); +import bcrypt from 'bcryptjs'; let HASH_ROUNDS = 10; @@ -6,11 +6,11 @@ if (process.env.NODE_ENV?.startsWith('testing')) { HASH_ROUNDS = 1; } -module.exports.hash = async function hash(plainPassword) { +export async function hash(plainPassword: string): Promise { const salt = await bcrypt.genSalt(HASH_ROUNDS); return bcrypt.hash(plainPassword, salt); -}; +} -module.exports.compare = function compare(plainPassword, hashedPassword) { +export function compare(plainPassword: string, hashedPassword: string): Promise { return bcrypt.compare(plainPassword, hashedPassword); -}; +} diff --git a/packages/security/lib/secret.js b/packages/security/lib/secret.ts similarity index 76% rename from packages/security/lib/secret.js rename to packages/security/lib/secret.ts index 205e03b93..d591e4188 100644 --- a/packages/security/lib/secret.js +++ b/packages/security/lib/secret.ts @@ -1,4 +1,4 @@ -const crypto = require('crypto'); +import { randomBytes } from 'node:crypto'; /* * Uses birthday problem estimation to calculate chance of collision @@ -21,11 +21,11 @@ const crypto = require('crypto'); * @param {String|Number} [typeOrLength=64] * @returns */ -module.exports.create = (typeOrLength) => { +export function create(typeOrLength?: string | number): string { let bytes; let length; - if (Number.isInteger(typeOrLength)) { + if (typeof typeOrLength === 'number' && Number.isInteger(typeOrLength)) { bytes = Math.ceil(typeOrLength / 2); length = typeOrLength; } else if (typeOrLength === 'content') { @@ -36,5 +36,5 @@ module.exports.create = (typeOrLength) => { length = 64; } - return crypto.randomBytes(bytes).toString('hex').slice(0, length); -}; + return randomBytes(bytes).toString('hex').slice(0, length); +} diff --git a/packages/security/lib/string.js b/packages/security/lib/string.ts similarity index 53% rename from packages/security/lib/string.js rename to packages/security/lib/string.ts index 3dc1865ea..e92d2bc91 100644 --- a/packages/security/lib/string.js +++ b/packages/security/lib/string.ts @@ -1,9 +1,9 @@ -const slugify = require('@tryghost/string').slugify; +import { slugify } from '@tryghost/string'; /** * @param {string} string * @param {{importing?: boolean}} [options] */ -module.exports.safe = function safe(string, options = {}) { +export function safe(string?: string | null, options: { importing?: boolean } = {}): string { return slugify(string, { requiredChangesOnly: options.importing === true }); -}; +} diff --git a/packages/security/lib/tokens.js b/packages/security/lib/tokens.ts similarity index 56% rename from packages/security/lib/tokens.js rename to packages/security/lib/tokens.ts index bff3f3286..8631dc523 100644 --- a/packages/security/lib/tokens.js +++ b/packages/security/lib/tokens.ts @@ -1,10 +1,22 @@ -const crypto = require('crypto'); - -module.exports.generateFromContent = function generateFromContent(options) { - options = options || {}; +import { createHash, type BinaryLike } from 'node:crypto'; + +type ContentTokenOptions = { content: BinaryLike }; +type EmailTokenOptions = { expires: number | string; email: string; secret: string }; +type ResetTokenOptions = { + expires: number | string; + email: string; + dbHash: string; + password: BinaryLike; +}; +type ExtractedToken = { expires: number; email: string }; +type TokenComparison = { + correct: boolean; + reason?: 'invalid_expiry' | 'expired' | 'invalid'; +}; - const hash = crypto.createHash('sha256'); - const content = options.content; +export function generateFromContent(options: ContentTokenOptions): string { + const hash = createHash('sha256'); + const content = options?.content; let text = ''; @@ -12,15 +24,13 @@ module.exports.generateFromContent = function generateFromContent(options) { text += [content, hash.digest('base64')].join('|'); return Buffer.from(text).toString('base64'); -}; - -module.exports.generateFromEmail = function generateFromEmail(options) { - options = options || {}; +} - const hash = crypto.createHash('sha256'); - const expires = options.expires; - const email = options.email; - const secret = options.secret; +export function generateFromEmail(options: EmailTokenOptions): string { + const hash = createHash('sha256'); + const expires = options?.expires; + const email = options?.email; + const secret = options?.secret; let text = ''; @@ -30,17 +40,15 @@ module.exports.generateFromEmail = function generateFromEmail(options) { text += [expires, email, hash.digest('base64')].join('|'); return Buffer.from(text).toString('base64'); -}; - -module.exports.resetToken = { - generateHash: function generateHash(options) { - options = options || {}; - - const hash = crypto.createHash('sha256'); - const expires = options.expires; - const email = options.email; - const dbHash = options.dbHash; - const password = options.password; +} + +export const resetToken = { + generateHash: function generateHash(options: ResetTokenOptions): string { + const hash = createHash('sha256'); + const expires = options?.expires; + const email = options?.email; + const dbHash = options?.dbHash; + const password = options?.password; let text = ''; hash.update(String(expires)); @@ -54,10 +62,8 @@ module.exports.resetToken = { text += [expires, email, hash.digest('base64')].join('|'); return Buffer.from(text).toString('base64'); }, - extract: function extract(options) { - options = options || {}; - - const token = options.token; + extract: function extract(options: { token: string }): ExtractedToken | false { + const token = options?.token; const tokenText = Buffer.from(token, 'base64').toString('ascii'); let parts; let expires; @@ -78,18 +84,20 @@ module.exports.resetToken = { email: email, }; }, - compare: function compare(options) { - options = options || {}; - - const tokenToCompare = options.token; - const parts = exports.resetToken.extract({ token: tokenToCompare }); - const dbHash = options.dbHash; - const password = options.password; + compare: function compare(options: { + token: string; + dbHash: string; + password: BinaryLike; + }): TokenComparison { + const tokenToCompare = options?.token; + const parts = resetToken.extract({ token: tokenToCompare }); + const dbHash = options?.dbHash; + const password = options?.password; let generatedToken; let diff = 0; let i; - if (isNaN(parts.expires)) { + if (!parts || isNaN(parts.expires)) { return { correct: false, reason: 'invalid_expiry', @@ -104,7 +112,7 @@ module.exports.resetToken = { }; } - generatedToken = exports.resetToken.generateHash({ + generatedToken = resetToken.generateHash({ email: parts.email, expires: parts.expires, dbHash: dbHash, @@ -119,7 +127,7 @@ module.exports.resetToken = { diff |= tokenToCompare.charCodeAt(i) ^ generatedToken.charCodeAt(i); } - const result = { + const result: TokenComparison = { correct: diff === 0, }; diff --git a/packages/security/lib/url.js b/packages/security/lib/url.ts similarity index 76% rename from packages/security/lib/url.js rename to packages/security/lib/url.ts index 400403740..be7b7ffed 100644 --- a/packages/security/lib/url.js +++ b/packages/security/lib/url.ts @@ -1,14 +1,14 @@ // The token is encoded URL safe by replacing '+' with '-', '\' with '_' and removing '=' // NOTE: the token is not encoded using valid base64 anymore -module.exports.encodeBase64 = function encodeBase64(base64String) { +export function encodeBase64(base64String: string): string { return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -}; +} // Decode url safe base64 encoding and add padding ('=') -module.exports.decodeBase64 = function decodeBase64(base64String) { +export function decodeBase64(base64String: string): string { base64String = base64String.replace(/-/g, '+').replace(/_/g, '/'); while (base64String.length % 4) { base64String += '='; } return base64String; -}; +} diff --git a/packages/security/package.json b/packages/security/package.json index 7902ab1ec..ed30b0ef5 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -11,19 +11,31 @@ }, "files": [ "index.js", - "lib" + "index.d.ts", + "lib/*.js", + "lib/*.d.ts" ], "main": "index.js", + "types": "index.d.ts", "publishConfig": { "access": "public" }, "scripts": { - "dev": "echo \"Implement me!\"", - "test": "NODE_ENV=testing vitest run --config ../../vitest.config.ts --root . --coverage", - "lint": "oxlint -c ../../.oxlintrc.json ." + "dev": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "prepare": "tsc", + "pretest": "pnpm run build", + "test": "pnpm run test:types && NODE_ENV=testing vitest run --coverage", + "test:types": "tsc -p tsconfig.test.json --noEmit", + "lint": "oxlint -c ../../.oxlintrc.json .", + "posttest": "pnpm run lint" }, "dependencies": { "@tryghost/string": "^0.3.5", "bcryptjs": "^3.0.3" + }, + "devDependencies": { + "@types/node": "24.19.2", + "typescript": "catalog:" } } diff --git a/packages/security/test/commonjs.test.js b/packages/security/test/commonjs.test.js new file mode 100644 index 000000000..4e8459ac1 --- /dev/null +++ b/packages/security/test/commonjs.test.js @@ -0,0 +1,24 @@ +const { execFileSync } = require('node:child_process'); + +describe('Security CommonJS compatibility', function () { + it('keeps lazy root getters and legacy lib entry points', function () { + execFileSync(process.execPath, [ + '-e', + ` + const assert = require('node:assert/strict'); + const path = require('node:path'); + const entry = process.argv[1]; + const security = require(entry); + const names = ['url', 'tokens', 'string', 'identifier', 'password', 'secret']; + assert.deepEqual(Object.keys(security), names); + for (const name of names) { + const file = require.resolve(path.join(path.dirname(entry), 'lib', name)); + assert.equal(require.cache[file], undefined); + assert.equal(typeof Object.getOwnPropertyDescriptor(security, name).get, 'function'); + assert.equal(security[name], require(file)); + } + `, + require.resolve('../'), + ]); + }); +}); diff --git a/packages/security/test/secret.test.js b/packages/security/test/secret.test.js index 1a806582f..592c0be13 100644 --- a/packages/security/test/secret.test.js +++ b/packages/security/test/secret.test.js @@ -30,6 +30,11 @@ describe('Lib: Security - Secret', function () { assert.match(secret, /[0-9a-z]+/); }); + it('uses the default length for numeric strings and non-integer numbers', function () { + assert.equal(security.secret.create('15').length, 64); + assert.equal(security.secret.create(15.5).length, 64); + }); + it('generates a 32 byte secret by default', function () { let secret = security.secret.create(); assert.equal(typeof secret, 'string'); diff --git a/packages/security/test/tokens.test.js b/packages/security/test/tokens.test.js index b7e957337..81b69fd89 100644 --- a/packages/security/test/tokens.test.js +++ b/packages/security/test/tokens.test.js @@ -200,6 +200,18 @@ describe('Utils: tokens', function () { assert.equal(result, false); }); + it('compare rejects invalid token structure with invalid_expiry', function () { + const token = Buffer.from('one|two').toString('base64'); + assert.deepEqual( + security.tokens.resetToken.compare({ + token, + dbHash: 'hash', + password: 'password', + }), + { correct: false, reason: 'invalid_expiry' }, + ); + }); + it('can validate an URI encoded reset token', function () { const expires = Date.now() + 60 * 1000; const email = 'test1@ghost.org'; diff --git a/packages/security/test/types.ts b/packages/security/test/types.ts new file mode 100644 index 000000000..0f582c6ac --- /dev/null +++ b/packages/security/test/types.ts @@ -0,0 +1,40 @@ +import security = require('../'); +import * as namespace from '../'; +import { expectTypeOf } from 'vitest'; + +expectTypeOf(security.url.encodeBase64('a+b/c==')).toEqualTypeOf(); +expectTypeOf(namespace.url.decodeBase64('a-b_c')).toEqualTypeOf(); +expectTypeOf(security.password.hash('password')).toEqualTypeOf>(); +expectTypeOf(security.password.compare('password', 'hash')).toEqualTypeOf>(); +expectTypeOf(security.secret.create()).toEqualTypeOf(); +expectTypeOf(security.secret.create(13)).toEqualTypeOf(); +expectTypeOf(security.identifier.uid(24)).toEqualTypeOf(); +expectTypeOf(security.string.safe()).toEqualTypeOf(); +expectTypeOf(security.string.safe(null)).toEqualTypeOf(); +expectTypeOf( + security.tokens.generateFromContent({ content: Buffer.from('content') }), +).toEqualTypeOf(); +expectTypeOf( + security.tokens.generateFromEmail({ + email: 'test@example.com', + expires: 1700000000000, + secret: 'secret', + }), +).toEqualTypeOf(); + +const extracted = security.tokens.resetToken.extract({ token: 'token' }); +if (extracted !== false) { + expectTypeOf(extracted.email).toEqualTypeOf(); + expectTypeOf(extracted.expires).toEqualTypeOf(); +} +const comparison = security.tokens.resetToken.compare({ + token: 'token', + dbHash: 'hash', + password: 'password', +}); +expectTypeOf(comparison.correct).toEqualTypeOf(); +expectTypeOf(comparison.reason).toEqualTypeOf< + 'invalid_expiry' | 'expired' | 'invalid' | undefined +>(); +expectTypeOf(security.url.encodeBase64).parameter(0).toEqualTypeOf(); +expectTypeOf(security.identifier.uid).parameter(0).toEqualTypeOf(); diff --git a/packages/security/tsconfig.json b/packages/security/tsconfig.json new file mode 100644 index 000000000..ec8f20fbe --- /dev/null +++ b/packages/security/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "sourceMap": false, + "noEmitOnError": true + }, + "include": ["index.ts", "lib/**/*.ts", "types/**/*.d.ts"] +} diff --git a/packages/security/tsconfig.test.json b/packages/security/tsconfig.test.json new file mode 100644 index 000000000..b22782e77 --- /dev/null +++ b/packages/security/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["index.ts", "lib/**/*.ts", "types/**/*.d.ts", "test/**/*.ts"] +} diff --git a/packages/security/types/tryghost-string.d.ts b/packages/security/types/tryghost-string.d.ts new file mode 100644 index 000000000..a03c42fff --- /dev/null +++ b/packages/security/types/tryghost-string.d.ts @@ -0,0 +1,7 @@ +// @tryghost/string does not publish declarations. Only its consumed API is modeled here. +declare module '@tryghost/string' { + export function slugify( + value: string | null | undefined, + options?: { requiredChangesOnly?: boolean }, + ): string; +} diff --git a/packages/security/vitest.config.ts b/packages/security/vitest.config.ts new file mode 100644 index 000000000..10013bd29 --- /dev/null +++ b/packages/security/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import rootConfig from '../../vitest.config'; + +export default mergeConfig( + rootConfig, + defineConfig({ + test: { + coverage: { + include: ['lib/**/*.js'], + exclude: ['**/*.ts', '**/*.d.ts'], + }, + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 173742472..8058485e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -772,6 +772,13 @@ importers: bcryptjs: specifier: ^3.0.3 version: 3.0.3 + devDependencies: + '@types/node': + specifier: 24.19.2 + version: 24.19.2 + typescript: + specifier: 'catalog:' + version: 7.0.2 packages/server: dependencies: