@classytic/primitives 0.9.1 → 0.11.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.11.0] - 2026-07-08
7
+
8
+ ### Added — eKYC vocabulary + secure OTP primitive
9
+
10
+ - **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
11
+ `IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
12
+ `IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
13
+ `VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
14
+ `VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
15
+ `checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
16
+ here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
17
+ (IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
18
+ - **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
19
+ never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
20
+ comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
21
+ counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
22
+ `verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
23
+ `@classytic/notifications` — this primitive owns only the verify half.
24
+
6
25
  ## [0.9.1] - 2026-07-04
7
26
 
8
27
  ### Added
@@ -0,0 +1,113 @@
1
+ import { PersonName } from "./person.mjs";
2
+
3
+ //#region src/identity/identity-verification.d.ts
4
+ /**
5
+ * Government / institutional identity documents. `trade_license` and
6
+ * `tin_certificate` support MERCHANT (business) onboarding — a payment gateway
7
+ * KYBs the business, not just a person.
8
+ */
9
+ type IdentityDocumentType = 'nid' | 'passport' | 'driving_license' | 'birth_certificate' | 'residence_permit' | 'trade_license' | 'tin_certificate' | 'other';
10
+ /** A single verification check a session can run. Providers advertise support. */
11
+ type VerificationCheck = 'document' | 'liveness' | 'face_match' | 'database' | 'address' | 'sanctions' | 'pep' | 'adverse_media';
12
+ /**
13
+ * Assurance tier — mirrors the market (Stripe Identity / Persona levels).
14
+ * Higher tiers imply a superset of checks; the exact mapping is host policy.
15
+ */
16
+ type VerificationLevel = 'basic' | 'standard' | 'enhanced';
17
+ /** The kind of subject under verification — a natural person or a business. */
18
+ type SubjectKind = 'individual' | 'business';
19
+ interface DocumentReference {
20
+ readonly type: IdentityDocumentType;
21
+ /** Document number (NID/passport no.). May be redacted/tokenized at rest. */
22
+ readonly number?: string;
23
+ /** ISO 3166-1 alpha-2 issuing country. */
24
+ readonly country?: string;
25
+ /** ISO date (YYYY-MM-DD). */
26
+ readonly expiresOn?: string;
27
+ }
28
+ interface IdentitySubject {
29
+ readonly kind: SubjectKind;
30
+ readonly name?: PersonName;
31
+ /** Legal/business name when `kind === 'business'`. */
32
+ readonly legalName?: string;
33
+ /** ISO date (YYYY-MM-DD). */
34
+ readonly dateOfBirth?: string;
35
+ /** ISO 3166-1 alpha-2. */
36
+ readonly country?: string;
37
+ }
38
+ interface IdentityProviderCapabilities {
39
+ readonly checks: readonly VerificationCheck[];
40
+ readonly documents: readonly IdentityDocumentType[];
41
+ /** ISO 3166-1 alpha-2 codes; empty ⇒ global. */
42
+ readonly countries: readonly string[];
43
+ /** Delivers async results via webhook (vs poll-only). */
44
+ readonly webhooks: boolean;
45
+ /** Supports business/KYB flows, not just individuals. */
46
+ readonly business: boolean;
47
+ }
48
+ type VerificationStatus = 'pending' | 'processing' | 'verified' | 'rejected' | 'review' | 'expired';
49
+ type CheckOutcome = 'pass' | 'fail' | 'manual' | 'skipped';
50
+ interface CheckResult {
51
+ readonly check: VerificationCheck;
52
+ readonly outcome: CheckOutcome;
53
+ /** Provider-specific reason/code for a fail/manual outcome. */
54
+ readonly reason?: string;
55
+ }
56
+ interface CreateVerificationParams {
57
+ readonly subject: IdentitySubject;
58
+ readonly level: VerificationLevel;
59
+ /** Checks the host requires to consider the subject verified. */
60
+ readonly requiredChecks?: readonly VerificationCheck[];
61
+ readonly documents?: readonly DocumentReference[];
62
+ /** Where the provider redirects the subject after a hosted flow. */
63
+ readonly redirectUrl?: string;
64
+ /** Correlates the session back to a host entity (payee id, signer id, …). */
65
+ readonly reference?: string;
66
+ readonly metadata?: Readonly<Record<string, string>>;
67
+ }
68
+ interface VerificationSession {
69
+ /** Provider-side session id — the handle a host stores and polls. */
70
+ readonly id: string;
71
+ readonly provider: string;
72
+ readonly level: VerificationLevel;
73
+ readonly status: VerificationStatus;
74
+ readonly checks: readonly CheckResult[];
75
+ /** Hosted-flow URL for the subject, when the provider uses one. */
76
+ readonly url?: string;
77
+ readonly reference?: string;
78
+ /** ISO timestamp the verification was decided. */
79
+ readonly decidedAt?: string;
80
+ /**
81
+ * ISO timestamp the verification lapses and re-KYC is required. Periodic
82
+ * re-KYC is an AML requirement; consumers gate on this (payee pauses
83
+ * payability, esign re-verifies before signing).
84
+ */
85
+ readonly expiresAt?: string;
86
+ /** Raw provider payload for audit/debugging. */
87
+ readonly raw?: unknown;
88
+ }
89
+ /** Normalized provider webhook — the async-decision callback shape. */
90
+ interface VerificationWebhookEvent {
91
+ readonly sessionId: string;
92
+ readonly provider: string;
93
+ readonly status: VerificationStatus;
94
+ readonly checks?: readonly CheckResult[];
95
+ readonly occurredAt: string;
96
+ readonly raw?: unknown;
97
+ }
98
+ /** A session is usable when it passed and has not lapsed at `now`. */
99
+ declare function isVerified(session: VerificationSession, now?: Date): boolean;
100
+ /**
101
+ * Do the check RESULTS satisfy the REQUIRED checks? Every required check must
102
+ * have a `pass`. Missing or non-pass required checks ⇒ false. Extra results
103
+ * are ignored.
104
+ */
105
+ declare function checksSatisfy(required: readonly VerificationCheck[], results: readonly CheckResult[]): boolean;
106
+ /**
107
+ * Derive a session status from check results alone (for providers that return
108
+ * raw checks without a verdict): any `fail` ⇒ rejected; any `manual` ⇒ review;
109
+ * all required present and `pass` ⇒ verified; otherwise processing.
110
+ */
111
+ declare function deriveStatus(results: readonly CheckResult[], required?: readonly VerificationCheck[]): VerificationStatus;
112
+ //#endregion
113
+ export { CheckOutcome, CheckResult, CreateVerificationParams, DocumentReference, IdentityDocumentType, IdentityProviderCapabilities, IdentitySubject, SubjectKind, VerificationCheck, VerificationLevel, VerificationSession, VerificationStatus, VerificationWebhookEvent, checksSatisfy, deriveStatus, isVerified };
@@ -0,0 +1,30 @@
1
+ //#region src/identity/identity-verification.ts
2
+ /** A session is usable when it passed and has not lapsed at `now`. */
3
+ function isVerified(session, now) {
4
+ if (session.status !== "verified") return false;
5
+ if (!session.expiresAt) return true;
6
+ const at = (now ?? /* @__PURE__ */ new Date()).toISOString();
7
+ return session.expiresAt > at;
8
+ }
9
+ /**
10
+ * Do the check RESULTS satisfy the REQUIRED checks? Every required check must
11
+ * have a `pass`. Missing or non-pass required checks ⇒ false. Extra results
12
+ * are ignored.
13
+ */
14
+ function checksSatisfy(required, results) {
15
+ const passed = new Set(results.filter((r) => r.outcome === "pass").map((r) => r.check));
16
+ return required.every((c) => passed.has(c));
17
+ }
18
+ /**
19
+ * Derive a session status from check results alone (for providers that return
20
+ * raw checks without a verdict): any `fail` ⇒ rejected; any `manual` ⇒ review;
21
+ * all required present and `pass` ⇒ verified; otherwise processing.
22
+ */
23
+ function deriveStatus(results, required = []) {
24
+ if (results.some((r) => r.outcome === "fail")) return "rejected";
25
+ if (results.some((r) => r.outcome === "manual")) return "review";
26
+ if (required.length > 0 && checksSatisfy(required, results)) return "verified";
27
+ return "processing";
28
+ }
29
+ //#endregion
30
+ export { checksSatisfy, deriveStatus, isVerified };
package/dist/otp.d.mts ADDED
@@ -0,0 +1,96 @@
1
+ //#region src/identity/otp.d.ts
2
+ /**
3
+ * OTP challenge — the pure, secure *verify* half of one-time-code verification.
4
+ *
5
+ * This primitive owns SECURITY, never delivery. Delivery (SMS / email / push /
6
+ * WhatsApp, templates, retry, fallback) is `@classytic/notifications`; the host
7
+ * passes the returned `code` to whichever channel/provider it likes. This file
8
+ * only guarantees the code cannot be forged, replayed, brute-forced, or leaked:
9
+ *
10
+ * - the plaintext code is NEVER persisted — only its HMAC-SHA256 digest is
11
+ * (a stolen challenge row cannot reveal the code);
12
+ * - comparison is constant-time (`timingSafeEqual`) — no timing oracle;
13
+ * - the code is drawn from a CSPRNG with rejection-free `randomInt` — no
14
+ * modulo bias;
15
+ * - a bounded attempt counter locks the challenge — no online brute force;
16
+ * - a TTL expires the challenge — no indefinite window;
17
+ * - a verified challenge is single-use — no replay.
18
+ *
19
+ * The shape is a plain value object: create it, persist `challenge`, and on each
20
+ * attempt feed the stored challenge back through `verifyOtpChallenge` and persist
21
+ * the returned next state. No I/O, no clock capture beyond an injectable `now`.
22
+ *
23
+ * Resend throttling (don't mint a new code too soon) is a host / arc-layer
24
+ * concern — rate-limit calls to `createOtpChallenge`; it is deliberately not
25
+ * modeled here so the verify primitive stays stateless between attempts.
26
+ */
27
+ /** Terminal + in-flight states a persisted challenge can hold. */
28
+ type OtpStatus = 'pending' | 'verified' | 'failed' | 'expired';
29
+ /** The result of a single `verifyOtpChallenge` call. */
30
+ type OtpOutcome = 'verified' | 'incorrect' | 'expired' | 'locked' | 'already_used';
31
+ /**
32
+ * The persistable challenge. Contains no secret material: `hash` is an HMAC of
33
+ * the code under a server-side `secret`, so the row is useless to a thief who
34
+ * lacks the secret. Persist this verbatim; never add the plaintext code to it.
35
+ */
36
+ interface OtpChallenge {
37
+ /** HMAC-SHA256(code, secret) as hex. The plaintext code is never stored. */
38
+ readonly hash: string;
39
+ /** ISO-8601 instant after which the challenge is dead. */
40
+ readonly expiresAt: string;
41
+ /** Failed attempts so far. */
42
+ readonly attempts: number;
43
+ /** Attempts allowed before the challenge locks (`failed`). */
44
+ readonly maxAttempts: number;
45
+ /** Lifecycle state. */
46
+ readonly status: OtpStatus;
47
+ }
48
+ interface CreateOtpOptions {
49
+ /** Server-side HMAC key. Required — a challenge with no secret is forgeable. */
50
+ readonly secret: string;
51
+ /** Number of digits in the code. Default 6. */
52
+ readonly length?: number;
53
+ /** Time-to-live in ms. Default 5 minutes. */
54
+ readonly ttlMs?: number;
55
+ /** Failed attempts before lock. Default 5. */
56
+ readonly maxAttempts?: number;
57
+ /** Injectable clock for deterministic tests. Default `new Date()`. */
58
+ readonly now?: Date;
59
+ }
60
+ interface CreatedOtp {
61
+ /** Persist this. Contains only the HMAC digest, never the code. */
62
+ readonly challenge: OtpChallenge;
63
+ /** Deliver this via `@classytic/notifications`. NEVER persist it. */
64
+ readonly code: string;
65
+ }
66
+ interface VerifyOtpOptions {
67
+ /** The same server-side HMAC key used at creation. */
68
+ readonly secret: string;
69
+ /** Injectable clock for deterministic tests. Default `new Date()`. */
70
+ readonly now?: Date;
71
+ }
72
+ interface OtpVerification {
73
+ /** The next challenge state to persist (attempt count / status advanced). */
74
+ readonly challenge: OtpChallenge;
75
+ /** What happened on this attempt. */
76
+ readonly outcome: OtpOutcome;
77
+ }
78
+ /**
79
+ * Mint a challenge + its one-time code. Persist `challenge`; hand `code` to the
80
+ * delivery layer. The code exists only in this return value and in the delivered
81
+ * message — it is never recoverable from the persisted challenge.
82
+ */
83
+ declare function createOtpChallenge(options: CreateOtpOptions): CreatedOtp;
84
+ /**
85
+ * Check `input` against a persisted challenge and return the next state. This is
86
+ * a pure transition — the caller persists `result.challenge`. Fail-closed: any
87
+ * non-`pending` challenge, an expired window, or an over-limit attempt yields a
88
+ * non-`verified` outcome and never re-opens a settled challenge.
89
+ */
90
+ declare function verifyOtpChallenge(challenge: OtpChallenge, input: string, options: VerifyOtpOptions): OtpVerification;
91
+ /** True once a challenge has been successfully verified. */
92
+ declare function isOtpVerified(challenge: OtpChallenge): boolean;
93
+ /** True while a challenge can still accept an attempt (pending + unexpired). */
94
+ declare function isOtpActive(challenge: OtpChallenge, now?: Date): boolean;
95
+ //#endregion
96
+ export { CreateOtpOptions, CreatedOtp, OtpChallenge, OtpOutcome, OtpStatus, OtpVerification, VerifyOtpOptions, createOtpChallenge, isOtpActive, isOtpVerified, verifyOtpChallenge };
package/dist/otp.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
2
+ //#region src/identity/otp.ts
3
+ /**
4
+ * OTP challenge — the pure, secure *verify* half of one-time-code verification.
5
+ *
6
+ * This primitive owns SECURITY, never delivery. Delivery (SMS / email / push /
7
+ * WhatsApp, templates, retry, fallback) is `@classytic/notifications`; the host
8
+ * passes the returned `code` to whichever channel/provider it likes. This file
9
+ * only guarantees the code cannot be forged, replayed, brute-forced, or leaked:
10
+ *
11
+ * - the plaintext code is NEVER persisted — only its HMAC-SHA256 digest is
12
+ * (a stolen challenge row cannot reveal the code);
13
+ * - comparison is constant-time (`timingSafeEqual`) — no timing oracle;
14
+ * - the code is drawn from a CSPRNG with rejection-free `randomInt` — no
15
+ * modulo bias;
16
+ * - a bounded attempt counter locks the challenge — no online brute force;
17
+ * - a TTL expires the challenge — no indefinite window;
18
+ * - a verified challenge is single-use — no replay.
19
+ *
20
+ * The shape is a plain value object: create it, persist `challenge`, and on each
21
+ * attempt feed the stored challenge back through `verifyOtpChallenge` and persist
22
+ * the returned next state. No I/O, no clock capture beyond an injectable `now`.
23
+ *
24
+ * Resend throttling (don't mint a new code too soon) is a host / arc-layer
25
+ * concern — rate-limit calls to `createOtpChallenge`; it is deliberately not
26
+ * modeled here so the verify primitive stays stateless between attempts.
27
+ */
28
+ const DEFAULT_LENGTH = 6;
29
+ const DEFAULT_TTL_MS = 300 * 1e3;
30
+ const DEFAULT_MAX_ATTEMPTS = 5;
31
+ function hmac(code, secret) {
32
+ return createHmac("sha256", secret).update(code).digest("hex");
33
+ }
34
+ /** CSPRNG numeric code with no modulo bias (`randomInt` rejection-samples). */
35
+ function generateCode(length) {
36
+ let code = "";
37
+ for (let i = 0; i < length; i += 1) code += randomInt(0, 10).toString();
38
+ return code;
39
+ }
40
+ /** Constant-time compare of two equal-length hex digests. */
41
+ function constantTimeEqualHex(a, b) {
42
+ const ab = Buffer.from(a, "hex");
43
+ const bb = Buffer.from(b, "hex");
44
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
45
+ }
46
+ /**
47
+ * Mint a challenge + its one-time code. Persist `challenge`; hand `code` to the
48
+ * delivery layer. The code exists only in this return value and in the delivered
49
+ * message — it is never recoverable from the persisted challenge.
50
+ */
51
+ function createOtpChallenge(options) {
52
+ if (!options.secret) throw new Error("[otp] a `secret` is required to HMAC the code.");
53
+ const length = options.length ?? DEFAULT_LENGTH;
54
+ if (length < 4) throw new Error("[otp] `length` must be at least 4 digits.");
55
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
56
+ if (ttlMs <= 0) throw new Error("[otp] `ttlMs` must be positive.");
57
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
58
+ if (maxAttempts < 1) throw new Error("[otp] `maxAttempts` must be at least 1.");
59
+ const now = options.now ?? /* @__PURE__ */ new Date();
60
+ const code = generateCode(length);
61
+ return {
62
+ code,
63
+ challenge: {
64
+ hash: hmac(code, options.secret),
65
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
66
+ attempts: 0,
67
+ maxAttempts,
68
+ status: "pending"
69
+ }
70
+ };
71
+ }
72
+ /**
73
+ * Check `input` against a persisted challenge and return the next state. This is
74
+ * a pure transition — the caller persists `result.challenge`. Fail-closed: any
75
+ * non-`pending` challenge, an expired window, or an over-limit attempt yields a
76
+ * non-`verified` outcome and never re-opens a settled challenge.
77
+ */
78
+ function verifyOtpChallenge(challenge, input, options) {
79
+ if (!options.secret) throw new Error("[otp] a `secret` is required to verify the code.");
80
+ if (challenge.status === "verified") return {
81
+ challenge,
82
+ outcome: "already_used"
83
+ };
84
+ if (challenge.status === "failed") return {
85
+ challenge,
86
+ outcome: "locked"
87
+ };
88
+ if (challenge.status === "expired") return {
89
+ challenge,
90
+ outcome: "expired"
91
+ };
92
+ if ((options.now ?? /* @__PURE__ */ new Date()).getTime() > Date.parse(challenge.expiresAt)) return {
93
+ challenge: {
94
+ ...challenge,
95
+ status: "expired"
96
+ },
97
+ outcome: "expired"
98
+ };
99
+ if (constantTimeEqualHex(hmac(input, options.secret), challenge.hash)) return {
100
+ challenge: {
101
+ ...challenge,
102
+ status: "verified"
103
+ },
104
+ outcome: "verified"
105
+ };
106
+ const attempts = challenge.attempts + 1;
107
+ if (attempts >= challenge.maxAttempts) return {
108
+ challenge: {
109
+ ...challenge,
110
+ attempts,
111
+ status: "failed"
112
+ },
113
+ outcome: "locked"
114
+ };
115
+ return {
116
+ challenge: {
117
+ ...challenge,
118
+ attempts
119
+ },
120
+ outcome: "incorrect"
121
+ };
122
+ }
123
+ /** True once a challenge has been successfully verified. */
124
+ function isOtpVerified(challenge) {
125
+ return challenge.status === "verified";
126
+ }
127
+ /** True while a challenge can still accept an attempt (pending + unexpired). */
128
+ function isOtpActive(challenge, now = /* @__PURE__ */ new Date()) {
129
+ return challenge.status === "pending" && now.getTime() <= Date.parse(challenge.expiresAt);
130
+ }
131
+ //#endregion
132
+ export { createOtpChallenge, isOtpActive, isOtpVerified, verifyOtpChallenge };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -97,6 +97,14 @@
97
97
  "types": "./dist/person.d.mts",
98
98
  "default": "./dist/person.mjs"
99
99
  },
100
+ "./identity-verification": {
101
+ "types": "./dist/identity-verification.d.mts",
102
+ "default": "./dist/identity-verification.mjs"
103
+ },
104
+ "./otp": {
105
+ "types": "./dist/otp.d.mts",
106
+ "default": "./dist/otp.mjs"
107
+ },
100
108
  "./state-machine": {
101
109
  "types": "./dist/state-machine.d.mts",
102
110
  "default": "./dist/state-machine.mjs"