@wtfalch/people
v0.2.3
Published
The estate's directory of people: profiles, org membership and roster reads, on top of @wtfalch/authz-store.
Readme
@wtfalch/people
The estate's directory of people: profiles, the member list of an
organisation, and grant/remove membership — including the guard that stops
the last independent owner from being removed. It sits on top of
@wtfalch/authz-store, which keeps the
authority itself (tenants, memberships, credentials).
Status
v2, on npm at 0.2.0. Depends on @wtfalch/authz-store 0.2.0, on npm
since 2026-09-23.
Install
pnpm add @wtfalch/people
pnpm exec people-migrations # copies migrations/*.sql into drizzle/ as the next numbersThe copy is recorded in drizzle/.people-migrations.json; running it again
copies nothing. Apply the copied file with the host's own migrate script.
Use
Apply the migration — either the file
people-migrationscopied intodrizzle/, or, for a quick local setup with no host migrate script,migrate()against any drizzleDbOrTx:import { migrate } from '@wtfalch/people'; await migrate(db); // idempotentThis applies only the
profilestable.@wtfalch/authz-store's own tables (memberships,tenants,credentials, …) are that package's to migrate, via its exportedmigrateStore(db)— run it first, or in either order: there is no foreign key between the two baselines.A profile, created on first sign-in and refreshed on every later one:
import { ensureProfile, setDisplayName, DISPLAY_NAME_MAX } from '@wtfalch/people'; const profile = await ensureProfile(db, { id: user.id, name: user.name, email: user.email }); await setDisplayName(db, user.id, 'Amy'); // only the signed-in person's own idAn organisation's roster:
import { membersOf } from '@wtfalch/people'; const members = await membersOf(db, tenantId); // [{ principalId, principalClass, source, viaTenantName, joinedAt, lastSeenAt, display, email, role, roleLabel }]displayfalls back through profile name → email → credential name → a class-specific label ("An agent no longer on record"), so a non-human member (an agent, a service, an API key) never shows as a raw id — carried forward from manage's roster, generalised for every host.role/roleLabelarenullunless a third argument, the samePolicyBindingguardStaysHeldtakes, resolves them from each principal's current primary assignment (ADR 0014):const members = await membersOf(db, tenantId, { applicationId, platformId }); // role/roleLabel now hold e.g. { role: 'owner', roleLabel: 'Owner' }Grant, remove, change role, and guard a membership:
import { grantMembership, removeMembership, changeRole, guardStaysHeld, } from '@wtfalch/people'; const granted = await grantMembership(db, { tenantId, tenantName, principal: { id, class: 'human' }, // Optional: runs after the row insert succeeds, before the audit // write, on this same `db` — attach your own role assignment, // tenant-tree propagation and participation-policy write here without // this package importing any of it. onGranted: async () => { await writePrimaryAssignment(db, role, principal, { createdBy: actor.id }); await writeParticipationPolicy(db, tenantId, principal); }, }); // { ok: true } | { ok: false, reason: 'already_member' } // Before removing or demoting a principal that might hold a guarded role, // check independent coverage stays — `guards` is the union of whatever // roles are being revoked's own `.guards` (the host's own role data): const held = await guardStaysHeld(db, { applicationId, platformId }, tenantId, principal, guards); if (!held) throw new Error('appoint another independent owner first'); // roleId is a role your own @wtfalch/authz layer already resolved and // refusal-checked — this package only writes the row. const changed = await changeRole(db, { tenantId, principal, binding: { applicationId, platformId }, roleId, }); // { ok: true } | { ok: false, reason: 'not_member' } const removed = await removeMembership(db, { tenantId, principal, onRemoved: async () => { await endSessionsFor(db, principal.id, '...'); }, }); // { ok: true } | { ok: false, reason: 'not_found' }grantMembership/removeMembership/changeRoleare still narrower than what each host writes today: role compilation, boundary refusal checks, self-promotion, break-glass and every consequence a host attaches to a role change stay in each host's own authz layer.onGranted/onRemovedlet a host attach its own row writes to the same transaction instead of sequencing them separately — see docs/adr/0014.Pass an
AuditOptions(a bound@wtfalch/auditAuditWriterplus the actingActor) as the third argument to any of the three functions above to write the audit row in the same transaction as the membership change:await grantMembership(db, input, { writer: auditWriter, actor });GDPR hooks, scoped to this package's own tables:
import { erasePerson, exportPerson } from '@wtfalch/people'; const { pseudonym } = await erasePerson(db, personId); // scrubs displayName/email const record = await exportPerson(db, personId); // { profile, memberships }Call these inside the host's own transaction when composing a larger erasure or export sweep across services — neither function opens its own transaction.
Authorization, checked before every read or write above (this package's store functions do not check it themselves — the same separation
package-template's widget toy keeps):import { catalogue, checkPeopleRead, checkPeopleManage } from '@wtfalch/people'; // checkPeopleManage checks the `people:members` permission — not // `people:manage`: @wtfalch/authz's permission-id schema refuses that // exact action name. import { resourceAccess } from '@wtfalch/authz'; const access = resourceAccess({ catalogue, principal, /* ...organisations, grants */ }); const result = checkPeopleRead(access, { id, type: 'member', applicationId, platformId, organisationId, teamId: null }); if (!result.allowed) throw new Error(result.reason);
Tests
pnpm test # PGlite, in memory
TEST_DATABASE_URL=postgres://... pnpm test # a real Postgres; a scratch schema per runTest fixtures run both @wtfalch/authz-store's migrateStore and this
package's own migrate, so roster/membership tests exercise real joins
against authz-store's tables, not a mock.
