Files
hanzo.ai/lib/auth/phone.ts
zeekay ac2da21e5f sign-in: wallet and phone, and a person claims a wallet's account
/login and /signup draw "Continue with wallet" and "Continue with phone"
after Google and GitHub, each only where IAM's methods say it is on.

Wallet: where IAM signs a wallet in (@hanzo/iam 0.21.37 `wallet`) and the
browser injects an EIP-1193 wallet; the press is loginWithWallet (IAM's
challenge, personal_sign of its EIP-4361 text, IAM's verify) and ends as a
password's sign-in ends. A wallet IAM has not seen is made an account in an
org of its own, with no email.

Phone: where IAM says it sends codes (`code`) and has answered. The number
is read in its country with the SMS consent ticked, then IAM's code flow.
IAM's address lookup answers an email address only, so a number is texted as
any is and the account that holds it is signed in by the code.

Settings -> General -> Sign in another way adds an email or a phone to the
signed-in account through IAM's own proof (addAddress, PUT /v1/iam/address),
drawn only where IAM's methods say `address`. Same account, org, keys and
balance; nothing is created, so nothing earns twice.

Gates: wallet.spec (a throwaway secp256k1 key behind an injected provider),
phone.spec, address.spec. login.spec expects the phone after the providers.
2026-10-26 16:47:32 -07:00

58 lines
2.6 KiB
TypeScript

/**
* A phone number, as the sign-in panel asks for one. The sign-in itself is IAM's
* code flow with a number for the address (@hanzo/iam `sendLoginCode`, then
* `loginWithCode`): IAM texts the code through notify and spends it at login.
* The number IAM is sent is @hanzo/iam/validation `e164`; this file holds what
* the page says and counts around it, and is pure, so it is tested without a
* browser.
*/
import { getCountries, getCountryCallingCode, parsePhoneNumberFromString } from 'libphonenumber-js/min'
/** IAM's wait between two codes to one number, in seconds (internal/otp `ResendInterval`). */
export const RESEND = 30
/** Wrong codes IAM takes against one it sent before that code is spent (internal/otp `MaxAttempts`). */
export const TRIES = 5
/**
* The SMS consent a person agrees to before a number is texted: hanzo/id's
* `smsConsentText` for Hanzo AI, word for word, because carrier campaign review
* (A2P 10DLC) compares the disclosure across every surface that collects a number.
*/
export const CONSENT =
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
'including one-time passcodes and two-factor authentication, account and security ' +
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
'a condition of any purchase.'
/** One country a number may be in: its ISO code, its name in the reader's language, its calling code. */
export type Country = { value: string; label: string; callingCode: string }
/**
* Every country libphonenumber numbers, named in `locale` (`Intl.DisplayNames`,
* the ISO code where the runtime has no name) and in that locale's order.
*/
export function countries(locale: string): Country[] {
let names: Intl.DisplayNames | null = null
try {
names = new Intl.DisplayNames([locale], { type: 'region' })
} catch {
/* no names here: the ISO codes stand */
}
return getCountries()
.map((value) => ({ value, label: names?.of(value) ?? value, callingCode: getCountryCallingCode(value) }))
.sort((a, b) => a.label.localeCompare(b.label, locale))
}
/** An E.164 number as a person reads it: "+1 415 555 0134". */
export function shown(number: string): string {
return parsePhoneNumberFromString(number)?.formatInternational() ?? number
}
/** Seconds until another code may be asked for, for one sent at `at` (ms), at `now` (ms). */
export function wait(at: number, now: number): number {
return Math.max(0, RESEND - Math.floor((now - at) / 1000))
}