@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 +21 -0
- package/README.md +140 -0
- package/dist/authorization.d.ts +18 -0
- package/dist/authorization.js +12 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +61 -0
- package/dist/catalogue.d.ts +68 -0
- package/dist/catalogue.js +49 -0
- package/dist/erase.d.ts +36 -0
- package/dist/erase.js +50 -0
- package/dist/export.d.ts +20 -0
- package/dist/export.js +23 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/membership.d.ts +61 -0
- package/dist/membership.js +83 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +48 -0
- package/dist/migrations/0001_profiles.sql +20 -0
- package/dist/profiles.d.ts +41 -0
- package/dist/profiles.js +70 -0
- package/dist/roster.d.ts +38 -0
- package/dist/roster.js +68 -0
- package/dist/schema.d.ts +121 -0
- package/dist/schema.js +20 -0
- package/package.json +67 -0
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,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]);
|
package/dist/erase.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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[]>;
|
package/dist/migrate.js
ADDED
|
@@ -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>;
|
package/dist/profiles.js
ADDED
|
@@ -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
|
+
}
|
package/dist/roster.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -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
|
+
}
|