Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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)"

34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
*.db
154 changes: 154 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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";

<form action={signup}>
<ProfileFields />
<button>Send me a sign-in link</button>
</form>
```

```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(`<form method="post" action="/signup">
${renderProfileFields()}
<button>Sign up</button>
</form><script>${PROFILE_ENHANCE_SCRIPT}</script>`),
);
```

### 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 <user-id>
profile find ada@acme.com
profile set <user-id> --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
Loading
Loading