@wtfalch/people 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @wtfalch/people
2
+
3
+ The estate's directory of people: profiles, the member list of an
4
+ organisation, and grant/remove membership — including the guard that stops
5
+ the last independent owner from being removed. It sits on top of
6
+ [`@wtfalch/authz-store`](https://github.com/wtfalch/authz), which keeps the
7
+ authority itself (tenants, memberships, credentials).
8
+
9
+ ## Status
10
+
11
+ v1. Not published yet: that needs the user's npm 2FA. It depends on
12
+ `@wtfalch/authz-store` 0.1.0, which is on npm since 2026-09-22.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pnpm add @wtfalch/people
18
+ pnpm exec people-migrations # copies migrations/*.sql into drizzle/ as the next numbers
19
+ ```
20
+
21
+ The copy is recorded in `drizzle/.people-migrations.json`; running it again
22
+ copies nothing. Apply the copied file with the host's own migrate script.
23
+
24
+ ## Use
25
+
26
+ 1. Apply the migration — either the file `people-migrations` copied into
27
+ `drizzle/`, or, for a quick local setup with no host migrate script,
28
+ `migrate()` against any drizzle `DbOrTx`:
29
+
30
+ ```ts
31
+ import { migrate } from '@wtfalch/people';
32
+
33
+ await migrate(db); // idempotent
34
+ ```
35
+
36
+ This applies only the `profiles` table. `@wtfalch/authz-store`'s own
37
+ tables (`memberships`, `tenants`, `credentials`, …) are that package's to
38
+ migrate, via its exported `migrateStore(db)` — run it first, or in either
39
+ order: there is no foreign key between the two baselines.
40
+
41
+ 2. A profile, created on first sign-in and refreshed on every later one:
42
+
43
+ ```ts
44
+ import { ensureProfile, setDisplayName, DISPLAY_NAME_MAX } from '@wtfalch/people';
45
+
46
+ const profile = await ensureProfile(db, { id: user.id, name: user.name, email: user.email });
47
+ await setDisplayName(db, user.id, 'Amy'); // only the signed-in person's own id
48
+ ```
49
+
50
+ 3. An organisation's roster:
51
+
52
+ ```ts
53
+ import { membersOf } from '@wtfalch/people';
54
+
55
+ const members = await membersOf(db, tenantId);
56
+ // [{ principalId, principalClass, source, viaTenantName, joinedAt, lastSeenAt, display, email }]
57
+ ```
58
+
59
+ `display` falls back through profile name → email → credential name → a
60
+ class-specific label ("An agent no longer on record"), so a non-human
61
+ member (an agent, a service, an API key) never shows as a raw id — carried
62
+ forward from manage's roster, generalised for every host.
63
+
64
+ `role`/`roleLabel` are not part of this row. Role resolution is
65
+ `@wtfalch/authz`'s policy layer (a correlated subquery over
66
+ `authz_assignments`), not exposed by `authz-store` yet and out of this
67
+ package's scope — see the root README.
68
+
69
+ 4. Grant, remove and guard a membership:
70
+
71
+ ```ts
72
+ import { grantMembership, removeMembership, guardStaysHeld } from '@wtfalch/people';
73
+
74
+ const granted = await grantMembership(db, {
75
+ tenantId, tenantName, principal: { id, class: 'human' },
76
+ });
77
+ // { ok: true } | { ok: false, reason: 'already_member' }
78
+
79
+ // Before removing or demoting a principal that might hold a guarded role,
80
+ // check independent coverage stays — `guards` is the union of whatever
81
+ // roles are being revoked's own `.guards` (the host's own role data):
82
+ const held = await guardStaysHeld(db, { applicationId, platformId }, tenantId, principal, guards);
83
+ if (!held) throw new Error('appoint another independent owner first');
84
+
85
+ const removed = await removeMembership(db, { tenantId, principal });
86
+ // { ok: true } | { ok: false, reason: 'not_found' }
87
+ ```
88
+
89
+ This is narrower than the `grantMembership`/`changeRole`/`removeMembership`
90
+ every host writes today: role assignment, boundary refusal checks,
91
+ tenant-tree propagation and participation-policy writes stay in each
92
+ host's own authz layer. See the root README's *Out of scope*.
93
+
94
+ Pass an `AuditOptions` (a bound `@wtfalch/audit` `AuditWriter` plus the
95
+ acting `Actor`) as the third argument to either function to write the
96
+ audit row in the same transaction as the membership change:
97
+
98
+ ```ts
99
+ await grantMembership(db, input, { writer: auditWriter, actor });
100
+ ```
101
+
102
+ 5. GDPR hooks, scoped to this package's own tables:
103
+
104
+ ```ts
105
+ import { erasePerson, exportPerson } from '@wtfalch/people';
106
+
107
+ const { pseudonym } = await erasePerson(db, personId); // scrubs displayName/email
108
+ const record = await exportPerson(db, personId); // { profile, memberships }
109
+ ```
110
+
111
+ Call these inside the host's own transaction when composing a larger
112
+ erasure or export sweep across services — neither function opens its own
113
+ transaction.
114
+
115
+ 6. Authorization, checked before every read or write above (this package's
116
+ store functions do not check it themselves — the same separation
117
+ `package-template`'s widget toy keeps):
118
+
119
+ ```ts
120
+ import { catalogue, checkPeopleRead, checkPeopleManage } from '@wtfalch/people';
121
+ // checkPeopleManage checks the `people:members` permission — not
122
+ // `people:manage`: @wtfalch/authz's permission-id schema refuses that
123
+ // exact action name.
124
+ import { resourceAccess } from '@wtfalch/authz';
125
+
126
+ const access = resourceAccess({ catalogue, principal, /* ...organisations, grants */ });
127
+ const result = checkPeopleRead(access, { id, type: 'member', applicationId, platformId, organisationId, teamId: null });
128
+ if (!result.allowed) throw new Error(result.reason);
129
+ ```
130
+
131
+ ## Tests
132
+
133
+ ```sh
134
+ pnpm test # PGlite, in memory
135
+ TEST_DATABASE_URL=postgres://... pnpm test # a real Postgres; a scratch schema per run
136
+ ```
137
+
138
+ Test fixtures run both `@wtfalch/authz-store`'s `migrateStore` and this
139
+ package's own `migrate`, so roster/membership tests exercise real joins
140
+ against authz-store's tables, not a mock.
@@ -0,0 +1,18 @@
1
+ import type { AccessResource, ResourceAccess, ResourceDenial } from '@wtfalch/authz';
2
+ /**
3
+ * One member row or profile, in the shape `@wtfalch/authz` needs to
4
+ * evaluate a resource-scoped permission against it.
5
+ */
6
+ export interface MemberResource extends AccessResource {
7
+ readonly type: 'member';
8
+ }
9
+ export type PeopleCheckResult = {
10
+ readonly allowed: true;
11
+ } | {
12
+ readonly allowed: false;
13
+ readonly reason: ResourceDenial;
14
+ };
15
+ /** Whether `access` may read the roster or a profile in `resource`'s organisation. */
16
+ export declare function checkPeopleRead(access: ResourceAccess<'people:read'>, resource: MemberResource): PeopleCheckResult;
17
+ /** Whether `access` may grant, remove or erase membership in `resource`'s organisation. */
18
+ export declare function checkPeopleManage(access: ResourceAccess<'people:members'>, resource: MemberResource): PeopleCheckResult;
@@ -0,0 +1,12 @@
1
+ /** Whether `access` may read the roster or a profile in `resource`'s organisation. */
2
+ export function checkPeopleRead(access, resource) {
3
+ if (access.allows('people:read', resource))
4
+ return { allowed: true };
5
+ return { allowed: false, reason: access.whyDenied('people:read', resource) ?? 'unknown' };
6
+ }
7
+ /** Whether `access` may grant, remove or erase membership in `resource`'s organisation. */
8
+ export function checkPeopleManage(access, resource) {
9
+ if (access.allows('people:members', resource))
10
+ return { allowed: true };
11
+ return { allowed: false, reason: access.whyDenied('people:members', resource) ?? 'unknown' };
12
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /**
6
+ * `people-migrations [dir]`: copy this package's migrations the app has not
7
+ * yet copied into `dir` (default `drizzle`) as the next numbers. Idempotent;
8
+ * run it after every upgrade of @wtfalch/people, then commit what it wrote.
9
+ * Apply the copied file with the host's own migrate script.
10
+ */
11
+ const MANIFEST = '.people-migrations.json';
12
+ const NUMBERED = /^(\d{4})_(.+\.sql)$/;
13
+ function readManifest(dir) {
14
+ const file = join(dir, MANIFEST);
15
+ if (!existsSync(file))
16
+ return { copied: {} };
17
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
18
+ return { copied: parsed.copied ?? {} };
19
+ }
20
+ function nextNumber(dir) {
21
+ let max = -1;
22
+ for (const name of readdirSync(dir)) {
23
+ const match = NUMBERED.exec(name);
24
+ if (match?.[1])
25
+ max = Math.max(max, Number(match[1]));
26
+ }
27
+ return max + 1;
28
+ }
29
+ function copyMigrations(opts) {
30
+ mkdirSync(opts.to, { recursive: true });
31
+ const manifest = readManifest(opts.to);
32
+ const copied = [];
33
+ const sources = readdirSync(opts.from)
34
+ .filter((name) => NUMBERED.test(name))
35
+ .sort();
36
+ let next = nextNumber(opts.to);
37
+ for (const name of sources) {
38
+ if (manifest.copied[name])
39
+ continue;
40
+ const rest = NUMBERED.exec(name)?.[2] ?? name;
41
+ const target = `${String(next).padStart(4, '0')}_${rest}`;
42
+ const body = readFileSync(join(opts.from, name), 'utf8');
43
+ const header = `-- Copied from @wtfalch/people${opts.version ? ` ${opts.version}` : ''} (migrations/${name}) by people-migrations.\n-- Do not edit here; the next package version ships the next file.\n\n`;
44
+ writeFileSync(join(opts.to, target), header + body);
45
+ manifest.copied[name] = target;
46
+ copied.push({ from: name, to: target });
47
+ next += 1;
48
+ }
49
+ writeFileSync(join(opts.to, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
50
+ return { copied };
51
+ }
52
+ function describeCopy(result) {
53
+ if (result.copied.length === 0)
54
+ return 'people-migrations: nothing to copy';
55
+ return result.copied.map((c) => `people-migrations: ${c.from} -> ${basename(c.to)}`).join('\n');
56
+ }
57
+ const here = dirname(fileURLToPath(import.meta.url));
58
+ const from = join(here, '..', 'migrations');
59
+ const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
60
+ const { version } = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
61
+ console.log(describeCopy(copyMigrations({ from, to, version })));
@@ -0,0 +1,68 @@
1
+ /**
2
+ * This package's authorization vocabulary (ADR 0005 of `package-template`:
3
+ * `@wtfalch/authz` is scaffolded, not optional). Two permissions: reading the
4
+ * roster and a profile, and changing membership (grant/remove) or erasing a
5
+ * person — the second is `sensitive` and refused in a support session
6
+ * (`support: 'never'`), the same refusal manage, app-template and operator
7
+ * all hard-code today for `grantMembership`/`changeRole`/`removeMembership`.
8
+ */
9
+ export declare const resourceModule: {
10
+ readonly namespace: "people";
11
+ readonly permissions: readonly [{
12
+ readonly id: "people:read";
13
+ readonly label: "Read people";
14
+ readonly description: "Read an organisation's member list and profiles.";
15
+ readonly resourceType: "member";
16
+ readonly effect: "read";
17
+ readonly scopes: readonly ["organisation"];
18
+ readonly boundaries: readonly ["organisation"];
19
+ readonly relations: readonly ["any"];
20
+ readonly tenantKinds: readonly ["customer", "operator"];
21
+ readonly offered: false;
22
+ readonly assignable: true;
23
+ readonly sensitive: false;
24
+ readonly survives: readonly [];
25
+ readonly support: "read";
26
+ }, {
27
+ readonly id: "people:members";
28
+ readonly label: "Manage people";
29
+ readonly description: "Grant or remove membership, and erase a person.";
30
+ readonly resourceType: "member";
31
+ readonly effect: "write";
32
+ readonly scopes: readonly ["organisation"];
33
+ readonly boundaries: readonly ["organisation"];
34
+ readonly relations: readonly ["any"];
35
+ readonly tenantKinds: readonly ["customer", "operator"];
36
+ readonly offered: false;
37
+ readonly assignable: true;
38
+ readonly sensitive: true;
39
+ readonly survives: readonly [];
40
+ readonly support: "never";
41
+ }];
42
+ };
43
+ export declare const catalogue: Readonly<Record<"people:read" | "people:members", {
44
+ id: string;
45
+ label: string;
46
+ description: string;
47
+ resourceType: string;
48
+ effect: "read" | "write";
49
+ scopes: ("organisation" | "team" | "own_teams" | "resource")[];
50
+ boundaries: ("organisation" | "organisations" | "platform")[];
51
+ relations: ("any" | "owner" | "actor" | "subject")[];
52
+ tenantKinds: ("operator" | "customer")[];
53
+ offered: boolean;
54
+ assignable: boolean;
55
+ sensitive: boolean;
56
+ survives: ("frozen" | "read_only" | "suspended")[];
57
+ support: "read" | "write" | "never";
58
+ guest?: "read" | "write" | "never" | undefined;
59
+ requiresPurpose?: boolean | undefined;
60
+ maxDelegationDepth?: number | undefined;
61
+ requiresCoSign?: boolean | undefined;
62
+ fields?: string[] | undefined;
63
+ acceptsFrom?: {
64
+ applicationId: string;
65
+ permission: string;
66
+ }[] | undefined;
67
+ }>>;
68
+ export type PeoplePermission = (typeof resourceModule.permissions)[number]['id'];
@@ -0,0 +1,49 @@
1
+ import { defineResourceCatalogue } from '@wtfalch/authz';
2
+ /**
3
+ * This package's authorization vocabulary (ADR 0005 of `package-template`:
4
+ * `@wtfalch/authz` is scaffolded, not optional). Two permissions: reading the
5
+ * roster and a profile, and changing membership (grant/remove) or erasing a
6
+ * person — the second is `sensitive` and refused in a support session
7
+ * (`support: 'never'`), the same refusal manage, app-template and operator
8
+ * all hard-code today for `grantMembership`/`changeRole`/`removeMembership`.
9
+ */
10
+ export const resourceModule = {
11
+ namespace: 'people',
12
+ permissions: [
13
+ {
14
+ id: 'people:read',
15
+ label: 'Read people',
16
+ description: "Read an organisation's member list and profiles.",
17
+ resourceType: 'member',
18
+ effect: 'read',
19
+ scopes: ['organisation'],
20
+ boundaries: ['organisation'],
21
+ relations: ['any'],
22
+ tenantKinds: ['customer', 'operator'],
23
+ offered: false,
24
+ assignable: true,
25
+ sensitive: false,
26
+ survives: [],
27
+ support: 'read',
28
+ },
29
+ {
30
+ // Not `people:manage` or `people:write`: @wtfalch/authz's permission-id
31
+ // schema refuses those exact action names (reserved).
32
+ id: 'people:members',
33
+ label: 'Manage people',
34
+ description: 'Grant or remove membership, and erase a person.',
35
+ resourceType: 'member',
36
+ effect: 'write',
37
+ scopes: ['organisation'],
38
+ boundaries: ['organisation'],
39
+ relations: ['any'],
40
+ tenantKinds: ['customer', 'operator'],
41
+ offered: false,
42
+ assignable: true,
43
+ sensitive: true,
44
+ survives: [],
45
+ support: 'never',
46
+ },
47
+ ],
48
+ };
49
+ export const catalogue = defineResourceCatalogue([resourceModule]);
@@ -0,0 +1,36 @@
1
+ import type { DbOrTx } from '@wtfalch/authz-store';
2
+ /**
3
+ * Stable and derived from the subject id, never random and never a counter:
4
+ * the same person has to read the same way across every row of theirs and
5
+ * across two separate calls, or a log stops being followable at exactly the
6
+ * moment somebody needs to follow it. sha256 of the id, truncated to 16 hex
7
+ * characters purely to keep the column short.
8
+ */
9
+ export declare function pseudonymFor(subjectId: string): string;
10
+ export interface ErasePersonResult {
11
+ readonly pseudonym: string;
12
+ /** False when there was no profile row to begin with — erasing an id that never signed in is a no-op, not an error. */
13
+ readonly erased: boolean;
14
+ }
15
+ /**
16
+ * Scrubs one person's own row: `displayName` and `email` become a stable
17
+ * pseudonym and null, so "who that was" no longer reads back, the same
18
+ * choice app-template and operator's `erase.ts` make (`ledger.erase` plus an
19
+ * inline `profiles` update, both in one transaction). manage's
20
+ * `erase_person()` — a Postgres `SECURITY DEFINER` function this package
21
+ * would need a privileged role to call — is not followed here; this package
22
+ * holds no database credential of its own (ADR 0005's shape reasoning) and a
23
+ * generated host should not need one just to erase a profile.
24
+ *
25
+ * The row itself is kept, not deleted: a `memberships` row and the audit
26
+ * trail both refer to this id, and removing the profile would leave them
27
+ * pointing at nothing.
28
+ *
29
+ * This is one statement, not a transaction: it touches only the table this
30
+ * package owns. A host composing a full erasure (its own `authz_events`
31
+ * sweep, invitations, analytics rollups, …) calls this with a `db` that is
32
+ * already its own open transaction, so everything commits or rolls back
33
+ * together — the same pattern `db.transaction` callers use elsewhere in this
34
+ * package.
35
+ */
36
+ export declare function erasePerson(db: DbOrTx, personId: string): Promise<ErasePersonResult>;
package/dist/erase.js ADDED
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { eq } from 'drizzle-orm';
3
+ import { profiles } from './schema.js';
4
+ /**
5
+ * Non-secret prefix on every erasure pseudonym, so a reader recognises one on
6
+ * sight without the prefix itself revealing anything about the id it
7
+ * replaced. Ported from `app-template/src/lib/authz/erase.ts`.
8
+ */
9
+ const PSEUDONYM_PREFIX = 'erased-person-';
10
+ /**
11
+ * Stable and derived from the subject id, never random and never a counter:
12
+ * the same person has to read the same way across every row of theirs and
13
+ * across two separate calls, or a log stops being followable at exactly the
14
+ * moment somebody needs to follow it. sha256 of the id, truncated to 16 hex
15
+ * characters purely to keep the column short.
16
+ */
17
+ export function pseudonymFor(subjectId) {
18
+ const digest = createHash('sha256').update(subjectId).digest('hex').slice(0, 16);
19
+ return `${PSEUDONYM_PREFIX}${digest}`;
20
+ }
21
+ /**
22
+ * Scrubs one person's own row: `displayName` and `email` become a stable
23
+ * pseudonym and null, so "who that was" no longer reads back, the same
24
+ * choice app-template and operator's `erase.ts` make (`ledger.erase` plus an
25
+ * inline `profiles` update, both in one transaction). manage's
26
+ * `erase_person()` — a Postgres `SECURITY DEFINER` function this package
27
+ * would need a privileged role to call — is not followed here; this package
28
+ * holds no database credential of its own (ADR 0005's shape reasoning) and a
29
+ * generated host should not need one just to erase a profile.
30
+ *
31
+ * The row itself is kept, not deleted: a `memberships` row and the audit
32
+ * trail both refer to this id, and removing the profile would leave them
33
+ * pointing at nothing.
34
+ *
35
+ * This is one statement, not a transaction: it touches only the table this
36
+ * package owns. A host composing a full erasure (its own `authz_events`
37
+ * sweep, invitations, analytics rollups, …) calls this with a `db` that is
38
+ * already its own open transaction, so everything commits or rolls back
39
+ * together — the same pattern `db.transaction` callers use elsewhere in this
40
+ * package.
41
+ */
42
+ export async function erasePerson(db, personId) {
43
+ const pseudonym = pseudonymFor(personId);
44
+ const rows = await db
45
+ .update(profiles)
46
+ .set({ displayName: pseudonym, email: null, updatedAt: new Date() })
47
+ .where(eq(profiles.id, personId))
48
+ .returning({ id: profiles.id });
49
+ return { pseudonym, erased: rows.length > 0 };
50
+ }
@@ -0,0 +1,20 @@
1
+ import { type DbOrTx } from '@wtfalch/authz-store';
2
+ import { type Profile } from './schema.js';
3
+ export interface PersonExportMembership {
4
+ readonly tenantId: string;
5
+ readonly principalClass: string;
6
+ readonly source: string;
7
+ readonly joinedAt: Date;
8
+ }
9
+ export interface PersonExport {
10
+ readonly profile: Profile | null;
11
+ readonly memberships: readonly PersonExportMembership[];
12
+ }
13
+ /**
14
+ * One person's data, scoped to the tables this package owns or reads: their
15
+ * profile, and every membership row naming them. This is not the "future
16
+ * erasure orchestrator" the README names — that sweeps every service for one
17
+ * person; this is the one hook it would call for the `people` slice of that
18
+ * sweep, and is usable on its own for a host that has no orchestrator yet.
19
+ */
20
+ export declare function exportPerson(db: DbOrTx, personId: string, principalClass?: string): Promise<PersonExport>;
package/dist/export.js ADDED
@@ -0,0 +1,23 @@
1
+ import { memberships } from '@wtfalch/authz-store';
2
+ import { and, eq } from 'drizzle-orm';
3
+ import { profiles } from './schema.js';
4
+ /**
5
+ * One person's data, scoped to the tables this package owns or reads: their
6
+ * profile, and every membership row naming them. This is not the "future
7
+ * erasure orchestrator" the README names — that sweeps every service for one
8
+ * person; this is the one hook it would call for the `people` slice of that
9
+ * sweep, and is usable on its own for a host that has no orchestrator yet.
10
+ */
11
+ export async function exportPerson(db, personId, principalClass = 'human') {
12
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, personId)).limit(1);
13
+ const memberRows = await db
14
+ .select({
15
+ tenantId: memberships.tenantId,
16
+ principalClass: memberships.principalClass,
17
+ source: memberships.source,
18
+ joinedAt: memberships.createdAt,
19
+ })
20
+ .from(memberships)
21
+ .where(and(eq(memberships.principalId, personId), eq(memberships.principalClass, principalClass)));
22
+ return { profile: profile ?? null, memberships: memberRows };
23
+ }
@@ -0,0 +1,16 @@
1
+ export type { NewProfile, Profile } from './schema.js';
2
+ export { profiles } from './schema.js';
3
+ export { DISPLAY_NAME_MAX, ensureProfile, setDisplayName } from './profiles.js';
4
+ export type { Member } from './roster.js';
5
+ export { membersOf } from './roster.js';
6
+ export type { AuditOptions, GrantMembershipInput, GrantMembershipResult, Principal, RemoveMembershipInput, RemoveMembershipResult, } from './membership.js';
7
+ export { grantMembership, guardStaysHeld, removeMembership } from './membership.js';
8
+ export type { ErasePersonResult } from './erase.js';
9
+ export { erasePerson, pseudonymFor } from './erase.js';
10
+ export type { PersonExport, PersonExportMembership } from './export.js';
11
+ export { exportPerson } from './export.js';
12
+ export type { PeoplePermission } from './catalogue.js';
13
+ export { catalogue, resourceModule } from './catalogue.js';
14
+ export type { MemberResource, PeopleCheckResult } from './authorization.js';
15
+ export { checkPeopleManage, checkPeopleRead } from './authorization.js';
16
+ export { migrate } from './migrate.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { profiles } from './schema.js';
2
+ export { DISPLAY_NAME_MAX, ensureProfile, setDisplayName } from './profiles.js';
3
+ export { membersOf } from './roster.js';
4
+ export { grantMembership, guardStaysHeld, removeMembership } from './membership.js';
5
+ export { erasePerson, pseudonymFor } from './erase.js';
6
+ export { exportPerson } from './export.js';
7
+ export { catalogue, resourceModule } from './catalogue.js';
8
+ export { checkPeopleManage, checkPeopleRead } from './authorization.js';
9
+ export { migrate } from './migrate.js';
@@ -0,0 +1,61 @@
1
+ import type { Actor, AuditWriter } from '@wtfalch/audit';
2
+ import { type DbOrTx, type PolicyBinding } from '@wtfalch/authz-store';
3
+ export interface Principal {
4
+ readonly id: string;
5
+ readonly class: string;
6
+ }
7
+ /** The host's already-bound audit writer, plus who to credit the change to. Optional: a caller with no audit trail wired up yet still gets a working membership change. */
8
+ export interface AuditOptions {
9
+ readonly writer: AuditWriter;
10
+ readonly actor: Actor;
11
+ }
12
+ export interface GrantMembershipInput {
13
+ readonly tenantId: string;
14
+ /** Denormalised onto the row, same as the three hosts do (D5: the switcher reads one index range). */
15
+ readonly tenantName: string;
16
+ readonly principal: Principal;
17
+ /** Defaults to `'direct'`. `'inherited'` is the tenant tree giving it — a host writes that itself, this package does not compute a tree. */
18
+ readonly source?: string;
19
+ readonly viaTenantId?: string | null;
20
+ readonly grantedBy?: string | null;
21
+ }
22
+ export type GrantMembershipResult = {
23
+ readonly ok: true;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly reason: 'already_member';
27
+ };
28
+ /**
29
+ * Adds one membership row. This is deliberately narrower than the
30
+ * `grantMembership` every host has today: role assignment
31
+ * (`writePrimaryAssignment`), the role-boundary refusal checks
32
+ * (`roleAssignmentRefusal`), tenant-tree propagation and participation-policy
33
+ * writes are `@wtfalch/authz`'s policy layer, not `memberships` table rows,
34
+ * and out of this package's scope (its own README: "Out of scope: ...
35
+ * permission checks"). The caller checks whether the acting principal may
36
+ * grant this membership before calling this.
37
+ */
38
+ export declare function grantMembership(db: DbOrTx, input: GrantMembershipInput, audit?: AuditOptions): Promise<GrantMembershipResult>;
39
+ export interface RemoveMembershipInput {
40
+ readonly tenantId: string;
41
+ readonly principal: Principal;
42
+ }
43
+ export type RemoveMembershipResult = {
44
+ readonly ok: true;
45
+ } | {
46
+ readonly ok: false;
47
+ readonly reason: 'not_found';
48
+ };
49
+ /** Removes one membership row. Call `guardStaysHeld` first when the removed principal might hold a guarded role — this function does not know what roles a principal holds. */
50
+ export declare function removeMembership(db: DbOrTx, input: RemoveMembershipInput, audit?: AuditOptions): Promise<RemoveMembershipResult>;
51
+ /**
52
+ * Whether removing `principal`'s membership (or changing it away from a
53
+ * guarded role) would leave a protected role with no independent recovery
54
+ * owner. Ported from manage's `guardStaysHeld`, with one change: it takes
55
+ * `guards` — the union of `role.guards` across whatever roles are being
56
+ * revoked — instead of `ResourceRole[]`, because role definitions are
57
+ * `@wtfalch/authz`'s, not this package's. The caller (which already resolved
58
+ * the roles to compute a refusal reason) reduces them to this set the same
59
+ * way the original did: `new Set(roles.flatMap((r) => r.guards))`.
60
+ */
61
+ export declare function guardStaysHeld(db: DbOrTx, binding: PolicyBinding, tenantId: string, principal: Principal, guards: ReadonlySet<string>): Promise<boolean>;
@@ -0,0 +1,83 @@
1
+ import { memberships, ownerCoverage, ownerSeatCovered, } from '@wtfalch/authz-store';
2
+ import { and, eq } from 'drizzle-orm';
3
+ /**
4
+ * Adds one membership row. This is deliberately narrower than the
5
+ * `grantMembership` every host has today: role assignment
6
+ * (`writePrimaryAssignment`), the role-boundary refusal checks
7
+ * (`roleAssignmentRefusal`), tenant-tree propagation and participation-policy
8
+ * writes are `@wtfalch/authz`'s policy layer, not `memberships` table rows,
9
+ * and out of this package's scope (its own README: "Out of scope: ...
10
+ * permission checks"). The caller checks whether the acting principal may
11
+ * grant this membership before calling this.
12
+ */
13
+ export async function grantMembership(db, input, audit) {
14
+ const [existing] = await db
15
+ .select({ principalId: memberships.principalId })
16
+ .from(memberships)
17
+ .where(identityWhere(input.tenantId, input.principal))
18
+ .limit(1);
19
+ if (existing)
20
+ return { ok: false, reason: 'already_member' };
21
+ const source = input.source ?? 'direct';
22
+ await db.insert(memberships).values({
23
+ tenantId: input.tenantId,
24
+ principalId: input.principal.id,
25
+ principalClass: input.principal.class,
26
+ source,
27
+ viaTenantId: input.viaTenantId ?? null,
28
+ tenantName: input.tenantName,
29
+ grantedBy: input.grantedBy ?? null,
30
+ });
31
+ if (audit) {
32
+ await audit.writer({
33
+ action: 'membership.created',
34
+ actor: audit.actor,
35
+ target: { type: 'membership', id: input.principal.id },
36
+ tenantId: input.tenantId,
37
+ after: { principalClass: input.principal.class, source },
38
+ }, db);
39
+ }
40
+ return { ok: true };
41
+ }
42
+ /** Removes one membership row. Call `guardStaysHeld` first when the removed principal might hold a guarded role — this function does not know what roles a principal holds. */
43
+ export async function removeMembership(db, input, audit) {
44
+ const [existing] = await db
45
+ .select({ principalId: memberships.principalId })
46
+ .from(memberships)
47
+ .where(identityWhere(input.tenantId, input.principal))
48
+ .limit(1);
49
+ if (!existing)
50
+ return { ok: false, reason: 'not_found' };
51
+ await db.delete(memberships).where(identityWhere(input.tenantId, input.principal));
52
+ if (audit) {
53
+ await audit.writer({
54
+ action: 'membership.ended',
55
+ actor: audit.actor,
56
+ target: { type: 'membership', id: input.principal.id },
57
+ tenantId: input.tenantId,
58
+ }, db);
59
+ }
60
+ return { ok: true };
61
+ }
62
+ /**
63
+ * Whether removing `principal`'s membership (or changing it away from a
64
+ * guarded role) would leave a protected role with no independent recovery
65
+ * owner. Ported from manage's `guardStaysHeld`, with one change: it takes
66
+ * `guards` — the union of `role.guards` across whatever roles are being
67
+ * revoked — instead of `ResourceRole[]`, because role definitions are
68
+ * `@wtfalch/authz`'s, not this package's. The caller (which already resolved
69
+ * the roles to compute a refusal reason) reduces them to this set the same
70
+ * way the original did: `new Set(roles.flatMap((r) => r.guards))`.
71
+ */
72
+ export async function guardStaysHeld(db, binding, tenantId, principal, guards) {
73
+ if (guards.size === 0)
74
+ return true;
75
+ const coverage = await ownerCoverage(db, binding, tenantId, {
76
+ excludePrincipalId: principal.id,
77
+ excludePrincipalClass: principal.class,
78
+ });
79
+ return guards.has('members:grant-owner') ? ownerSeatCovered(coverage) : coverage.directOwners > 0;
80
+ }
81
+ function identityWhere(tenantId, principal) {
82
+ return and(eq(memberships.tenantId, tenantId), eq(memberships.principalClass, principal.class), eq(memberships.principalId, principal.id));
83
+ }
@@ -0,0 +1,12 @@
1
+ import type { DbOrTx } from '@wtfalch/authz-store';
2
+ /**
3
+ * Applies every migration this package ships that `people_migrations` does
4
+ * not yet record, in file order, one transaction per file. Idempotent: a
5
+ * second call applies nothing and returns `[]`.
6
+ *
7
+ * Does not touch `@wtfalch/authz-store`'s tables. A caller that also needs
8
+ * those (every test in this package does) calls that package's own
9
+ * `migrateStore(db)` as well — the two run independently, in either order:
10
+ * this package's baseline has no foreign key into authz-store's tables.
11
+ */
12
+ export declare function migrate(db: DbOrTx): Promise<string[]>;
@@ -0,0 +1,48 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { sql } from 'drizzle-orm';
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const MIGRATIONS_DIR = join(here, 'migrations');
7
+ /** The marker drizzle-kit writes; also a valid `--` SQL line comment, so both `migrate()` here and a host's own `psql` ignore it uniformly. */
8
+ const BREAKPOINT = '--> statement-breakpoint';
9
+ /** postgres-js returns the rows as the result; node-postgres and PGlite wrap them in `rows`. */
10
+ function rowsOf(result) {
11
+ return Array.isArray(result)
12
+ ? result
13
+ : (result.rows ?? []);
14
+ }
15
+ /**
16
+ * Applies every migration this package ships that `people_migrations` does
17
+ * not yet record, in file order, one transaction per file. Idempotent: a
18
+ * second call applies nothing and returns `[]`.
19
+ *
20
+ * Does not touch `@wtfalch/authz-store`'s tables. A caller that also needs
21
+ * those (every test in this package does) calls that package's own
22
+ * `migrateStore(db)` as well — the two run independently, in either order:
23
+ * this package's baseline has no foreign key into authz-store's tables.
24
+ */
25
+ export async function migrate(db) {
26
+ const files = readdirSync(MIGRATIONS_DIR)
27
+ .filter((name) => name.endsWith('.sql'))
28
+ .sort();
29
+ const ran = [];
30
+ for (const file of files) {
31
+ const applied = await db.transaction(async (tx) => {
32
+ await tx.execute(sql `create table if not exists people_migrations (name text primary key, applied_at timestamptz not null default now())`);
33
+ const done = rowsOf(await tx.execute(sql `select 1 from people_migrations where name = ${file}`));
34
+ if (done.length > 0)
35
+ return false;
36
+ const text = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
37
+ for (const statement of text.split(BREAKPOINT)) {
38
+ if (statement.trim())
39
+ await tx.execute(sql.raw(statement));
40
+ }
41
+ await tx.execute(sql `insert into people_migrations (name) values (${file})`);
42
+ return true;
43
+ });
44
+ if (applied)
45
+ ran.push(file);
46
+ }
47
+ return ran;
48
+ }
@@ -0,0 +1,20 @@
1
+ -- @wtfalch/people: the profiles table. A profile belongs to one person
2
+ -- (keyed by the ZITADEL person id) and holds the display fields every host
3
+ -- shows in a member list.
4
+ --
5
+ -- Applied by the host's own migrate script after the host copies this file
6
+ -- into its drizzle/ directory (people-migrations); never edited there. A
7
+ -- host without one can instead apply this package's own migrate() against
8
+ -- any DbOrTx, which is what src/test/db.ts and src/migrate.ts do.
9
+
10
+ create table if not exists profiles (
11
+ id text primary key,
12
+ display_name text,
13
+ email text,
14
+ last_seen_at timestamptz,
15
+ created_at timestamptz not null default now(),
16
+ updated_at timestamptz not null default now()
17
+ );
18
+ --> statement-breakpoint
19
+ create index if not exists profiles_email_idx
20
+ on profiles (email);
@@ -0,0 +1,41 @@
1
+ import type { DbOrTx } from '@wtfalch/authz-store';
2
+ import { type Profile } from './schema.js';
3
+ /**
4
+ * The row this package keeps for a person, created the first time it is
5
+ * needed and refreshed on every later sign-in. Ported from
6
+ * `app-template/src/lib/db/profiles.ts`, byte-identical across app-template,
7
+ * manage and operator except for the import of `db` itself, which the host
8
+ * now supplies as `db: DbOrTx`.
9
+ *
10
+ * Idempotent on the id, and deliberately asymmetric between its two other
11
+ * mutable fields: `email` is a display copy of what the issuer reports, so a
12
+ * fresh sign-in overwrites it whenever the caller supplies one; `displayName`
13
+ * is something a person sets inside a host app and a later sign-in must never
14
+ * stomp on, so it is only ever written on the first insert.
15
+ */
16
+ export declare function ensureProfile(db: DbOrTx, person: {
17
+ id: string;
18
+ name: string | null;
19
+ email?: string | null;
20
+ }): Promise<Profile>;
21
+ /**
22
+ * The longest display name a members list can show without the layout
23
+ * fighting it. Names are truncated in the UI; this stops the column being
24
+ * defended by CSS alone.
25
+ */
26
+ export declare const DISPLAY_NAME_MAX = 80;
27
+ /**
28
+ * What a person calls themselves inside a host app, set from that host's own
29
+ * account page.
30
+ *
31
+ * The counterpart to `ensureProfile`'s asymmetry: that function writes
32
+ * `displayName` only on the first insert, precisely so this one can own it
33
+ * afterwards and a later sign-in cannot stomp on it.
34
+ *
35
+ * Scoped to one id by the caller, which must be the signed-in person's own:
36
+ * there is no permission to check here, because a display name is not
37
+ * anybody's authority. Clearing it is allowed and meaningful; a null name
38
+ * falls back to the email the profile already holds (see `roster.ts`), so a
39
+ * person can withdraw a name without becoming anonymous in a members list.
40
+ */
41
+ export declare function setDisplayName(db: DbOrTx, personId: string, displayName: string | null): Promise<void>;
@@ -0,0 +1,70 @@
1
+ import { eq, sql } from 'drizzle-orm';
2
+ import { profiles } from './schema.js';
3
+ /**
4
+ * The row this package keeps for a person, created the first time it is
5
+ * needed and refreshed on every later sign-in. Ported from
6
+ * `app-template/src/lib/db/profiles.ts`, byte-identical across app-template,
7
+ * manage and operator except for the import of `db` itself, which the host
8
+ * now supplies as `db: DbOrTx`.
9
+ *
10
+ * Idempotent on the id, and deliberately asymmetric between its two other
11
+ * mutable fields: `email` is a display copy of what the issuer reports, so a
12
+ * fresh sign-in overwrites it whenever the caller supplies one; `displayName`
13
+ * is something a person sets inside a host app and a later sign-in must never
14
+ * stomp on, so it is only ever written on the first insert.
15
+ */
16
+ export async function ensureProfile(db, person) {
17
+ const emailGiven = person.email !== undefined;
18
+ const [row] = await db
19
+ .insert(profiles)
20
+ .values({
21
+ id: person.id,
22
+ displayName: person.name,
23
+ email: emailGiven ? person.email : null,
24
+ lastSeenAt: sql `now()`,
25
+ })
26
+ .onConflictDoUpdate({
27
+ target: profiles.id,
28
+ // displayName is deliberately absent here: an existing row's own value
29
+ // survives every later call. email is refreshed whenever the caller
30
+ // gives one; a caller that does not know it leaves the stored value
31
+ // alone rather than blanking it to null.
32
+ set: emailGiven
33
+ ? { email: person.email, lastSeenAt: sql `now()`, updatedAt: sql `now()` }
34
+ : { lastSeenAt: sql `now()`, updatedAt: sql `now()` },
35
+ })
36
+ .returning();
37
+ if (!row)
38
+ throw new Error(`profile ${person.id} vanished on insert`);
39
+ return row;
40
+ }
41
+ /**
42
+ * The longest display name a members list can show without the layout
43
+ * fighting it. Names are truncated in the UI; this stops the column being
44
+ * defended by CSS alone.
45
+ */
46
+ export const DISPLAY_NAME_MAX = 80;
47
+ /**
48
+ * What a person calls themselves inside a host app, set from that host's own
49
+ * account page.
50
+ *
51
+ * The counterpart to `ensureProfile`'s asymmetry: that function writes
52
+ * `displayName` only on the first insert, precisely so this one can own it
53
+ * afterwards and a later sign-in cannot stomp on it.
54
+ *
55
+ * Scoped to one id by the caller, which must be the signed-in person's own:
56
+ * there is no permission to check here, because a display name is not
57
+ * anybody's authority. Clearing it is allowed and meaningful; a null name
58
+ * falls back to the email the profile already holds (see `roster.ts`), so a
59
+ * person can withdraw a name without becoming anonymous in a members list.
60
+ */
61
+ export async function setDisplayName(db, personId, displayName) {
62
+ const trimmed = displayName?.trim();
63
+ await db
64
+ .update(profiles)
65
+ .set({
66
+ displayName: trimmed === undefined || trimmed.length === 0 ? null : trimmed,
67
+ updatedAt: sql `now()`,
68
+ })
69
+ .where(eq(profiles.id, personId));
70
+ }
@@ -0,0 +1,38 @@
1
+ import { type DbOrTx } from '@wtfalch/authz-store';
2
+ /**
3
+ * One row of an organisation's member list: who they are, how they got
4
+ * here, and what to call them.
5
+ *
6
+ * `role`/`roleLabel` are deliberately absent. `manage`'s `membersOf` computes
7
+ * them from a correlated subquery over `authz_assignments`
8
+ * (`primaryRoleKey`, in that app's own `assignments.ts`) — role resolution is
9
+ * `@wtfalch/authz`'s policy layer, not exposed by `@wtfalch/authz-store` yet,
10
+ * and out of this package's scope (its own README: "Out of scope: ...
11
+ * permission checks"). A host that needs the role joins it in on top of this.
12
+ */
13
+ export interface Member {
14
+ readonly principalId: string;
15
+ readonly principalClass: string;
16
+ readonly source: string;
17
+ readonly display: string;
18
+ readonly email: string | null;
19
+ /**
20
+ * Where this membership came from — `null` for `source === 'direct'`, the
21
+ * parent organisation's name otherwise (manage's addition, carried forward
22
+ * for every host: scout finding #1).
23
+ */
24
+ readonly viaTenantName: string | null;
25
+ readonly joinedAt: Date;
26
+ readonly lastSeenAt: Date | null;
27
+ }
28
+ /**
29
+ * Every member of one tenant, newest membership first is not assumed here —
30
+ * callers that want an order sort by `joinedAt` themselves; this returns in
31
+ * principal order, which is what makes the query itself stable to test
32
+ * against.
33
+ *
34
+ * The caller checks `people:read` before calling this — see
35
+ * `authorization.ts` — this function does not check it itself, the same
36
+ * separation `@wtfalch/widget`'s store keeps from its authorization module.
37
+ */
38
+ export declare function membersOf(db: DbOrTx, tenantId: string): Promise<readonly Member[]>;
package/dist/roster.js ADDED
@@ -0,0 +1,68 @@
1
+ import { credentials, memberships, tenants } from '@wtfalch/authz-store';
2
+ import { and, asc, eq, inArray, sql } from 'drizzle-orm';
3
+ import { profiles } from './schema.js';
4
+ /**
5
+ * A name for a non-human member once no credential row explains it either —
6
+ * a dangling reference, not the normal case, since every credential is
7
+ * minted with a `name`. Still never a raw principal id: a UUID says nothing
8
+ * a person can act on. Ported from manage's `memberships.ts`.
9
+ */
10
+ function fallbackCredentialLabel(principalClass) {
11
+ switch (principalClass) {
12
+ case 'api_key':
13
+ return 'An API key no longer on record';
14
+ case 'agent':
15
+ return 'An agent no longer on record';
16
+ case 'service':
17
+ return 'A service no longer on record';
18
+ default:
19
+ return 'A credential no longer on record';
20
+ }
21
+ }
22
+ /**
23
+ * Every member of one tenant, newest membership first is not assumed here —
24
+ * callers that want an order sort by `joinedAt` themselves; this returns in
25
+ * principal order, which is what makes the query itself stable to test
26
+ * against.
27
+ *
28
+ * The caller checks `people:read` before calling this — see
29
+ * `authorization.ts` — this function does not check it itself, the same
30
+ * separation `@wtfalch/widget`'s store keeps from its authorization module.
31
+ */
32
+ export async function membersOf(db, tenantId) {
33
+ const rows = await db
34
+ .select({
35
+ principalId: memberships.principalId,
36
+ principalClass: memberships.principalClass,
37
+ source: memberships.source,
38
+ viaTenantId: memberships.viaTenantId,
39
+ joinedAt: memberships.createdAt,
40
+ displayName: profiles.displayName,
41
+ email: profiles.email,
42
+ lastSeenAt: profiles.lastSeenAt,
43
+ // A non-human member's name is the name its own credential was minted
44
+ // with, not the principal id `display` falls back to next — a raw
45
+ // UUID where a name belongs.
46
+ credentialName: credentials.name,
47
+ })
48
+ .from(memberships)
49
+ .leftJoin(profiles, and(eq(memberships.principalClass, 'human'), eq(profiles.id, memberships.principalId)))
50
+ .leftJoin(credentials, and(eq(memberships.principalClass, credentials.kind), sql `${memberships.principalId} = ${credentials.id}::text`))
51
+ .where(eq(memberships.tenantId, tenantId))
52
+ .orderBy(asc(memberships.principalId), asc(memberships.principalClass));
53
+ const viaTenantIds = [...new Set(rows.map((r) => r.viaTenantId).filter((id) => id !== null))];
54
+ const viaTenantNames = viaTenantIds.length
55
+ ? new Map((await db
56
+ .select({ id: tenants.id, name: tenants.name })
57
+ .from(tenants)
58
+ .where(inArray(tenants.id, viaTenantIds))).map((t) => [t.id, t.name]))
59
+ : new Map();
60
+ return rows.map(({ displayName, credentialName, viaTenantId, ...r }) => ({
61
+ ...r,
62
+ display: displayName ?? r.email ?? credentialName ?? fallbackCredentialLabel(r.principalClass),
63
+ email: r.email ?? null,
64
+ viaTenantName: viaTenantId
65
+ ? (viaTenantNames.get(viaTenantId) ?? 'a parent organisation')
66
+ : null,
67
+ }));
68
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The row this package owns for a person: display name and the fields every
3
+ * host shows. Keyed by the ZITADEL person id (the README's own scope line),
4
+ * a `text` id because that id is not a uuid.
5
+ *
6
+ * `memberships`, `tenants` and `credentials` — the tables `roster.ts` joins
7
+ * this against — are not defined here. They belong to `@wtfalch/authz-store`,
8
+ * which ships and migrates them; this package only reads and writes its own
9
+ * `profiles` table (`src/migrations/0001_profiles.sql`).
10
+ */
11
+ export declare const profiles: import("drizzle-orm/pg-core").PgTableWithColumns<{
12
+ name: "profiles";
13
+ schema: undefined;
14
+ columns: {
15
+ id: import("drizzle-orm/pg-core").PgColumn<{
16
+ name: "id";
17
+ tableName: "profiles";
18
+ dataType: "string";
19
+ columnType: "PgText";
20
+ data: string;
21
+ driverParam: string;
22
+ notNull: true;
23
+ hasDefault: false;
24
+ isPrimaryKey: true;
25
+ isAutoincrement: false;
26
+ hasRuntimeDefault: false;
27
+ enumValues: [string, ...string[]];
28
+ baseColumn: never;
29
+ identity: undefined;
30
+ generated: undefined;
31
+ }, {}, {}>;
32
+ displayName: import("drizzle-orm/pg-core").PgColumn<{
33
+ name: "display_name";
34
+ tableName: "profiles";
35
+ dataType: "string";
36
+ columnType: "PgText";
37
+ data: string;
38
+ driverParam: string;
39
+ notNull: false;
40
+ hasDefault: false;
41
+ isPrimaryKey: false;
42
+ isAutoincrement: false;
43
+ hasRuntimeDefault: false;
44
+ enumValues: [string, ...string[]];
45
+ baseColumn: never;
46
+ identity: undefined;
47
+ generated: undefined;
48
+ }, {}, {}>;
49
+ email: import("drizzle-orm/pg-core").PgColumn<{
50
+ name: "email";
51
+ tableName: "profiles";
52
+ dataType: "string";
53
+ columnType: "PgText";
54
+ data: string;
55
+ driverParam: string;
56
+ notNull: false;
57
+ hasDefault: false;
58
+ isPrimaryKey: false;
59
+ isAutoincrement: false;
60
+ hasRuntimeDefault: false;
61
+ enumValues: [string, ...string[]];
62
+ baseColumn: never;
63
+ identity: undefined;
64
+ generated: undefined;
65
+ }, {}, {}>;
66
+ lastSeenAt: import("drizzle-orm/pg-core").PgColumn<{
67
+ name: "last_seen_at";
68
+ tableName: "profiles";
69
+ dataType: "date";
70
+ columnType: "PgTimestamp";
71
+ data: Date;
72
+ driverParam: string;
73
+ notNull: false;
74
+ hasDefault: false;
75
+ isPrimaryKey: false;
76
+ isAutoincrement: false;
77
+ hasRuntimeDefault: false;
78
+ enumValues: undefined;
79
+ baseColumn: never;
80
+ identity: undefined;
81
+ generated: undefined;
82
+ }, {}, {}>;
83
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
84
+ name: "created_at";
85
+ tableName: "profiles";
86
+ dataType: "date";
87
+ columnType: "PgTimestamp";
88
+ data: Date;
89
+ driverParam: string;
90
+ notNull: true;
91
+ hasDefault: true;
92
+ isPrimaryKey: false;
93
+ isAutoincrement: false;
94
+ hasRuntimeDefault: false;
95
+ enumValues: undefined;
96
+ baseColumn: never;
97
+ identity: undefined;
98
+ generated: undefined;
99
+ }, {}, {}>;
100
+ updatedAt: import("drizzle-orm/pg-core").PgColumn<{
101
+ name: "updated_at";
102
+ tableName: "profiles";
103
+ dataType: "date";
104
+ columnType: "PgTimestamp";
105
+ data: Date;
106
+ driverParam: string;
107
+ notNull: true;
108
+ hasDefault: true;
109
+ isPrimaryKey: false;
110
+ isAutoincrement: false;
111
+ hasRuntimeDefault: false;
112
+ enumValues: undefined;
113
+ baseColumn: never;
114
+ identity: undefined;
115
+ generated: undefined;
116
+ }, {}, {}>;
117
+ };
118
+ dialect: "pg";
119
+ }>;
120
+ export type Profile = typeof profiles.$inferSelect;
121
+ export type NewProfile = typeof profiles.$inferInsert;
package/dist/schema.js ADDED
@@ -0,0 +1,20 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
3
+ /**
4
+ * The row this package owns for a person: display name and the fields every
5
+ * host shows. Keyed by the ZITADEL person id (the README's own scope line),
6
+ * a `text` id because that id is not a uuid.
7
+ *
8
+ * `memberships`, `tenants` and `credentials` — the tables `roster.ts` joins
9
+ * this against — are not defined here. They belong to `@wtfalch/authz-store`,
10
+ * which ships and migrates them; this package only reads and writes its own
11
+ * `profiles` table (`src/migrations/0001_profiles.sql`).
12
+ */
13
+ export const profiles = pgTable('profiles', {
14
+ id: text('id').primaryKey(),
15
+ displayName: text('display_name'),
16
+ email: text('email'),
17
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
18
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
19
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
20
+ });
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@wtfalch/people",
3
+ "version": "0.1.0",
4
+ "description": "The estate's directory of people: profiles, org membership and roster reads, on top of @wtfalch/authz-store.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/wtfalch/people",
8
+ "directory": "packages/people"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "bin": {
18
+ "people-migrations": "dist/bin/migrations.js"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./migrations/*.sql": "./dist/migrations/*.sql",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "sideEffects": false,
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.0.0"
34
+ },
35
+ "dependencies": {
36
+ "@wtfalch/authz-store": "0.1.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@wtfalch/audit": "0.4.0",
40
+ "@wtfalch/authz": "0.12.0",
41
+ "drizzle-orm": "^0.39.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@wtfalch/audit": {
45
+ "optional": true
46
+ },
47
+ "@wtfalch/authz": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@electric-sql/pglite": "^0.5.8",
53
+ "@types/node": "^22",
54
+ "@wtfalch/audit": "0.4.0",
55
+ "@wtfalch/authz": "0.12.0",
56
+ "drizzle-orm": "^0.39.0",
57
+ "postgres": "^3.4.5",
58
+ "typescript": "^5.9.0",
59
+ "vitest": "^4.1.6",
60
+ "zod": "^4.5.4"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run"
66
+ }
67
+ }