onehitter 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ It generates an OTP, stores a hashed record, emails it to the user, then validat
28
28
  - create(...): persist a hashed OTP document
29
29
  - send(to, otp): email the OTP via SES (customizable template)
30
30
  - validate / validateStatus(...): consume once and return success or a detailed status
31
- - Storage adapter: MongoDB (default) or SQLite (experimental). Choose with `DB_DRIVER`.
31
+ - Storage adapter: MongoDB (default) or SQLite (experimental). Choose with `OTP_DB_DRIVER` (`mongodb` or `sqlite`).
32
32
  - Expiry: checked at validation time; MongoDB users should also create a TTL index on `createdAt` for cleanup.
33
33
  - Rate limiting: bring your own limiter or enable a built-in in-memory limiter via env flags.
34
34
 
@@ -102,7 +102,12 @@ if (status === 'ok') {
102
102
 
103
103
  ## Databases
104
104
  - Default: MongoDB. Your app owns the `MongoClient` (construct, connect/close, pass to `create`/`validate`).
105
- - Optional: SQLite (`DB_DRIVER=sqlite`, optional `SQLITE_PATH`); good for tests/small apps.
105
+ - Optional: SQLite (`OTP_DB_DRIVER=sqlite`, optional `SQLITE_PATH`); good for tests/small apps.
106
+
107
+ ### Database driver env
108
+ - `OTP_DB_DRIVER` (optional): selects the storage driver.
109
+ - `mongodb` (default): uses the MongoDB adapter and only requires the `mongodb` dependency.
110
+ - `sqlite`: uses the built-in SQLite adapter and requires the host app to install `sqlite3` (for example, `npm install sqlite3`). When `OTP_DB_DRIVER=mongodb`, `sqlite3` is not required and is not loaded.
106
111
 
107
112
  Details and tradeoffs: docs/DB.md
108
113
 
@@ -15,7 +15,7 @@ const otpCreate = async (client, otp) => {
15
15
  throw new Error('otpCreate does not accept an _id; it will be generated by MongoDB');
16
16
  }
17
17
  const doc = {
18
- contact: otp.contact,
18
+ contactId: (0, shared_1.computeContactId)(otp.contact),
19
19
  otpHash: (0, shared_1.computeOtpHash)(otp.contact, otp.otp),
20
20
  createdAt: otp.createdAt,
21
21
  };
@@ -35,8 +35,9 @@ const otpValidateWithStatus = async (client, otp, now = new Date(), ttlSeconds)
35
35
  const database = client.db(process.env.OTP_MONGO_DATABASE);
36
36
  const cursor = database.collection(process.env.OTP_MONGO_COLLECTION);
37
37
  const otpHash = (0, shared_1.computeOtpHash)(otp.contact, otp.otp);
38
+ const contactId = (0, shared_1.computeContactId)(otp.contact);
38
39
  // Delete matching doc and retrieve the deleted document for inspection
39
- const res = await cursor.findOneAndDelete({ contact: otp.contact, otpHash });
40
+ const res = await cursor.findOneAndDelete({ contactId, otpHash });
40
41
  const deleted = res && (res.createdAt ? res : (res.value ?? null));
41
42
  if (!deleted)
42
43
  return 'not_found';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.computeOtpHash = exports.SQLITE_PATH = void 0;
3
+ exports.computeContactId = exports.computeOtpHash = exports.SQLITE_PATH = void 0;
4
4
  exports.currentDriver = currentDriver;
5
5
  function currentDriver() {
6
6
  const d = (process.env.OTP_DB_DRIVER);
@@ -26,3 +26,26 @@ const computeOtpHash = (contact, otp, opts) => {
26
26
  return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
27
27
  };
28
28
  exports.computeOtpHash = computeOtpHash;
29
+ /**
30
+ * Deterministic contact identifier used in storage.
31
+ *
32
+ * This function derives a pseudonymous ID from the contact string using the
33
+ * same peppered HMAC/Hash strategy as `computeOtpHash`, but without the OTP
34
+ * component. It allows equality comparison (lookups) without storing the
35
+ * original contact value in the database.
36
+ */
37
+ const computeContactId = (contact, opts) => {
38
+ const pepper = process.env.OTP_PEPPER || '';
39
+ const salt = opts?.salt || '';
40
+ const allowInsecure = process.env.ONEHITTER_ALLOW_INSECURE_HASH === 'true';
41
+ if (process.env.NODE_ENV === 'production' && !pepper && !allowInsecure) {
42
+ throw new Error('Security requirement: OTP_PEPPER must be set in production to HMAC-protect contact identifiers and OTP hashes (override with ONEHITTER_ALLOW_INSECURE_HASH=true for non-prod-like runs)');
43
+ }
44
+ const message = salt ? `${contact}|${salt}` : contact;
45
+ const crypto = require('crypto');
46
+ if (pepper) {
47
+ return crypto.createHmac('sha256', pepper).update(message, 'utf8').digest('hex');
48
+ }
49
+ return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
50
+ };
51
+ exports.computeContactId = computeContactId;
@@ -4,20 +4,38 @@ exports.otpValidateWithStatus = exports.otpCreate = void 0;
4
4
  const shared_1 = require("./shared");
5
5
  let sqlite3;
6
6
  let db = null;
7
+ // Bundler-safe loader for optional sqlite3 dependency.
8
+ // Using eval('require') prevents bundlers from eagerly resolving the sqlite3
9
+ // module when the SQLite driver is not used (e.g. when OTP_DB_DRIVER=mongodb).
10
+ function loadSqlite3() {
11
+ try {
12
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
13
+ const req = eval('require');
14
+ return req('sqlite3');
15
+ }
16
+ catch (err) {
17
+ // Provide a clear error when the SQLite driver is selected but sqlite3
18
+ // is not installed in the host application.
19
+ const message = err && err.code === 'MODULE_NOT_FOUND'
20
+ ? 'The sqlite3 package is not installed. Install it with "npm install sqlite3" to use OTP_DB_DRIVER=sqlite.'
21
+ : `Failed to load sqlite3 driver: ${String(err)}`;
22
+ throw new Error(message);
23
+ }
24
+ }
7
25
  function getDb() {
8
26
  if (db)
9
27
  return db;
10
28
  // Lazy-load sqlite3 only when the SQLite driver is actually used
11
- const s = sqlite3 ?? (sqlite3 = require('sqlite3'));
29
+ const s = sqlite3 ?? (sqlite3 = loadSqlite3());
12
30
  db = new s.Database(shared_1.SQLITE_PATH);
13
31
  db.serialize(() => {
14
32
  db.run('CREATE TABLE IF NOT EXISTS otp (\n' +
15
33
  ' id INTEGER PRIMARY KEY AUTOINCREMENT,\n' +
16
- ' contact TEXT NOT NULL,\n' +
34
+ ' contactId TEXT NOT NULL,\n' +
17
35
  ' otpHash TEXT NOT NULL,\n' +
18
36
  ' createdAt INTEGER NOT NULL\n' +
19
37
  ')');
20
- db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(contact, otpHash)');
38
+ db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(contactId, otpHash)');
21
39
  db.run('CREATE INDEX IF NOT EXISTS idx_otp_createdAt ON otp(createdAt)');
22
40
  });
23
41
  return db;
@@ -26,8 +44,9 @@ const otpCreate = async (otp) => {
26
44
  const database = getDb();
27
45
  const createdAt = otp.createdAt ? otp.createdAt.getTime() : Date.now();
28
46
  const otpHash = (0, shared_1.computeOtpHash)(otp.contact, otp.otp);
47
+ const contactId = (0, shared_1.computeContactId)(otp.contact);
29
48
  return await new Promise((resolve, reject) => {
30
- database.run('INSERT INTO otp (contact, otpHash, createdAt) VALUES (?, ?, ?)', [otp.contact, otpHash, createdAt], function (err) {
49
+ database.run('INSERT INTO otp (contactId, otpHash, createdAt) VALUES (?, ?, ?)', [contactId, otpHash, createdAt], function (err) {
31
50
  if (err)
32
51
  return reject(err);
33
52
  // Shape it like a Mongo InsertOneResult enough for callers
@@ -39,10 +58,11 @@ exports.otpCreate = otpCreate;
39
58
  const otpValidateWithStatus = async (otp, now = new Date(), ttlSeconds) => {
40
59
  const database = getDb();
41
60
  const otpHash = (0, shared_1.computeOtpHash)(otp.contact, otp.otp);
61
+ const contactId = (0, shared_1.computeContactId)(otp.contact);
42
62
  return await new Promise((resolve, reject) => {
43
63
  // Single-statement atomicity: select the newest id, then conditionally delete it.
44
64
  // We avoid explicit BEGIN/COMMIT to prevent nested transaction errors under concurrency.
45
- database.get('SELECT id, createdAt FROM otp WHERE contact = ? AND otpHash = ? ORDER BY id DESC LIMIT 1', [otp.contact, otpHash], function (err, row) {
65
+ database.get('SELECT id, createdAt FROM otp WHERE contactId = ? AND otpHash = ? ORDER BY id DESC LIMIT 1', [contactId, otpHash], function (err, row) {
46
66
  if (err)
47
67
  return reject(err);
48
68
  if (!row)
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createSesV2Transport = createSesV2Transport;
6
7
  const nodemailer_1 = __importDefault(require("nodemailer"));
7
8
  const config_1 = require("./config");
8
9
  /**
@@ -113,20 +114,16 @@ Once used, this one-time password can not be used again. That's why it's called
113
114
  * @throws {Error} If the recipient (`to`) or the sender (`from`) address is missing.
114
115
  */
115
116
  function createSesTransport(region) {
116
- // Prefer Nodemailer v7 SESv2 client when available; fall back to legacy SES config
117
+ // Force SESv2 path only; do not use legacy SES
117
118
  try {
118
119
  // eslint-disable-next-line @typescript-eslint/no-var-requires
119
120
  const sesv2 = require('@aws-sdk/client-sesv2');
120
121
  const client = new sesv2.SESv2Client({ region });
121
- // Nodemailer v7 expects SESv2 via options.SES with { sesClient, SendEmailCommand }
122
122
  const opts = { SES: { sesClient: client, SendEmailCommand: sesv2.SendEmailCommand } };
123
123
  return nodemailer_1.default.createTransport(opts);
124
124
  }
125
- catch {
126
- // eslint-disable-next-line @typescript-eslint/no-var-requires
127
- const aws = require('@aws-sdk/client-ses');
128
- const legacy = new aws.SES({ apiVersion: '2010-12-01', region });
129
- return nodemailer_1.default.createTransport({ SES: { ses: legacy, aws } });
125
+ catch (err) {
126
+ throw new Error('SESv2 client not available. Install @aws-sdk/client-sesv2 to use OneHitter sender, or pass a Nodemailer transporter explicitly.');
130
127
  }
131
128
  }
132
129
  async function send(to, otp, url, expiry, message, opts) {
@@ -147,4 +144,7 @@ async function send(to, otp, url, expiry, message, opts) {
147
144
  ...(text ? { text } : {}),
148
145
  });
149
146
  }
147
+ function createSesV2Transport(region) {
148
+ return createSesTransport(region);
149
+ }
150
150
  exports.default = send;
@@ -1,4 +1,4 @@
1
- import { computeOtpHash } from './shared';
1
+ import { computeOtpHash, computeContactId } from './shared';
2
2
  export const otpCreate = async (client, otp) => {
3
3
  if (!process.env.OTP_MONGO_DATABASE || !process.env.OTP_MONGO_COLLECTION) {
4
4
  throw new Error('Missing OTP_MONGO_DATABASE or OTP_MONGO_COLLECTION');
@@ -12,7 +12,7 @@ export const otpCreate = async (client, otp) => {
12
12
  throw new Error('otpCreate does not accept an _id; it will be generated by MongoDB');
13
13
  }
14
14
  const doc = {
15
- contact: otp.contact,
15
+ contactId: computeContactId(otp.contact),
16
16
  otpHash: computeOtpHash(otp.contact, otp.otp),
17
17
  createdAt: otp.createdAt,
18
18
  };
@@ -31,8 +31,9 @@ export const otpValidateWithStatus = async (client, otp, now = new Date(), ttlSe
31
31
  const database = client.db(process.env.OTP_MONGO_DATABASE);
32
32
  const cursor = database.collection(process.env.OTP_MONGO_COLLECTION);
33
33
  const otpHash = computeOtpHash(otp.contact, otp.otp);
34
+ const contactId = computeContactId(otp.contact);
34
35
  // Delete matching doc and retrieve the deleted document for inspection
35
- const res = await cursor.findOneAndDelete({ contact: otp.contact, otpHash });
36
+ const res = await cursor.findOneAndDelete({ contactId, otpHash });
36
37
  const deleted = res && (res.createdAt ? res : (res.value ?? null));
37
38
  if (!deleted)
38
39
  return 'not_found';
@@ -21,3 +21,25 @@ export const computeOtpHash = (contact, otp, opts) => {
21
21
  const crypto = require('crypto');
22
22
  return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
23
23
  };
24
+ /**
25
+ * Deterministic contact identifier used in storage.
26
+ *
27
+ * This function derives a pseudonymous ID from the contact string using the
28
+ * same peppered HMAC/Hash strategy as `computeOtpHash`, but without the OTP
29
+ * component. It allows equality comparison (lookups) without storing the
30
+ * original contact value in the database.
31
+ */
32
+ export const computeContactId = (contact, opts) => {
33
+ const pepper = process.env.OTP_PEPPER || '';
34
+ const salt = opts?.salt || '';
35
+ const allowInsecure = process.env.ONEHITTER_ALLOW_INSECURE_HASH === 'true';
36
+ if (process.env.NODE_ENV === 'production' && !pepper && !allowInsecure) {
37
+ throw new Error('Security requirement: OTP_PEPPER must be set in production to HMAC-protect contact identifiers and OTP hashes (override with ONEHITTER_ALLOW_INSECURE_HASH=true for non-prod-like runs)');
38
+ }
39
+ const message = salt ? `${contact}|${salt}` : contact;
40
+ const crypto = require('crypto');
41
+ if (pepper) {
42
+ return crypto.createHmac('sha256', pepper).update(message, 'utf8').digest('hex');
43
+ }
44
+ return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
45
+ };
@@ -1,20 +1,38 @@
1
- import { SQLITE_PATH, computeOtpHash } from './shared';
1
+ import { SQLITE_PATH, computeOtpHash, computeContactId } from './shared';
2
2
  let sqlite3;
3
3
  let db = null;
4
+ // Bundler-safe loader for optional sqlite3 dependency.
5
+ // Using eval('require') prevents bundlers from eagerly resolving the sqlite3
6
+ // module when the SQLite driver is not used (e.g. when OTP_DB_DRIVER=mongodb).
7
+ function loadSqlite3() {
8
+ try {
9
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
10
+ const req = eval('require');
11
+ return req('sqlite3');
12
+ }
13
+ catch (err) {
14
+ // Provide a clear error when the SQLite driver is selected but sqlite3
15
+ // is not installed in the host application.
16
+ const message = err && err.code === 'MODULE_NOT_FOUND'
17
+ ? 'The sqlite3 package is not installed. Install it with "npm install sqlite3" to use OTP_DB_DRIVER=sqlite.'
18
+ : `Failed to load sqlite3 driver: ${String(err)}`;
19
+ throw new Error(message);
20
+ }
21
+ }
4
22
  function getDb() {
5
23
  if (db)
6
24
  return db;
7
25
  // Lazy-load sqlite3 only when the SQLite driver is actually used
8
- const s = sqlite3 ?? (sqlite3 = require('sqlite3'));
26
+ const s = sqlite3 ?? (sqlite3 = loadSqlite3());
9
27
  db = new s.Database(SQLITE_PATH);
10
28
  db.serialize(() => {
11
29
  db.run('CREATE TABLE IF NOT EXISTS otp (\n' +
12
30
  ' id INTEGER PRIMARY KEY AUTOINCREMENT,\n' +
13
- ' contact TEXT NOT NULL,\n' +
31
+ ' contactId TEXT NOT NULL,\n' +
14
32
  ' otpHash TEXT NOT NULL,\n' +
15
33
  ' createdAt INTEGER NOT NULL\n' +
16
34
  ')');
17
- db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(contact, otpHash)');
35
+ db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(contactId, otpHash)');
18
36
  db.run('CREATE INDEX IF NOT EXISTS idx_otp_createdAt ON otp(createdAt)');
19
37
  });
20
38
  return db;
@@ -23,8 +41,9 @@ export const otpCreate = async (otp) => {
23
41
  const database = getDb();
24
42
  const createdAt = otp.createdAt ? otp.createdAt.getTime() : Date.now();
25
43
  const otpHash = computeOtpHash(otp.contact, otp.otp);
44
+ const contactId = computeContactId(otp.contact);
26
45
  return await new Promise((resolve, reject) => {
27
- database.run('INSERT INTO otp (contact, otpHash, createdAt) VALUES (?, ?, ?)', [otp.contact, otpHash, createdAt], function (err) {
46
+ database.run('INSERT INTO otp (contactId, otpHash, createdAt) VALUES (?, ?, ?)', [contactId, otpHash, createdAt], function (err) {
28
47
  if (err)
29
48
  return reject(err);
30
49
  // Shape it like a Mongo InsertOneResult enough for callers
@@ -35,10 +54,11 @@ export const otpCreate = async (otp) => {
35
54
  export const otpValidateWithStatus = async (otp, now = new Date(), ttlSeconds) => {
36
55
  const database = getDb();
37
56
  const otpHash = computeOtpHash(otp.contact, otp.otp);
57
+ const contactId = computeContactId(otp.contact);
38
58
  return await new Promise((resolve, reject) => {
39
59
  // Single-statement atomicity: select the newest id, then conditionally delete it.
40
60
  // We avoid explicit BEGIN/COMMIT to prevent nested transaction errors under concurrency.
41
- database.get('SELECT id, createdAt FROM otp WHERE contact = ? AND otpHash = ? ORDER BY id DESC LIMIT 1', [otp.contact, otpHash], function (err, row) {
61
+ database.get('SELECT id, createdAt FROM otp WHERE contactId = ? AND otpHash = ? ORDER BY id DESC LIMIT 1', [contactId, otpHash], function (err, row) {
42
62
  if (err)
43
63
  return reject(err);
44
64
  if (!row)
@@ -108,20 +108,16 @@ Once used, this one-time password can not be used again. That's why it's called
108
108
  * @throws {Error} If the recipient (`to`) or the sender (`from`) address is missing.
109
109
  */
110
110
  function createSesTransport(region) {
111
- // Prefer Nodemailer v7 SESv2 client when available; fall back to legacy SES config
111
+ // Force SESv2 path only; do not use legacy SES
112
112
  try {
113
113
  // eslint-disable-next-line @typescript-eslint/no-var-requires
114
114
  const sesv2 = require('@aws-sdk/client-sesv2');
115
115
  const client = new sesv2.SESv2Client({ region });
116
- // Nodemailer v7 expects SESv2 via options.SES with { sesClient, SendEmailCommand }
117
116
  const opts = { SES: { sesClient: client, SendEmailCommand: sesv2.SendEmailCommand } };
118
117
  return nodemailer.createTransport(opts);
119
118
  }
120
- catch {
121
- // eslint-disable-next-line @typescript-eslint/no-var-requires
122
- const aws = require('@aws-sdk/client-ses');
123
- const legacy = new aws.SES({ apiVersion: '2010-12-01', region });
124
- return nodemailer.createTransport({ SES: { ses: legacy, aws } });
119
+ catch (err) {
120
+ throw new Error('SESv2 client not available. Install @aws-sdk/client-sesv2 to use OneHitter sender, or pass a Nodemailer transporter explicitly.');
125
121
  }
126
122
  }
127
123
  async function send(to, otp, url, expiry, message, opts) {
@@ -142,4 +138,7 @@ async function send(to, otp, url, expiry, message, opts) {
142
138
  ...(text ? { text } : {}),
143
139
  });
144
140
  }
141
+ export function createSesV2Transport(region) {
142
+ return createSesTransport(region);
143
+ }
145
144
  export default send;
@@ -1,7 +1,7 @@
1
1
  import type { MongoClient, InsertOneResult, ObjectId } from 'mongodb';
2
2
  import { type ValidateStatus, type OtpDoc } from './shared';
3
3
  interface StoredOtpDoc {
4
- contact: string;
4
+ contactId: string;
5
5
  otpHash: string;
6
6
  createdAt: Date;
7
7
  _id?: ObjectId;
@@ -21,3 +21,14 @@ export interface DbAdapter {
21
21
  export declare const computeOtpHash: (contact: string, otp: string, opts?: {
22
22
  salt?: string;
23
23
  }) => string;
24
+ /**
25
+ * Deterministic contact identifier used in storage.
26
+ *
27
+ * This function derives a pseudonymous ID from the contact string using the
28
+ * same peppered HMAC/Hash strategy as `computeOtpHash`, but without the OTP
29
+ * component. It allows equality comparison (lookups) without storing the
30
+ * original contact value in the database.
31
+ */
32
+ export declare const computeContactId: (contact: string, opts?: {
33
+ salt?: string;
34
+ }) => string;
@@ -24,4 +24,5 @@ export interface MessageConfig {
24
24
  template?: MessageTemplate;
25
25
  }
26
26
  declare function send(to: string, otp: string, url: string, expiry?: number | string, message?: MessageConfig | MessageTemplate, opts?: SendOptions): Promise<void>;
27
+ export declare function createSesV2Transport(region: string): nodemailer.Transporter;
27
28
  export default send;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onehitter",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "One-time password user validation package.",
5
5
  "main": "dist/cjs/onehitter.js",
6
6
  "module": "dist/esm/onehitter.js",