diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..0021879
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,106 @@
+# Publish the release to npm.
+#
+# The release is the trigger, so there is no separate step to forget: a channel
+# that only updates when somebody remembers is a channel that silently serves an
+# old version forever. `workflow_dispatch` is for re-running one that failed on
+# something transient, and is safe because a version already on the registry is
+# skipped rather than attempted.
+#
+# Authenticates with an npm automation token in the repository secret
+# `NPM_TOKEN`. `registry-url` is what makes setup-node write the token into
+# .npmrc; it is required for token auth and fatal under trusted publishing.
+name: publish
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+
+ # The Postgres half of the suite runs in test.yml; a release reruns the
+ # rest (libSQL in memory, rendering, validation) against the exact tree.
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ env:
+ CI: true
+
+ - name: Read the version being published
+ id: version
+ run: |
+ VERSION="$(node -p "require('./package.json').version")"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "@profullstack/profile@$VERSION"
+
+ - name: Check the tag matches package.json
+ if: github.event_name == 'release'
+ env:
+ TAG: ${{ github.event.release.tag_name }}
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ if [ "$TAG" != "v$VERSION" ]; then
+ echo "::error::release tag $TAG does not match package.json version $VERSION"
+ exit 1
+ fi
+
+ - name: Skip if this version is already on npm
+ id: published
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ if npm view "@profullstack/profile@$VERSION" version >/dev/null 2>&1; then
+ echo "already=true" >> "$GITHUB_OUTPUT"
+ echo "@profullstack/profile@$VERSION is already published — nothing to do"
+ else
+ echo "already=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Require an npm token
+ if: steps.published.outputs.already == 'false'
+ env:
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: |
+ if [ -z "$NPM_TOKEN" ]; then
+ echo "::error::NPM_TOKEN is not set — add an npm automation token as a repository secret named NPM_TOKEN"
+ exit 1
+ fi
+
+ - name: Publish
+ if: steps.published.outputs.already == 'false'
+ run: npm publish --access public --provenance
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ # A brand-new scoped package 404s on every read path for minutes after a
+ # successful publish, so a short window here fails a release that worked.
+ # Wait longer, and never fail the run on the read alone: `npm publish`
+ # exiting 0 is the authority on whether it published.
+ - name: Confirm the registry has it
+ if: steps.published.outputs.already == 'false'
+ continue-on-error: true
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ for _ in $(seq 1 30); do
+ if [ "$(npm view "@profullstack/profile@$VERSION" version 2>/dev/null)" = "$VERSION" ]; then
+ echo "@profullstack/profile@$VERSION is on the registry"
+ exit 0
+ fi
+ sleep 10
+ done
+ echo "::warning::published, but the registry does not report @profullstack/profile@$VERSION yet (first publishes can lag several minutes)"
+
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..c3e0571
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,34 @@
+name: test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ # The store's whole point is behaving the same on both databases, so the
+ # Postgres half runs against a real one, not a mock.
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_PASSWORD: test
+ POSTGRES_DB: profile_test
+ ports: ["5432:5432"]
+ options: >-
+ --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 2s --health-retries 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ env:
+ TEST_DATABASE_URL: postgres://postgres:test@127.0.0.1:5432/profile_test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..21d1c1c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+*.db
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d1406c4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,154 @@
+# @profullstack/profile
+
+One user profile for every Profullstack property. Six fields, in this order, everywhere:
+
+| column | what it holds | example |
+| --- | --- | --- |
+| `email` | the contact address, lower-cased | `ada@acme.com` |
+| `first_name` | as typed, case kept | `Ada` |
+| `last_name` | as typed, case kept | `Lovelace` |
+| `company_domain` | bare domain, no scheme or `www.` | `acme.com` |
+| `job_title` | as typed, whitespace cleaned | `VP Engineering` |
+| `linkedin_url` | canonical profile URL | `https://www.linkedin.com/in/ada-lovelace` |
+
+The order is the CSV column order Explee, Apollo and cli-tools `user-export --full` read, so an export from any property imports anywhere without remapping.
+
+What the package gives a property:
+
+- **Validation and normalization.** `acme.com/about`, `https://www.Acme.com` and `@acme.com` all become `acme.com`. Any LinkedIn URL, `in/x` or bare handle becomes the canonical URL. A link in a name box is refused as spam. Names are never "corrected" (McDonald stays McDonald).
+- **Company from a work email.** A blank company domain is filled from the signup email unless it is a personal or relay mailbox (gmail, proton, passmail and so on).
+- **Signup form fields.** As an HTML string for server-rendered pages, and as a React component, with the right `autocomplete` tokens so browsers fill them.
+- **Storage on whatever the property already runs:** postgres.js, node-postgres, libSQL/Turso, or supabase-js on the server. Either its own `user_profiles` table, or the columns added to an existing `profiles`/`users` table.
+- **`GET`/`PUT`/`PATCH /api/v1/me/profile`** handlers on Web-standard Request/Response, for Next and Hono alike.
+- **A CLI and an MCP server** (`profile`), including `profile export` in contract order.
+
+```sh
+npm i @profullstack/profile
+```
+
+It has no dependencies. `postgres`, `@libsql/client` and `react` are optional peers; the property already has whichever it uses.
+
+## Required fields
+
+By default a signup must give `email`, `first_name` and `last_name`; the other three are offered and marked optional, because every required box costs signups. A property that sells to companies passes `LEAD_REQUIRED` (adds `company_domain` and `job_title`, the fields an outbound tool will not import a lead without).
+
+```js
+import { validateProfile, LEAD_REQUIRED } from "@profullstack/profile";
+validateProfile(form, { required: LEAD_REQUIRED });
+```
+
+## Adopting it in a property
+
+1. **Schema.** Call `migrate()` on boot or in the migration runner. It creates `user_profiles`, or adds only the missing columns to the table you name. Postgres takes an advisory lock, so concurrent boots are safe. On Supabase (no DDL through supabase-js), put `profile schema` output in a migration file.
+2. **Signup form.** Add the fields beside the email box.
+3. **Signup handler.** Validate before creating the user, save after.
+4. **Profile page.** Mount the handlers and let people fill in what they skipped.
+5. **Backfill.** Copy any name or company columns the property already has, using `fillBlanks` so nothing typed is overwritten.
+
+### Next on Postgres (postgres.js)
+
+```js
+// lib/profile.js
+import sql from "@/lib/db";
+import { createProfileStore } from "@profullstack/profile";
+export const profiles = createProfileStore(sql); // or { table: "profiles", key: "id" }
+```
+
+```jsx
+// app/signup/page.jsx (a server component posting to a route handler or a server action)
+import { ProfileFields } from "@profullstack/profile/react";
+
+
+```
+
+```js
+// the signup action / POST handler
+import { validateProfile, saveSignupProfile } from "@profullstack/profile";
+
+const check = validateProfile(formData);
+if (!check.ok) return { errors: check.errors };
+const user = await createUserAndSendMagicLink(check.profile.email);
+await saveSignupProfile(profiles, user.id, formData, { email: user.email });
+```
+
+```js
+// app/api/v1/me/profile/route.js
+import { createProfileHandlers } from "@profullstack/profile";
+import { profiles } from "@/lib/profile";
+import { currentUser } from "@/lib/session";
+
+export const { GET, PUT, PATCH } = createProfileHandlers({
+ store: profiles,
+ getUser: (req) => currentUser(req), // { id, email } or null
+});
+```
+
+### Hono on Turso
+
+```js
+import { createClient } from "@libsql/client";
+import { createProfileStore, mountProfileRoutes, renderProfileFields, PROFILE_ENHANCE_SCRIPT } from "@profullstack/profile";
+
+const profiles = createProfileStore(createClient({ url, authToken }));
+await profiles.migrate();
+mountProfileRoutes(app, { store: profiles, getUser: (req) => sessionUser(req) });
+
+app.get("/signup", (c) =>
+ c.html(``),
+);
+```
+
+### Supabase
+
+Browsers talk only to `/api`, so the store runs on the server with the service client:
+
+```js
+const profiles = createProfileStore(supabaseAdmin, { table: "profiles", key: "id" });
+```
+
+```sh
+npx @profullstack/profile schema --table profiles --key id > supabase/migrations/20261026000000_profile_fields.sql
+```
+
+(For an existing table, write the `alter table ... add column if not exists` lines yourself, or run `profile migrate` with `DATABASE_URL` pointing at the database.)
+
+## Store API
+
+```js
+const profiles = createProfileStore(db, { table = "user_profiles", key = "user_id" });
+await profiles.migrate(); // { created, added: [columns] }
+await profiles.save(userId, { job_title: "CTO" }); // only given fields; null clears
+await profiles.fillBlanks(userId, enriched); // never overwrites what a person typed
+await profiles.get(userId); await profiles.getByEmail(email);
+await profiles.list({ limit, after, since }); // keyset pages: { profiles, next }
+for await (const p of profiles.all()) { ... }
+await profiles.remove(userId);
+```
+
+There is no foreign key to a users table: the host's may be named anything or live elsewhere. Deleting a user is the host's job, so call `remove()` there.
+
+## CLI and MCP
+
+```sh
+export DATABASE_URL=postgres://... # or libsql://..., file:app.db (TURSO_AUTH_TOKEN for Turso)
+profile migrate
+profile get
+profile find ada@acme.com
+profile set --job-title CTO --linkedin in/ada-lovelace
+profile check --company-domain https://www.acme.com/about
+profile export --complete > leads.csv # only rows with every lead field
+profile export --format tsv --since 2026-10-01
+profile mcp # tools: get_profile, update_profile, validate_profile, export_profiles
+```
+
+`--table` and `--key` (or `PROFILE_TABLE`, `PROFILE_KEY`) point it at an existing table.
+
+## License
+
+MIT
diff --git a/index.d.ts b/index.d.ts
new file mode 100644
index 0000000..fdfa704
--- /dev/null
+++ b/index.d.ts
@@ -0,0 +1,159 @@
+export type ProfileField =
+ | "email"
+ | "first_name"
+ | "last_name"
+ | "company_domain"
+ | "job_title"
+ | "linkedin_url";
+
+export type ProfileFields = { [K in ProfileField]: string | null };
+
+export interface Profile extends ProfileFields {
+ user_id: string;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+export interface FieldMeta {
+ name: ProfileField;
+ label: string;
+ type: "email" | "text" | "url";
+ autocomplete: string;
+ inputmode?: string;
+ maxLength: number;
+ placeholder?: string;
+ hint?: string;
+}
+
+export const COLUMNS: readonly ProfileField[];
+export const FIELDS: readonly FieldMeta[];
+export const FIELD_BY_NAME: Readonly>;
+export const DEFAULT_REQUIRED: readonly ProfileField[];
+export const LEAD_REQUIRED: readonly ProfileField[];
+
+export class ProfileFieldError extends Error {
+ field: ProfileField;
+}
+export class ProfileValidationError extends Error {
+ status: 400;
+ errors: Partial>;
+}
+
+export const NORMALIZERS: Readonly string | null>>;
+export const FREEMAIL: Set;
+export function isFreemail(domain: string | null | undefined): boolean;
+export function cleanText(v: unknown): string;
+export function normalizeEmail(v: unknown): string | null;
+export function normalizeFirstName(v: unknown): string | null;
+export function normalizeLastName(v: unknown): string | null;
+export function normalizeDomain(v: unknown): string | null;
+export function normalizeJobTitle(v: unknown): string | null;
+export function normalizeLinkedin(v: unknown): string | null;
+export function domainFromEmail(email: string | null | undefined): string | null;
+export function splitFullName(full: string | null | undefined): {
+ first_name: string | null;
+ last_name: string | null;
+};
+
+export type ProfileInput =
+ | FormData
+ | URLSearchParams
+ | Map
+ | Record
+ | null
+ | undefined;
+
+export interface ValidateOptions {
+ required?: readonly ProfileField[];
+ partial?: boolean;
+ deriveDomain?: boolean;
+}
+export interface ValidationResult {
+ ok: boolean;
+ profile: Partial;
+ errors: Partial>;
+}
+export function readProfileInput(input: ProfileInput): Partial>;
+export function validateProfile(input: ProfileInput, options?: ValidateOptions): ValidationResult;
+export function parseProfile(input: ProfileInput, options?: ValidateOptions): Partial;
+export function missingFields(
+ profile: Partial | null | undefined,
+ fields?: readonly ProfileField[],
+): ProfileField[];
+
+export interface DbAdapter {
+ dialect: "postgres" | "sqlite";
+ query(text: string, params?: unknown[]): Promise[]>;
+}
+export interface StoreOptions {
+ table?: string;
+ key?: string;
+ keyType?: string;
+}
+export interface ProfileStore {
+ readonly table: string;
+ readonly key: string;
+ migrate(): Promise<{ created: boolean; added: string[] }>;
+ get(userId: string | number): Promise;
+ getByEmail(email: string): Promise;
+ save(userId: string | number, profile: Partial): Promise;
+ fillBlanks(userId: string | number, profile: Partial): Promise;
+ remove(userId: string | number): Promise;
+ list(opts?: { limit?: number; after?: string | null; since?: string | null }): Promise<{
+ profiles: Profile[];
+ next: string | null;
+ }>;
+ all(opts?: { limit?: number; since?: string | null }): AsyncGenerator;
+}
+/** postgres.js sql, a pg Pool/Client, an @libsql/client, a server-side supabase-js client, or a DbAdapter. */
+export function createProfileStore(db: unknown, options?: StoreOptions): ProfileStore;
+export function migrate(
+ dbOrStore: unknown,
+ options?: StoreOptions,
+): Promise<{ created: boolean; added: string[] }>;
+export function schemaSql(options?: StoreOptions & { dialect?: "postgres" | "sqlite" }): string;
+export function adapt(db: unknown): DbAdapter | { dialect: "supabase"; client: unknown };
+
+export interface SessionUser {
+ id: string | number;
+ email?: string | null;
+}
+export interface HandlerOptions {
+ store: ProfileStore;
+ getUser(request: Request, ...rest: unknown[]): SessionUser | null | Promise;
+ required?: readonly ProfileField[];
+ onSaved?(profile: Profile, user: SessionUser): unknown;
+}
+export function createProfileHandlers(options: HandlerOptions): {
+ GET(request: Request, ...rest: unknown[]): Promise;
+ PUT(request: Request, ...rest: unknown[]): Promise;
+ PATCH(request: Request, ...rest: unknown[]): Promise;
+};
+export function mountProfileRoutes(app: T, options: HandlerOptions & { path?: string }): T;
+export function saveSignupProfile(
+ store: ProfileStore,
+ userId: string | number,
+ input: ProfileInput,
+ options?: { email?: string | null; required?: readonly ProfileField[] },
+): Promise | Profile }>;
+
+export interface RenderOptions {
+ values?: Partial>;
+ errors?: Partial>;
+ required?: readonly ProfileField[];
+ fields?: readonly ProfileField[];
+ idPrefix?: string;
+ className?: string;
+ labels?: Partial>;
+}
+export function renderProfileFields(options?: RenderOptions): string;
+export const PROFILE_ENHANCE_SCRIPT: string;
+
+export interface CsvOptions {
+ columns?: readonly string[];
+ delimiter?: string;
+}
+export function csvCell(v: unknown, delimiter?: string): string;
+export function csvHeader(options?: CsvOptions): string;
+export function csvRow(profile: Partial>, options?: CsvOptions): string;
+export function toCsv(profiles: Partial>[], options?: CsvOptions): string;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..2070dc6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,479 @@
+{
+ "name": "@profullstack/profile",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@profullstack/profile",
+ "version": "0.1.0",
+ "license": "MIT",
+ "bin": {
+ "profile": "src/cli.js"
+ },
+ "devDependencies": {
+ "@libsql/client": "^0.15.15",
+ "postgres": "^3.4.7",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@libsql/client": ">=0.6.0",
+ "postgres": "^3.4.0",
+ "react": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@libsql/client": {
+ "optional": true
+ },
+ "postgres": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@libsql/client": {
+ "version": "0.15.15",
+ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz",
+ "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@libsql/core": "^0.15.14",
+ "@libsql/hrana-client": "^0.7.0",
+ "js-base64": "^3.7.5",
+ "libsql": "^0.5.22",
+ "promise-limit": "^2.7.0"
+ }
+ },
+ "node_modules/@libsql/core": {
+ "version": "0.15.15",
+ "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.15.15.tgz",
+ "integrity": "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-base64": "^3.7.5"
+ }
+ },
+ "node_modules/@libsql/darwin-arm64": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz",
+ "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@libsql/darwin-x64": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz",
+ "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@libsql/hrana-client": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz",
+ "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@libsql/isomorphic-fetch": "^0.3.1",
+ "@libsql/isomorphic-ws": "^0.1.5",
+ "js-base64": "^3.7.5",
+ "node-fetch": "^3.3.2"
+ }
+ },
+ "node_modules/@libsql/isomorphic-fetch": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz",
+ "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@libsql/isomorphic-ws": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz",
+ "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ws": "^8.5.4",
+ "ws": "^8.13.0"
+ }
+ },
+ "node_modules/@libsql/linux-arm-gnueabihf": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz",
+ "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@libsql/linux-arm-musleabihf": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz",
+ "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@libsql/linux-arm64-gnu": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz",
+ "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@libsql/linux-arm64-musl": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz",
+ "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@libsql/linux-x64-gnu": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz",
+ "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@libsql/linux-x64-musl": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz",
+ "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@libsql/win32-x64-msvc": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz",
+ "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@neon-rs/load": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz",
+ "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
+ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
+ "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/js-base64": {
+ "version": "3.9.4",
+ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz",
+ "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/libsql": {
+ "version": "0.5.29",
+ "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz",
+ "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==",
+ "cpu": [
+ "x64",
+ "arm64",
+ "wasm32",
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "os": [
+ "darwin",
+ "linux",
+ "win32"
+ ],
+ "dependencies": {
+ "@neon-rs/load": "^0.0.4",
+ "detect-libc": "2.0.2"
+ },
+ "optionalDependencies": {
+ "@libsql/darwin-arm64": "0.5.29",
+ "@libsql/darwin-x64": "0.5.29",
+ "@libsql/linux-arm-gnueabihf": "0.5.29",
+ "@libsql/linux-arm-musleabihf": "0.5.29",
+ "@libsql/linux-arm64-gnu": "0.5.29",
+ "@libsql/linux-arm64-musl": "0.5.29",
+ "@libsql/linux-x64-gnu": "0.5.29",
+ "@libsql/linux-x64-musl": "0.5.29",
+ "@libsql/win32-x64-msvc": "0.5.29"
+ }
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
+ "node_modules/postgres": {
+ "version": "3.4.9",
+ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
+ "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
+ "dev": true,
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/porsager"
+ }
+ },
+ "node_modules/promise-limit": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
+ "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/react": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
+ "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
+ "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.1"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0204d48
--- /dev/null
+++ b/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@profullstack/profile",
+ "version": "0.1.0",
+ "type": "module",
+ "description": "One user profile for every property: email, first_name, last_name, company_domain, job_title, linkedin_url. Validation, signup form fields, storage on Postgres/Turso/Supabase, HTTP handlers, CSV export, CLI and MCP.",
+ "keywords": ["profile", "signup", "user-profile", "lead", "postgres", "turso", "libsql", "supabase", "mcp"],
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/profullstack/profile.git"
+ },
+ "homepage": "https://github.com/profullstack/profile#readme",
+ "exports": {
+ ".": { "types": "./index.d.ts", "import": "./src/index.js" },
+ "./react": { "types": "./react.d.ts", "import": "./src/react.js" },
+ "./cli": { "import": "./src/cli.js" }
+ },
+ "bin": { "profile": "./src/cli.js" },
+ "files": ["src", "index.d.ts", "react.d.ts", "README.md"],
+ "scripts": {
+ "test": "node --test test/*.test.js"
+ },
+ "peerDependencies": {
+ "@libsql/client": ">=0.6.0",
+ "postgres": "^3.4.0",
+ "react": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@libsql/client": { "optional": true },
+ "postgres": { "optional": true },
+ "react": { "optional": true }
+ },
+ "devDependencies": {
+ "@libsql/client": "^0.15.15",
+ "postgres": "^3.4.7",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "engines": { "node": ">=20" }
+}
diff --git a/react.d.ts b/react.d.ts
new file mode 100644
index 0000000..68f3d4e
--- /dev/null
+++ b/react.d.ts
@@ -0,0 +1,13 @@
+import type { ReactElement } from "react";
+import type { ProfileField, RenderOptions } from "./index.js";
+
+export interface ProfileFieldsProps extends RenderOptions {
+ /** Controlled values; pass with onChange. */
+ values?: Partial>;
+ onChange?(values: Partial>): void;
+ /** Initial values when uncontrolled. */
+ defaultValues?: Partial>;
+ disabled?: boolean;
+}
+
+export function ProfileFields(props: ProfileFieldsProps): ReactElement;
diff --git a/src/cli.js b/src/cli.js
new file mode 100644
index 0000000..9e8212e
--- /dev/null
+++ b/src/cli.js
@@ -0,0 +1,277 @@
+#!/usr/bin/env node
+/**
+ * profile - the six signup fields of any property, from a terminal or an agent.
+ *
+ * Reads PROFILE_DATABASE_URL or DATABASE_URL: postgres:// uses postgres.js,
+ * libsql://, file: and *.db use @libsql/client. Whichever the property uses is
+ * already installed there; this package depends on neither.
+ */
+import { createWriteStream } from "node:fs";
+import { createInterface } from "node:readline";
+import {
+ COLUMNS,
+ LEAD_REQUIRED,
+ createProfileStore,
+ migrate,
+ schemaSql,
+ validateProfile,
+ missingFields,
+ csvHeader,
+ csvRow,
+} from "./index.js";
+
+const VERSION = "0.1.0";
+
+const USAGE = `profile - signup profile fields (${COLUMNS.join(", ")})
+
+ profile migrate create the table, or add missing columns
+ profile schema [--dialect sqlite] print the DDL for a migration file
+ profile get one profile
+ profile find look a profile up by email
+ profile set --first-name Ada --company-domain acme.com ...
+ validate and save the given fields
+ profile check --email a@acme.com --linkedin linkedin.com/in/ada
+ validate and normalize, no database
+ profile export [--format csv|tsv|json|jsonl] [--complete] [--since ISO] [--out FILE]
+ every profile, columns in contract order;
+ --complete keeps only lead-ready rows
+ profile mcp MCP server on stdio
+
+Options: --table user_profiles --key user_id (or PROFILE_TABLE, PROFILE_KEY)
+Environment: PROFILE_DATABASE_URL or DATABASE_URL`;
+
+function parseArgs(argv) {
+ const flags = {};
+ const args = [];
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (!a.startsWith("--")) {
+ args.push(a);
+ continue;
+ }
+ const [k, inline] = a.slice(2).split(/=(.*)/s);
+ const key = k.replace(/-/g, "_");
+ if (inline !== undefined) flags[key] = inline;
+ else if (argv[i + 1] === undefined || argv[i + 1].startsWith("--")) flags[key] = true;
+ else flags[key] = argv[++i];
+ }
+ return { flags, args };
+}
+
+/** Flags named like the fields (or their aliases) become a profile input. */
+function fieldFlags(flags) {
+ const skip = new Set(["table", "key", "format", "out", "since", "complete", "dialect", "help", "json"]);
+ return Object.fromEntries(Object.entries(flags).filter(([k]) => !skip.has(k)));
+}
+
+async function connect(flags) {
+ const url = process.env.PROFILE_DATABASE_URL || process.env.DATABASE_URL;
+ if (!url) {
+ console.error("PROFILE_DATABASE_URL or DATABASE_URL is not set");
+ process.exit(2);
+ }
+ const opts = {
+ table: flags.table || process.env.PROFILE_TABLE || undefined,
+ key: flags.key || process.env.PROFILE_KEY || undefined,
+ };
+ if (/^postgres(ql)?:/.test(url)) {
+ const { default: postgres } = await import("postgres");
+ const sql = postgres(url, { onnotice: () => {}, max: 2 });
+ return { store: createProfileStore(sql, opts), close: () => sql.end() };
+ }
+ const { createClient } = await import("@libsql/client");
+ const client = createClient({
+ url: /^(libsql|https?|wss?|file):/.test(url) ? url : `file:${url}`,
+ authToken: process.env.PROFILE_DATABASE_TOKEN || process.env.TURSO_AUTH_TOKEN || undefined,
+ });
+ return { store: createProfileStore(client, opts), close: () => client.close() };
+}
+
+const print = (v) => console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
+
+async function exportProfiles(store, { format = "csv", complete = false, since, out } = {}) {
+ const stream = out ? createWriteStream(out) : process.stdout;
+ const write = (s) => new Promise((r) => (stream.write(s) ? r() : stream.once("drain", r)));
+ const delimiter = format === "tsv" ? "\t" : ",";
+ let n = 0;
+ if (format === "csv" || format === "tsv") await write(csvHeader({ delimiter }) + "\n");
+ if (format === "json") await write("[");
+ for await (const p of store.all({ since })) {
+ if (complete && missingFields(p, LEAD_REQUIRED).length) continue;
+ if (format === "json") await write((n ? ",\n" : "\n") + JSON.stringify(p));
+ else if (format === "jsonl") await write(JSON.stringify(p) + "\n");
+ else await write(csvRow(p, { delimiter }) + "\n");
+ n++;
+ }
+ if (format === "json") await write("\n]\n");
+ if (out) await new Promise((r) => stream.end(r));
+ return n;
+}
+
+// ---------------------------------------------------------------- MCP (stdio)
+
+const TOOLS = [
+ {
+ name: "get_profile",
+ description: "Read one user's profile (email, name, company domain, job title, LinkedIn) by user id or email.",
+ inputSchema: {
+ type: "object",
+ properties: { user_id: { type: "string" }, email: { type: "string" } },
+ },
+ },
+ {
+ name: "update_profile",
+ description:
+ "Validate and save profile fields for a user. Only the fields given are changed; an empty string clears one. company_domain accepts a URL or email; linkedin_url accepts a handle or any LinkedIn profile URL.",
+ inputSchema: {
+ type: "object",
+ required: ["user_id"],
+ properties: {
+ user_id: { type: "string" },
+ ...Object.fromEntries(COLUMNS.map((c) => [c, { type: "string" }])),
+ only_blanks: { type: "boolean", description: "Write only into fields that are empty (enrichment)." },
+ },
+ },
+ },
+ {
+ name: "validate_profile",
+ description: "Normalize and validate profile fields without saving. Returns the canonical values and any errors.",
+ inputSchema: { type: "object", properties: Object.fromEntries(COLUMNS.map((c) => [c, { type: "string" }])) },
+ },
+ {
+ name: "export_profiles",
+ description: `Export profiles as CSV with columns ${COLUMNS.join(",")}. complete=true keeps only rows with every lead field.`,
+ inputSchema: {
+ type: "object",
+ properties: {
+ complete: { type: "boolean" },
+ since: { type: "string", description: "ISO time; only rows updated since." },
+ limit: { type: "number", description: "Default 1000." },
+ },
+ },
+ },
+];
+
+async function callTool(getStore, name, a = {}) {
+ if (name === "validate_profile") return validateProfile(a, { required: [] });
+ const store = await getStore();
+ if (name === "get_profile") {
+ const p = a.user_id ? await store.get(a.user_id) : a.email ? await store.getByEmail(a.email) : null;
+ if (!a.user_id && !a.email) throw new Error("give user_id or email");
+ return p ?? { error: "not found" };
+ }
+ if (name === "update_profile") {
+ const { user_id, only_blanks, ...fields } = a;
+ const r = validateProfile(fields, { required: [], partial: true });
+ if (!r.ok) return { error: "invalid", errors: r.errors };
+ return only_blanks ? store.fillBlanks(user_id, r.profile) : store.save(user_id, r.profile);
+ }
+ if (name === "export_profiles") {
+ const limit = Math.min(Number(a.limit) || 1000, 20000);
+ const rows = [csvHeader()];
+ for await (const p of store.all({ since: a.since })) {
+ if (a.complete && missingFields(p, LEAD_REQUIRED).length) continue;
+ rows.push(csvRow(p));
+ if (rows.length > limit) break;
+ }
+ return rows.join("\n");
+ }
+ throw new Error(`unknown tool ${name}`);
+}
+
+async function serveMcp(flags) {
+ let conn;
+ const getStore = async () => (conn ??= await connect(flags)).store;
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
+ const rl = createInterface({ input: process.stdin });
+ for await (const line of rl) {
+ if (!line.trim()) continue;
+ let msg;
+ try {
+ msg = JSON.parse(line);
+ } catch {
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
+ continue;
+ }
+ const { id, method, params } = msg;
+ if (id === undefined) continue; // notifications need no answer
+ const reply = (result) => send({ jsonrpc: "2.0", id, result });
+ try {
+ if (method === "initialize") {
+ reply({
+ protocolVersion: params?.protocolVersion ?? "2025-06-18",
+ capabilities: { tools: {} },
+ serverInfo: { name: "@profullstack/profile", version: VERSION },
+ });
+ } else if (method === "ping") reply({});
+ else if (method === "tools/list") reply({ tools: TOOLS });
+ else if (method === "tools/call") {
+ try {
+ const out = await callTool(getStore, params?.name, params?.arguments);
+ reply({ content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] });
+ } catch (e) {
+ reply({ content: [{ type: "text", text: String(e.message ?? e) }], isError: true });
+ }
+ } else send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
+ } catch (e) {
+ send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e.message ?? e) } });
+ }
+ }
+ await conn?.close();
+}
+
+// ---------------------------------------------------------------- main
+
+const { flags, args } = parseArgs(process.argv.slice(2));
+const [cmd, ...rest] = args;
+
+if (flags.version) {
+ print(VERSION);
+ process.exit(0);
+}
+if (!cmd || flags.help || cmd === "help") {
+ print(USAGE);
+ process.exit(0);
+}
+
+if (cmd === "mcp") {
+ await serveMcp(flags);
+ process.exit(0);
+}
+if (cmd === "schema") {
+ print(schemaSql({ dialect: flags.dialect === "sqlite" ? "sqlite" : "postgres", table: flags.table, key: flags.key }));
+ process.exit(0);
+}
+if (cmd === "check") {
+ const r = validateProfile(fieldFlags(flags), { required: [] });
+ print(r);
+ process.exit(r.ok ? 0 : 1);
+}
+
+const { store, close } = await connect(flags);
+let code = 0;
+try {
+ if (cmd === "migrate") print(await migrate(store));
+ else if (cmd === "get") print((await store.get(rest[0])) ?? "not found");
+ else if (cmd === "find") print((await store.getByEmail(rest[0])) ?? "not found");
+ else if (cmd === "set") {
+ if (!rest[0]) throw new Error("profile set --field value ...");
+ const r = validateProfile(fieldFlags(flags), { required: [], partial: true });
+ if (!r.ok) {
+ print(r.errors);
+ code = 1;
+ } else print(await store.save(rest[0], r.profile));
+ } else if (cmd === "export") {
+ const n = await exportProfiles(store, flags);
+ console.error(`${n} profile(s)`);
+ } else {
+ console.error(`unknown command: ${cmd}\n\n${USAGE}`);
+ code = 2;
+ }
+} catch (e) {
+ console.error(e.message ?? e);
+ code = 1;
+} finally {
+ await close();
+}
+process.exit(code);
diff --git a/src/csv.js b/src/csv.js
new file mode 100644
index 0000000..892fe13
--- /dev/null
+++ b/src/csv.js
@@ -0,0 +1,27 @@
+/**
+ * CSV in the contract's column order, which is what Explee, Apollo and our own
+ * `user-export --full` read, so an export from any property imports anywhere.
+ */
+import { COLUMNS } from "./fields.js";
+
+// A cell a spreadsheet would run as a formula gets a leading apostrophe.
+const FORMULA = /^[=+@\t\r]|^-(?![\d.])/;
+
+export function csvCell(v, delimiter = ",") {
+ if (v == null) return "";
+ let s = String(v);
+ if (FORMULA.test(s)) s = `'${s}`;
+ return s.includes(delimiter) || /["\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}
+
+export function csvHeader({ columns = COLUMNS, delimiter = "," } = {}) {
+ return columns.join(delimiter);
+}
+
+export function csvRow(profile, { columns = COLUMNS, delimiter = "," } = {}) {
+ return columns.map((c) => csvCell(profile?.[c], delimiter)).join(delimiter);
+}
+
+export function toCsv(profiles, opts = {}) {
+ return [csvHeader(opts), ...profiles.map((p) => csvRow(p, opts))].join("\n") + "\n";
+}
diff --git a/src/fields.js b/src/fields.js
new file mode 100644
index 0000000..2125dc5
--- /dev/null
+++ b/src/fields.js
@@ -0,0 +1,92 @@
+/**
+ * The profile contract: six fields, in this order, everywhere.
+ *
+ * The order is not cosmetic. It is the column order of the CSV every sales tool
+ * we feed expects (Explee, Apollo, our own `user-export --full`), so an export
+ * from any property can be imported anywhere without remapping. Add a field at
+ * the end or not at all.
+ */
+export const COLUMNS = Object.freeze([
+ "email",
+ "first_name",
+ "last_name",
+ "company_domain",
+ "job_title",
+ "linkedin_url",
+]);
+
+/**
+ * What a signup form asks for out of the box. Email is the account, and a name
+ * is what makes a person out of an address; the rest is asked for, not demanded,
+ * because every required box costs signups. A site that sells to companies can
+ * require more by passing its own list.
+ */
+export const DEFAULT_REQUIRED = Object.freeze(["email", "first_name", "last_name"]);
+
+/** The fields a lead needs before an outbound tool will accept it at all. */
+export const LEAD_REQUIRED = Object.freeze([
+ "email",
+ "first_name",
+ "last_name",
+ "company_domain",
+ "job_title",
+]);
+
+/**
+ * Form metadata. `autocomplete` uses the HTML tokens so browsers and password
+ * managers fill the right box; `inputmode` picks a phone keyboard that has the
+ * characters the field needs.
+ */
+export const FIELDS = Object.freeze([
+ {
+ name: "email",
+ label: "Email",
+ type: "email",
+ autocomplete: "email",
+ inputmode: "email",
+ maxLength: 254,
+ placeholder: "you@company.com",
+ },
+ {
+ name: "first_name",
+ label: "First name",
+ type: "text",
+ autocomplete: "given-name",
+ maxLength: 100,
+ },
+ {
+ name: "last_name",
+ label: "Last name",
+ type: "text",
+ autocomplete: "family-name",
+ maxLength: 100,
+ },
+ {
+ name: "company_domain",
+ label: "Company website",
+ type: "text",
+ autocomplete: "url",
+ inputmode: "url",
+ maxLength: 253,
+ placeholder: "company.com",
+ hint: "Filled from a work email.",
+ },
+ {
+ name: "job_title",
+ label: "Job title",
+ type: "text",
+ autocomplete: "organization-title",
+ maxLength: 120,
+ },
+ {
+ name: "linkedin_url",
+ label: "LinkedIn",
+ type: "url",
+ autocomplete: "url",
+ inputmode: "url",
+ maxLength: 200,
+ placeholder: "linkedin.com/in/you",
+ },
+]);
+
+export const FIELD_BY_NAME = Object.freeze(Object.fromEntries(FIELDS.map((f) => [f.name, f])));
diff --git a/src/handlers.js b/src/handlers.js
new file mode 100644
index 0000000..fb6ef75
--- /dev/null
+++ b/src/handlers.js
@@ -0,0 +1,102 @@
+/**
+ * HTTP for the profile, on Web-standard Request/Response so the same handlers
+ * are a Next route (`export const { GET, PUT, PATCH } = createProfileHandlers(...)`)
+ * and a Hono route (`mountProfileRoutes(app, ...)`), with no dependency on either.
+ */
+import { DEFAULT_REQUIRED } from "./fields.js";
+import { validateProfile, missingFields } from "./validate.js";
+
+const json = (body, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
+ });
+
+async function readBody(request) {
+ const type = request.headers.get("content-type") ?? "";
+ if (type.includes("application/json")) return request.json();
+ if (type.includes("form")) return request.formData();
+ const text = await request.text();
+ return text ? JSON.parse(text) : {};
+}
+
+/**
+ * Validate a signup's profile fields and store them for a user who was just
+ * created. The signup handler calls this right after it has a user id:
+ *
+ * const result = await saveSignupProfile(store, user.id, form, { email: user.email });
+ * if (!result.ok) return badRequest(result.errors);
+ *
+ * Validate before creating the user (validateProfile) when a failure should
+ * stop the signup; this call validates again and is safe to repeat.
+ */
+export async function saveSignupProfile(store, userId, input, { email, required = DEFAULT_REQUIRED } = {}) {
+ const result = validateProfile(withEmail(input, email), { required });
+ if (!result.ok) return result;
+ return { ...result, profile: await store.save(userId, result.profile) };
+}
+
+function withEmail(input, email) {
+ if (!email) return input;
+ const has = input && (typeof input.get === "function" ? input.get("email") : input.email);
+ if (has) return input;
+ if (typeof input?.entries === "function" && input.constructor !== Object) {
+ return { ...Object.fromEntries(input.entries()), email };
+ }
+ return { ...(input ?? {}), email };
+}
+
+/**
+ * `getUser(request)` returns `{ id, email? }` for the signed-in user, or null.
+ * The account email fills the profile email when the profile has none.
+ * `required` applies to PUT (a whole profile); PATCH checks only what it sends.
+ */
+export function createProfileHandlers({ store, getUser, required = DEFAULT_REQUIRED, onSaved } = {}) {
+ if (!store || typeof getUser !== "function") throw new Error("profile: store and getUser are required");
+
+ const authed = (fn) => async (request, ...rest) => {
+ let user;
+ try {
+ user = await getUser(request, ...rest);
+ } catch {
+ user = null;
+ }
+ if (!user?.id) return json({ error: "Sign in first." }, 401);
+ try {
+ return await fn(request, user);
+ } catch (e) {
+ if (e instanceof SyntaxError) return json({ error: "The body is not valid JSON." }, 400);
+ throw e;
+ }
+ };
+
+ const write = (partial) =>
+ authed(async (request, user) => {
+ const body = await readBody(request);
+ const input = partial ? body : withEmail(body, user.email);
+ const result = validateProfile(input, { required, partial });
+ if (!result.ok) return json({ error: "Some fields need fixing.", errors: result.errors }, 400);
+ const profile = await store.save(user.id, result.profile);
+ await onSaved?.(profile, user);
+ return json({ profile, missing: missingFields(profile, required) });
+ });
+
+ return {
+ GET: authed(async (_request, user) => {
+ const profile = (await store.get(user.id)) ?? { user_id: String(user.id), email: user.email ?? null };
+ if (!profile.email && user.email) profile.email = user.email;
+ return json({ profile, missing: missingFields(profile, required) });
+ }),
+ PUT: write(false),
+ PATCH: write(true),
+ };
+}
+
+/** Mount on a Hono app (or anything with get/put/patch(path, c => Response)). */
+export function mountProfileRoutes(app, { path = "/api/v1/me/profile", ...opts } = {}) {
+ const h = createProfileHandlers(opts);
+ app.get(path, (c) => h.GET(c.req.raw, c));
+ app.put(path, (c) => h.PUT(c.req.raw, c));
+ app.patch(path, (c) => h.PATCH(c.req.raw, c));
+ return app;
+}
diff --git a/src/html.js b/src/html.js
new file mode 100644
index 0000000..ed2c52d
--- /dev/null
+++ b/src/html.js
@@ -0,0 +1,61 @@
+/**
+ * The signup fields as HTML, for server-rendered pages (Hono, static sites,
+ * anything that is not React). Inputs are named by the column, so the posted
+ * form goes straight into validateProfile().
+ */
+import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js";
+import { FREEMAIL } from "./normalize.js";
+
+const esc = (v) =>
+ String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
+
+/**
+ * Options: `values`, `errors` (from validateProfile), `required`, `fields`
+ * (which to show, default all six), `idPrefix`, `className` (wrapper class,
+ * each row is `${className}__row`), `labels` (override text per field).
+ */
+export function renderProfileFields({
+ values = {},
+ errors = {},
+ required = DEFAULT_REQUIRED,
+ fields = COLUMNS,
+ idPrefix = "profile-",
+ className = "pfs-profile",
+ labels = {},
+} = {}) {
+ const rows = fields.map((name) => {
+ const f = FIELD_BY_NAME[name];
+ if (!f) throw new Error(`profile: unknown field ${name}`);
+ const id = `${idPrefix}${name}`;
+ const req = required.includes(name);
+ const err = errors[name];
+ const attrs = [
+ `id="${esc(id)}"`,
+ `name="${name}"`,
+ `type="${f.type}"`,
+ `autocomplete="${f.autocomplete}"`,
+ f.inputmode && `inputmode="${f.inputmode}"`,
+ `maxlength="${f.maxLength}"`,
+ f.placeholder && `placeholder="${esc(f.placeholder)}"`,
+ values[name] != null && `value="${esc(values[name])}"`,
+ req && "required",
+ err && `aria-invalid="true" aria-describedby="${esc(id)}-error"`,
+ name === "email" && "spellcheck=\"false\" autocapitalize=\"off\"",
+ ].filter(Boolean);
+ return `
+
+ ${err ? `\n
${esc(err)}
` : ""}
+
`;
+ });
+ return ``;
+}
+
+/**
+ * A small script that fills the company box from a work email when the person
+ * leaves the email box and the company box is still empty. Inline it once
+ * after the form, inside the page's CSP nonce if it has one.
+ */
+export const PROFILE_ENHANCE_SCRIPT = `(()=>{const free=new Set(${JSON.stringify([...FREEMAIL])});
+document.querySelectorAll('input[name="email"]').forEach(e=>{const f=e.form;const d=f&&f.querySelector('input[name="company_domain"]');if(!d)return;
+e.addEventListener('blur',()=>{if(d.value.trim())return;const h=(e.value.split('@')[1]||'').trim().toLowerCase();
+if(h.includes('.')&&!free.has(h)&&!/^(yahoo|hotmail|outlook|live|gmx|yandex)\\./.test(h))d.value=h;});});})();`;
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..a08b8d1
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,27 @@
+export { COLUMNS, FIELDS, FIELD_BY_NAME, DEFAULT_REQUIRED, LEAD_REQUIRED } from "./fields.js";
+export {
+ ProfileFieldError,
+ NORMALIZERS,
+ FREEMAIL,
+ isFreemail,
+ cleanText,
+ normalizeEmail,
+ normalizeFirstName,
+ normalizeLastName,
+ normalizeDomain,
+ normalizeJobTitle,
+ normalizeLinkedin,
+ domainFromEmail,
+ splitFullName,
+} from "./normalize.js";
+export {
+ validateProfile,
+ parseProfile,
+ readProfileInput,
+ missingFields,
+ ProfileValidationError,
+} from "./validate.js";
+export { createProfileStore, migrate, schemaSql, adapt } from "./store.js";
+export { createProfileHandlers, mountProfileRoutes, saveSignupProfile } from "./handlers.js";
+export { renderProfileFields, PROFILE_ENHANCE_SCRIPT } from "./html.js";
+export { toCsv, csvHeader, csvRow, csvCell } from "./csv.js";
diff --git a/src/normalize.js b/src/normalize.js
new file mode 100644
index 0000000..ce7a05c
--- /dev/null
+++ b/src/normalize.js
@@ -0,0 +1,178 @@
+/**
+ * Normalizers. Each takes whatever a person typed and returns the canonical
+ * value, null for "left blank", or throws a ProfileFieldError with a message
+ * fit to show next to the box.
+ *
+ * Deliberately lenient about form and strict about meaning: "Acme.com/about",
+ * "https://www.acme.com" and "@acme.com" are all the domain acme.com, but a URL
+ * in a name box is spam and is refused. What is never done is "correcting" a
+ * person's name: McDonald, de la Cruz and O'Neil stay as typed.
+ */
+
+export class ProfileFieldError extends Error {
+ constructor(field, message) {
+ super(message);
+ this.name = "ProfileFieldError";
+ this.field = field;
+ }
+}
+
+const blank = (v) => v === undefined || v === null || String(v).trim() === "";
+
+/** Collapse runs of whitespace and drop control characters. */
+export function cleanText(v) {
+ return String(v)
+ .replace(/[\u0000-\u001f\u007f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+const looksLikeUrlOrEmail = (s) => /(https?:\/\/|www\.|@|\.(com|net|org|io|ru|xyz|top|info)\b)/i.test(s);
+
+// Addresses that are a person's own and say nothing about where they work.
+// Shared relay and temp-mail hosts are here too: their domain is nobody's company.
+export const FREEMAIL = new Set([
+ "gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "rocketmail.com",
+ "hotmail.com", "outlook.com", "live.com", "msn.com", "passport.com",
+ "icloud.com", "me.com", "mac.com", "aol.com", "aim.com",
+ "proton.me", "protonmail.com", "protonmail.ch", "pm.me",
+ "gmx.com", "gmx.net", "gmx.de", "web.de", "mail.com", "email.com",
+ "yandex.com", "yandex.ru", "ya.ru", "mail.ru", "inbox.ru", "list.ru", "bk.ru",
+ "qq.com", "163.com", "126.com", "sina.com", "foxmail.com",
+ "fastmail.com", "fastmail.fm", "hey.com", "tutanota.com", "tuta.io", "tutamail.com",
+ "duck.com", "passmail.net", "passmail.com", "aleeas.com", "simplelogin.com",
+ "anonaddy.me", "addy.io", "relay.firefox.com", "mozmail.com", "privaterelay.appleid.com",
+ "mailinator.com", "guerrillamail.com", "10minutemail.com", "temp-mail.org", "yopmail.com",
+ "hushmail.com", "zohomail.com", "rediffmail.com", "libero.it", "orange.fr", "free.fr",
+ "laposte.net", "btinternet.com", "comcast.net", "verizon.net", "att.net", "sbcglobal.net",
+ "cox.net", "charter.net", "earthlink.net", "t-online.de", "seznam.cz", "naver.com",
+ "daum.net", "hanmail.net", "uol.com.br", "bol.com.br", "terra.com.br",
+]);
+
+export function isFreemail(domain) {
+ if (!domain) return false;
+ const d = String(domain).toLowerCase();
+ if (FREEMAIL.has(d)) return true;
+ // yahoo.co.uk, hotmail.fr, outlook.de and friends.
+ return /^(yahoo|hotmail|outlook|live|gmx|yandex)\.[a-z.]+$/.test(d);
+}
+
+export function normalizeEmail(v) {
+ if (blank(v)) return null;
+ const s = String(v).trim().toLowerCase();
+ if (s.length > 254 || !/^[^\s@]+@[^\s@]+\.[a-z0-9-]{2,}$/i.test(s)) {
+ throw new ProfileFieldError("email", "Enter an email address like you@company.com.");
+ }
+ return s;
+}
+
+function personName(field, label) {
+ return (v) => {
+ if (blank(v)) return null;
+ const s = cleanText(v);
+ if (s.length > 100) throw new ProfileFieldError(field, `${label} is too long.`);
+ if (looksLikeUrlOrEmail(s)) throw new ProfileFieldError(field, `${label} cannot be a link or an email.`);
+ return s;
+ };
+}
+
+export const normalizeFirstName = personName("first_name", "First name");
+export const normalizeLastName = personName("last_name", "Last name");
+
+/**
+ * A company domain from a domain, a URL or an email address. Lower-case, no
+ * scheme, no www., no path, no port. IDNs pass through as typed (the browser
+ * gives us punycode or Unicode; both are the same company).
+ */
+export function normalizeDomain(v) {
+ if (blank(v)) return null;
+ let s = String(v).trim().toLowerCase();
+ if (s.includes("@")) s = s.slice(s.lastIndexOf("@") + 1);
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
+ s = s.split(/[/?#]/)[0].split(":")[0].replace(/\.$/, "");
+ s = s.replace(/^www\d*\./, "");
+ const label = "[a-z0-9\\u00a1-\\uffff](?:[a-z0-9\\u00a1-\\uffff-]{0,61}[a-z0-9\\u00a1-\\uffff])?";
+ const ok = new RegExp(`^(?:${label}\\.)+(?:[a-z\\u00a1-\\uffff]{2,63}|xn--[a-z0-9-]{1,59})$`);
+ if (s.length > 253 || !ok.test(s)) {
+ throw new ProfileFieldError("company_domain", "Enter your company's website, like company.com.");
+ }
+ return s;
+}
+
+/** The company domain an email implies, or null when it is a personal mailbox. */
+export function domainFromEmail(email) {
+ if (blank(email)) return null;
+ let domain;
+ try {
+ domain = normalizeDomain(String(email).split("@").pop());
+ } catch {
+ return null;
+ }
+ return isFreemail(domain) ? null : domain;
+}
+
+export function normalizeJobTitle(v) {
+ if (blank(v)) return null;
+ const s = cleanText(v);
+ if (s.length > 120) throw new ProfileFieldError("job_title", "Job title is too long.");
+ if (/(https?:\/\/|www\.)/i.test(s)) throw new ProfileFieldError("job_title", "Job title cannot be a link.");
+ return s;
+}
+
+/**
+ * The canonical public profile URL: https://www.linkedin.com/in/.
+ * Accepts the full URL with tracking junk, a country subdomain, a bare
+ * "linkedin.com/in/x", "in/x", or just the vanity name. Company pages are
+ * refused: this field is the person.
+ */
+export function normalizeLinkedin(v) {
+ if (blank(v)) return null;
+ const err = () =>
+ new ProfileFieldError("linkedin_url", "Enter your LinkedIn profile, like linkedin.com/in/yourname.");
+ let s = String(v).trim();
+ let slug;
+ const bare = s.replace(/^@/, "").replace(/^\/?in\//i, "").replace(/\/$/, "");
+ if (!/[.:]/.test(s) && /^[\p{L}\p{N}%_-]{3,100}$/u.test(bare)) {
+ slug = bare;
+ } else {
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`;
+ let url;
+ try {
+ url = new URL(s);
+ } catch {
+ throw err();
+ }
+ if (!/(^|\.)linkedin\.com$/i.test(url.hostname)) throw err();
+ const m = url.pathname.match(/^\/(?:in|pub)\/([^/]+)/i);
+ if (!m) throw err();
+ slug = m[1];
+ }
+ try {
+ slug = decodeURIComponent(slug);
+ } catch {
+ throw err();
+ }
+ slug = slug.toLowerCase();
+ if (!/^[\p{L}\p{N}_-]{3,100}$/u.test(slug)) throw err();
+ return `https://www.linkedin.com/in/${encodeURIComponent(slug)}`;
+}
+
+export const NORMALIZERS = Object.freeze({
+ email: normalizeEmail,
+ first_name: normalizeFirstName,
+ last_name: normalizeLastName,
+ company_domain: normalizeDomain,
+ job_title: normalizeJobTitle,
+ linkedin_url: normalizeLinkedin,
+});
+
+/**
+ * "Ada King Lovelace" -> { first_name: "Ada", last_name: "King Lovelace" }.
+ * For backfilling a table that only ever kept a full name. A single word is a
+ * first name; nothing is guessed about particles or order.
+ */
+export function splitFullName(full) {
+ if (blank(full)) return { first_name: null, last_name: null };
+ const parts = cleanText(full).split(" ");
+ return { first_name: parts[0], last_name: parts.length > 1 ? parts.slice(1).join(" ") : null };
+}
diff --git a/src/react.js b/src/react.js
new file mode 100644
index 0000000..7c2a241
--- /dev/null
+++ b/src/react.js
@@ -0,0 +1,82 @@
+"use client";
+/**
+ * for React signup forms. Written with createElement so the
+ * package needs no JSX build. Renders the same markup as renderProfileFields,
+ * so one stylesheet serves both.
+ *
+ * Uncontrolled by default (inputs post with the form, or read them with
+ * FormData); pass `values` and `onChange(values)` to control it.
+ */
+import { createElement as h, useRef } from "react";
+import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js";
+import { domainFromEmail } from "./normalize.js";
+
+export function ProfileFields({
+ values,
+ defaultValues = {},
+ onChange,
+ errors = {},
+ required = DEFAULT_REQUIRED,
+ fields = COLUMNS,
+ idPrefix = "profile-",
+ className = "pfs-profile",
+ labels = {},
+ disabled = false,
+}) {
+ const domainRef = useRef(null);
+ const controlled = values !== undefined && typeof onChange === "function";
+
+ const onEmailBlur = (e) => {
+ const derived = domainFromEmail(e.target.value);
+ if (!derived) return;
+ if (controlled) {
+ if (!values.company_domain) onChange({ ...values, company_domain: derived });
+ } else if (domainRef.current && !domainRef.current.value.trim()) {
+ domainRef.current.value = derived;
+ }
+ };
+
+ return h(
+ "fieldset",
+ { className, disabled },
+ fields.map((name) => {
+ const f = FIELD_BY_NAME[name];
+ const id = `${idPrefix}${name}`;
+ const req = required.includes(name);
+ const err = errors[name];
+ const input = {
+ id,
+ name,
+ type: f.type,
+ autoComplete: f.autocomplete,
+ inputMode: f.inputmode,
+ maxLength: f.maxLength,
+ placeholder: f.placeholder,
+ required: req,
+ "aria-invalid": err ? true : undefined,
+ "aria-describedby": err ? `${id}-error` : undefined,
+ ref: name === "company_domain" ? domainRef : undefined,
+ onBlur: name === "email" ? onEmailBlur : undefined,
+ };
+ if (name === "email") Object.assign(input, { spellCheck: false, autoCapitalize: "off" });
+ if (controlled) {
+ input.value = values[name] ?? "";
+ input.onChange = (e) => onChange({ ...values, [name]: e.target.value });
+ } else {
+ input.defaultValue = defaultValues[name] ?? undefined;
+ }
+ return h(
+ "div",
+ { key: name, className: `${className}__row`, "data-field": name },
+ h(
+ "label",
+ { htmlFor: id },
+ labels[name] ?? f.label,
+ req ? null : h("span", { className: `${className}__optional` }, " (optional)"),
+ ),
+ h("input", input),
+ err ? h("p", { id: `${id}-error`, className: `${className}__error`, role: "alert" }, err) : null,
+ );
+ }),
+ );
+}
diff --git a/src/store.js b/src/store.js
new file mode 100644
index 0000000..f10267c
--- /dev/null
+++ b/src/store.js
@@ -0,0 +1,301 @@
+/**
+ * Storage for the six profile fields, on whatever database the host already has.
+ *
+ * Two shapes are supported, because the fleet has both:
+ * - a table of its own (default `user_profiles`, keyed by `user_id`), or
+ * - the host's existing table (`profiles` keyed by `id`, or even `users`), to
+ * which `migrate()` adds only the columns that are missing.
+ *
+ * Like @profullstack/orgs there is no foreign key to a users table: the host's
+ * may be named anything or live elsewhere, so the key is a bare value with no
+ * constraint, and deleting a user is the host's job (call `remove()`).
+ *
+ * Drivers: postgres.js (`sql`), node-postgres (Pool/Client), @libsql/client
+ * (Turso, SQLite files), and supabase-js on the server. Anything else can pass
+ * `{ dialect: "postgres" | "sqlite", query(text, params) => rows }`.
+ */
+import { COLUMNS } from "./fields.js";
+
+const IDENT = /^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/i;
+const quote = (name) => name.split(".").map((p) => `"${p}"`).join(".");
+
+function checkIdent(name, what) {
+ if (!IDENT.test(name)) throw new Error(`profile: invalid ${what} name ${JSON.stringify(name)}`);
+ return name;
+}
+
+/** Wrap a driver in `{ dialect, query(text, params) -> rows }`; `?` placeholders. */
+export function adapt(db) {
+ if (!db) throw new Error("profile: a database client is required");
+ if (db.dialect && typeof db.query === "function") return db;
+ if (typeof db.from === "function" && db.auth) return { dialect: "supabase", client: db };
+ if (typeof db === "function" && typeof db.unsafe === "function") {
+ return { dialect: "postgres", query: (text, params = []) => db.unsafe(dollars(text), params) };
+ }
+ if (typeof db.execute === "function") {
+ return {
+ dialect: "sqlite",
+ query: async (text, params = []) => (await db.execute({ sql: text, args: params })).rows,
+ };
+ }
+ if (typeof db.query === "function") {
+ return { dialect: "postgres", query: async (text, params = []) => (await db.query(dollars(text), params)).rows };
+ }
+ throw new Error("profile: unsupported database client");
+}
+
+function dollars(text) {
+ let n = 0;
+ return text.replace(/\?/g, () => `$${++n}`);
+}
+
+/**
+ * The DDL for a fresh table, for hosts that keep migrations as files
+ * (Supabase, Drizzle). `migrate()` runs the same thing and also upgrades an
+ * existing table in place.
+ */
+export function schemaSql({ dialect = "postgres", table = "user_profiles", key = "user_id", keyType } = {}) {
+ checkIdent(table, "table");
+ checkIdent(key, "key");
+ const pg = dialect !== "sqlite";
+ const t = quote(table);
+ const idx = table.replace(/\./g, "_");
+ const ts = pg ? "timestamptz not null default now()" : "text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))";
+ return `create table if not exists ${t} (
+ "${key}" ${keyType ?? (pg ? "uuid" : "text")} primary key,
+${COLUMNS.map((c) => ` "${c}" text,`).join("\n")}
+ "created_at" ${ts},
+ "updated_at" ${ts}
+);
+create index if not exists "${idx}_email" on ${t} (lower("email"));
+create index if not exists "${idx}_company_domain" on ${t} ("company_domain");
+`;
+}
+
+async function existingColumns(db, table) {
+ if (db.dialect === "sqlite") {
+ const rows = await db.query(`select name from pragma_table_info(?)`, [table.split(".").pop()]);
+ return new Set(rows.map((r) => r.name));
+ }
+ const [schema, name] = table.includes(".") ? table.split(".") : [null, table];
+ const rows = await db.query(
+ `select column_name from information_schema.columns
+ where table_name = ? and table_schema = coalesce(?, current_schema())`,
+ [name, schema],
+ );
+ return new Set(rows.map((r) => r.column_name));
+}
+
+/**
+ * Create the table, or add whatever columns an existing one lacks. Safe to run
+ * on every boot and from two instances at once (Postgres takes an advisory lock
+ * for the duration).
+ */
+export async function migrate(dbOrStore, opts = {}) {
+ const store = dbOrStore?.__profileStore ? dbOrStore : createProfileStore(dbOrStore, opts);
+ const { db, table, key, keyType } = store;
+ if (db.dialect === "supabase") {
+ throw new Error(
+ "profile: supabase-js cannot run DDL. Add schemaSql() to a migration file instead:\n\n" +
+ schemaSql({ table, key, keyType }),
+ );
+ }
+ const pg = db.dialect === "postgres";
+ if (pg) await db.query(`select pg_advisory_lock(hashtext(?))`, [`@profullstack/profile:${table}`]);
+ try {
+ const have = await existingColumns(db, table);
+ if (have.size === 0) {
+ for (const stmt of schemaSql({ dialect: db.dialect, table, key, keyType }).split(";\n")) {
+ if (stmt.trim()) await db.query(stmt);
+ }
+ return { created: true, added: [] };
+ }
+ if (!have.has(key)) throw new Error(`profile: table ${table} has no key column ${key}`);
+ const added = [];
+ for (const col of [...COLUMNS, "created_at", "updated_at"]) {
+ if (have.has(col)) continue;
+ // SQLite cannot add a column whose default is not a constant, so the
+ // timestamps arrive empty and are filled on the next write.
+ const type = col.endsWith("_at") ? (pg ? "timestamptz default now()" : "text") : "text";
+ await db.query(`alter table ${quote(table)} add column ${pg ? "if not exists " : ""}"${col}" ${type}`);
+ added.push(col);
+ }
+ if (!have.has("email") || added.includes("email")) {
+ const idx = table.replace(/\./g, "_");
+ await db.query(`create index if not exists "${idx}_email" on ${quote(table)} (lower("email"))`);
+ }
+ return { created: false, added };
+ } finally {
+ if (pg) await db.query(`select pg_advisory_unlock(hashtext(?))`, [`@profullstack/profile:${table}`]);
+ }
+}
+
+/** Only the contract's fields, from whatever object was passed. */
+function pick(profile) {
+ const out = {};
+ for (const c of COLUMNS) if (profile && c in profile && profile[c] !== undefined) out[c] = profile[c] ?? null;
+ return out;
+}
+
+function toProfile(row, key) {
+ if (!row) return null;
+ const out = { user_id: row[key] == null ? null : String(row[key]) };
+ for (const c of COLUMNS) out[c] = row[c] ?? null;
+ for (const c of ["created_at", "updated_at"]) {
+ const v = row[c];
+ out[c] = v == null ? null : v instanceof Date ? v.toISOString() : String(v);
+ }
+ return out;
+}
+
+/**
+ * Create a store. Options: `table` (default "user_profiles"), `key` (default
+ * "user_id"; "id" for a Supabase-style `profiles` table), `keyType` (DDL only;
+ * default uuid on Postgres, text on SQLite).
+ */
+export function createProfileStore(dbClient, { table = "user_profiles", key = "user_id", keyType } = {}) {
+ checkIdent(table, "table");
+ checkIdent(key, "key");
+ const db = adapt(dbClient);
+ if (db.dialect === "supabase") return supabaseStore(db.client, { table, key, keyType, db });
+
+ const t = quote(table);
+ const k = `"${key}"`;
+ const now = () => new Date().toISOString();
+ const cast = db.dialect === "postgres" ? "::timestamptz" : "";
+ const cols = [key, ...COLUMNS, "created_at", "updated_at"].map((c) => `"${c}"`).join(", ");
+
+ async function write(userId, profile, mode) {
+ if (userId == null || userId === "") throw new Error("profile: a user id is required");
+ const fields = pick(profile);
+ const names = Object.keys(fields);
+ const stamp = now();
+ const insertCols = [k, ...names.map((n) => `"${n}"`), `"created_at"`, `"updated_at"`];
+ const values = [String(userId), ...names.map((n) => fields[n]), stamp, stamp];
+ const placeholders = insertCols.map((c) => (c.endsWith(`_at"`) ? `?${cast}` : "?"));
+ const set = names.map((n) =>
+ mode === "fill" ? `"${n}" = coalesce(p."${n}", excluded."${n}")` : `"${n}" = excluded."${n}"`,
+ );
+ set.push(`"updated_at" = excluded."updated_at"`);
+ const rows = await db.query(
+ `insert into ${t} as p (${insertCols.join(", ")}) values (${placeholders.join(", ")})
+ on conflict (${k}) do update set ${set.join(", ")}
+ returning ${cols}`,
+ values,
+ );
+ return toProfile(rows[0], key);
+ }
+
+ const self = {
+ __profileStore: true,
+ db,
+ table,
+ key,
+ keyType,
+ migrate: () => migrate(self),
+
+ async get(userId) {
+ const rows = await db.query(`select ${cols} from ${t} where ${k} = ?`, [String(userId)]);
+ return toProfile(rows[0], key);
+ },
+
+ async getByEmail(email) {
+ const rows = await db.query(`select ${cols} from ${t} where lower("email") = lower(?) limit 1`, [
+ String(email),
+ ]);
+ return toProfile(rows[0], key);
+ },
+
+ /** Write the fields present in `profile`; absent ones are left alone, null clears. */
+ save: (userId, profile) => write(userId, profile, "set"),
+
+ /** Write only into fields that are empty. For backfills and enrichment. */
+ fillBlanks: (userId, profile) => write(userId, profile, "fill"),
+
+ async remove(userId) {
+ await db.query(`delete from ${t} where ${k} = ?`, [String(userId)]);
+ },
+
+ /**
+ * A page of profiles in key order. `after` is the previous page's `next`;
+ * `since` limits to rows updated at or after an ISO time.
+ */
+ async list({ limit = 500, after = null, since = null } = {}) {
+ const where = [];
+ const params = [];
+ if (after != null) (where.push(`${k} > ?`), params.push(String(after)));
+ if (since) (where.push(`"updated_at" >= ?${cast}`), params.push(since));
+ params.push(Math.min(Math.max(1, Number(limit) || 500), 5000));
+ const rows = await db.query(
+ `select ${cols} from ${t} ${where.length ? `where ${where.join(" and ")}` : ""}
+ order by ${k} limit ?`,
+ params,
+ );
+ const profiles = rows.map((r) => toProfile(r, key));
+ return { profiles, next: profiles.length === params.at(-1) ? profiles.at(-1).user_id : null };
+ },
+
+ async *all(opts = {}) {
+ let after = null;
+ do {
+ const page = await self.list({ ...opts, after });
+ yield* page.profiles;
+ after = page.next;
+ } while (after != null);
+ },
+ };
+ return self;
+}
+
+function supabaseStore(client, { table, key, keyType, db }) {
+ const sel = [key, ...COLUMNS, "created_at", "updated_at"].join(",");
+ const from = () => client.from(table);
+ const must = ({ data, error }) => {
+ if (error) throw new Error(`profile: ${error.message}`);
+ return data;
+ };
+ const self = {
+ __profileStore: true,
+ db,
+ table,
+ key,
+ keyType,
+ migrate: () => migrate(self),
+ async get(userId) {
+ return toProfile(must(await from().select(sel).eq(key, String(userId)).maybeSingle()), key);
+ },
+ async getByEmail(email) {
+ return toProfile(must(await from().select(sel).ilike("email", String(email)).limit(1).maybeSingle()), key);
+ },
+ async save(userId, profile) {
+ const row = { [key]: String(userId), ...pick(profile), updated_at: new Date().toISOString() };
+ return toProfile(must(await from().upsert(row, { onConflict: key }).select(sel).single()), key);
+ },
+ async fillBlanks(userId, profile) {
+ const current = (await self.get(userId)) ?? {};
+ const fields = pick(profile);
+ for (const c of Object.keys(fields)) if (current[c]) delete fields[c];
+ return self.save(userId, fields);
+ },
+ async remove(userId) {
+ must(await from().delete().eq(key, String(userId)));
+ },
+ async list({ limit = 500, after = null, since = null } = {}) {
+ const n = Math.min(Math.max(1, Number(limit) || 500), 5000);
+ let q = from().select(sel).order(key).limit(n);
+ if (after != null) q = q.gt(key, String(after));
+ if (since) q = q.gte("updated_at", since);
+ const profiles = must(await q).map((r) => toProfile(r, key));
+ return { profiles, next: profiles.length === n ? profiles.at(-1).user_id : null };
+ },
+ async *all(opts = {}) {
+ let after = null;
+ do {
+ const page = await self.list({ ...opts, after });
+ yield* page.profiles;
+ after = page.next;
+ } while (after != null);
+ },
+ };
+ return self;
+}
diff --git a/src/validate.js b/src/validate.js
new file mode 100644
index 0000000..4a8cca5
--- /dev/null
+++ b/src/validate.js
@@ -0,0 +1,89 @@
+import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js";
+import { NORMALIZERS, ProfileFieldError, domainFromEmail } from "./normalize.js";
+
+// Names apps and libraries already send, so a form need not be rewritten
+// before it can be validated here.
+const ALIASES = {
+ firstName: "first_name", given_name: "first_name", givenName: "first_name",
+ lastName: "last_name", family_name: "last_name", familyName: "last_name", surname: "last_name",
+ companyDomain: "company_domain", company: "company_domain", company_website: "company_domain",
+ website: "company_domain", domain: "company_domain",
+ jobTitle: "job_title", title: "job_title", position: "job_title",
+ linkedinUrl: "linkedin_url", linkedin: "linkedin_url", linkedIn: "linkedin_url",
+};
+
+/** Read fields from FormData, URLSearchParams, a Map or a plain object. */
+export function readProfileInput(input) {
+ const out = {};
+ if (!input) return out;
+ const entries =
+ typeof input.entries === "function" && !(input.constructor === Object)
+ ? [...input.entries()]
+ : Object.entries(input);
+ for (const [rawKey, value] of entries) {
+ const key = FIELD_BY_NAME[rawKey] ? rawKey : ALIASES[rawKey];
+ // A canonical key wins over an alias for the same field.
+ if (!key || (key in out && !FIELD_BY_NAME[rawKey])) continue;
+ out[key] = typeof value === "string" || value === null ? value : value?.toString?.() ?? null;
+ }
+ return out;
+}
+
+/**
+ * Validate and normalize a profile.
+ *
+ * - `required`: field names that must be present (default email, first and
+ * last name). Pass `LEAD_REQUIRED` on a B2B signup.
+ * - `partial`: an update. Only fields present in the input are checked and
+ * returned, `required` applies only to those, and an explicit empty value
+ * clears the field (returned as null).
+ * - `deriveDomain` (default true): a blank company domain is filled from a work
+ * email. Personal mailboxes (gmail, proton, relays) never fill it.
+ *
+ * Returns `{ ok, profile, errors }`; `errors` maps a field to a message.
+ */
+export function validateProfile(input, { required = DEFAULT_REQUIRED, partial = false, deriveDomain = true } = {}) {
+ const raw = readProfileInput(input);
+ const profile = {};
+ const errors = {};
+ for (const name of COLUMNS) {
+ if (partial && !(name in raw)) continue;
+ try {
+ profile[name] = NORMALIZERS[name](raw[name]);
+ } catch (e) {
+ if (!(e instanceof ProfileFieldError)) throw e;
+ errors[name] = e.message;
+ }
+ }
+ // Not on an update: there the stored domain may be one the person typed, and
+ // changing an email address is no reason to overwrite it.
+ if (deriveDomain && !partial && !errors.company_domain && !profile.company_domain && profile.email) {
+ profile.company_domain = domainFromEmail(profile.email);
+ }
+ for (const name of required) {
+ if (partial && !(name in raw)) continue;
+ if (!errors[name] && !profile[name]) errors[name] = `${FIELD_BY_NAME[name]?.label ?? name} is required.`;
+ }
+ return { ok: Object.keys(errors).length === 0, profile, errors };
+}
+
+/** Like validateProfile, but throws a ProfileValidationError carrying `errors`. */
+export function parseProfile(input, options) {
+ const result = validateProfile(input, options);
+ if (!result.ok) throw new ProfileValidationError(result.errors);
+ return result.profile;
+}
+
+export class ProfileValidationError extends Error {
+ constructor(errors) {
+ super(Object.values(errors)[0] ?? "Invalid profile.");
+ this.name = "ProfileValidationError";
+ this.status = 400;
+ this.errors = errors;
+ }
+}
+
+/** Which of `fields` a stored profile is still missing. */
+export function missingFields(profile, fields = DEFAULT_REQUIRED) {
+ return fields.filter((f) => !profile?.[f]);
+}
diff --git a/test/normalize.test.js b/test/normalize.test.js
new file mode 100644
index 0000000..7e56501
--- /dev/null
+++ b/test/normalize.test.js
@@ -0,0 +1,173 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ COLUMNS,
+ LEAD_REQUIRED,
+ normalizeDomain,
+ normalizeLinkedin,
+ normalizeEmail,
+ normalizeFirstName,
+ normalizeJobTitle,
+ domainFromEmail,
+ splitFullName,
+ validateProfile,
+ parseProfile,
+ ProfileValidationError,
+ missingFields,
+ renderProfileFields,
+ toCsv,
+} from "../src/index.js";
+
+describe("the contract", () => {
+ test("columns are the import order sales tools expect", () => {
+ assert.deepEqual(COLUMNS, ["email", "first_name", "last_name", "company_domain", "job_title", "linkedin_url"]);
+ });
+});
+
+describe("company domain", () => {
+ for (const [input, want] of [
+ ["acme.com", "acme.com"],
+ ["https://www.Acme.com/about?x=1", "acme.com"],
+ ["http://acme.co.uk:8080", "acme.co.uk"],
+ ["@acme.io", "acme.io"],
+ ["ada@sub.acme.dev", "sub.acme.dev"],
+ ["WWW2.acme.com.", "acme.com"],
+ ["münchen.de", "münchen.de"],
+ ]) {
+ test(`${input} -> ${want}`, () => assert.equal(normalizeDomain(input), want));
+ }
+ for (const bad of ["acme", "not a domain.com", "http://", "a..com", "-acme.com"]) {
+ test(`refuses ${JSON.stringify(bad)}`, () => assert.throws(() => normalizeDomain(bad), /company/));
+ }
+ test("blank is null, not an error", () => assert.equal(normalizeDomain(" "), null));
+});
+
+describe("domain from email", () => {
+ test("a work address gives its company", () => assert.equal(domainFromEmail("Ada@Acme.com"), "acme.com"));
+ test("personal and relay mailboxes give nothing", () => {
+ for (const e of ["a@gmail.com", "a@proton.me", "a@yahoo.co.uk", "a@passmail.net", "a@hotmail.fr", "a@icloud.com"]) {
+ assert.equal(domainFromEmail(e), null, e);
+ }
+ });
+});
+
+describe("linkedin", () => {
+ const want = "https://www.linkedin.com/in/ada-lovelace";
+ for (const input of [
+ "https://www.linkedin.com/in/ada-lovelace/",
+ "linkedin.com/in/Ada-Lovelace?trk=public_profile",
+ "https://uk.linkedin.com/in/ada-lovelace",
+ "in/ada-lovelace",
+ "ada-lovelace",
+ "@ada-lovelace",
+ "http://linkedin.com/in/ada-lovelace/details/experience/",
+ ]) {
+ test(`${input}`, () => assert.equal(normalizeLinkedin(input), want));
+ }
+ test("unicode vanity names survive", () =>
+ assert.equal(normalizeLinkedin("linkedin.com/in/jos%C3%A9-p"), "https://www.linkedin.com/in/jos%C3%A9-p"));
+ for (const bad of ["https://linkedin.com/company/acme", "https://evil.com/in/ada", "linkedin.com", "a b"]) {
+ test(`refuses ${bad}`, () => assert.throws(() => normalizeLinkedin(bad), /LinkedIn/));
+ }
+});
+
+describe("names, titles, email", () => {
+ test("names keep their case and lose extra space", () => {
+ assert.equal(normalizeFirstName(" Mary Ann "), "Mary Ann");
+ assert.equal(normalizeFirstName("McDonald"), "McDonald");
+ assert.equal(normalizeFirstName("de la Cruz"), "de la Cruz");
+ });
+ test("a link in a name is spam", () => {
+ assert.throws(() => normalizeFirstName("Win $$$ at http://x.ru"));
+ assert.throws(() => normalizeFirstName("bob@spam.com"));
+ });
+ test("titles", () => {
+ assert.equal(normalizeJobTitle(" VP,\tEngineering "), "VP, Engineering");
+ assert.throws(() => normalizeJobTitle("see www.x.com"));
+ });
+ test("email is lower-cased and checked", () => {
+ assert.equal(normalizeEmail(" Ada@Acme.COM "), "ada@acme.com");
+ assert.throws(() => normalizeEmail("ada@acme"));
+ });
+ test("splitFullName", () => {
+ assert.deepEqual(splitFullName("Ada King Lovelace"), { first_name: "Ada", last_name: "King Lovelace" });
+ assert.deepEqual(splitFullName("Cher"), { first_name: "Cher", last_name: null });
+ });
+});
+
+describe("validateProfile", () => {
+ test("a signup with a work email gets its company filled in", () => {
+ const r = validateProfile({ email: "ada@acme.com", firstName: "Ada", last_name: "Lovelace" });
+ assert.ok(r.ok);
+ assert.equal(r.profile.company_domain, "acme.com");
+ assert.equal(r.profile.job_title, null);
+ });
+ test("a typed company beats the email's", () => {
+ const r = validateProfile({ email: "ada@gmail.com", first_name: "A", last_name: "L", company_domain: "https://acme.com" });
+ assert.equal(r.profile.company_domain, "acme.com");
+ });
+ test("required fields are reported per field", () => {
+ const r = validateProfile({ email: "x" });
+ assert.equal(r.ok, false);
+ assert.match(r.errors.email, /email/);
+ assert.match(r.errors.first_name, /required/);
+ assert.match(r.errors.last_name, /required/);
+ });
+ test("LEAD_REQUIRED asks for title and company", () => {
+ const r = validateProfile({ email: "a@gmail.com", first_name: "A", last_name: "B" }, { required: LEAD_REQUIRED });
+ assert.deepEqual(Object.keys(r.errors).sort(), ["company_domain", "job_title"]);
+ });
+ test("partial only touches what was sent, and blank clears", () => {
+ const r = validateProfile({ job_title: "CTO", linkedin_url: "" }, { partial: true });
+ assert.deepEqual(r.profile, { job_title: "CTO", linkedin_url: null });
+ });
+ test("partial never derives a domain over a stored one", () => {
+ const r = validateProfile({ email: "a@acme.com" }, { partial: true });
+ assert.deepEqual(r.profile, { email: "a@acme.com" });
+ });
+ test("FormData works", () => {
+ const fd = new FormData();
+ fd.set("email", "a@acme.com");
+ fd.set("first_name", "A");
+ fd.set("last_name", "B");
+ fd.set("linkedin", "in/abc");
+ assert.equal(validateProfile(fd).profile.linkedin_url, "https://www.linkedin.com/in/abc");
+ });
+ test("parseProfile throws with the errors attached", () => {
+ try {
+ parseProfile({});
+ assert.fail("should throw");
+ } catch (e) {
+ assert.ok(e instanceof ProfileValidationError);
+ assert.equal(e.status, 400);
+ assert.ok(e.errors.email);
+ }
+ });
+ test("missingFields", () => {
+ assert.deepEqual(missingFields({ email: "a@b.co", first_name: "A" }, LEAD_REQUIRED), [
+ "last_name",
+ "company_domain",
+ "job_title",
+ ]);
+ });
+});
+
+describe("rendering", () => {
+ test("html fields carry names, autocomplete and errors, escaped", () => {
+ const html = renderProfileFields({ values: { first_name: '">