@house-rules/party 0.0.0-stage → 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/AGENTS.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -2
- package/dist/adapters/memory/party.d.ts +3 -0
- package/dist/adapters/memory/party.js +170 -0
- package/dist/adapters/memory/person.d.ts +3 -0
- package/dist/adapters/memory/person.js +87 -0
- package/dist/adapters/postgres/party.d.ts +4 -0
- package/dist/adapters/postgres/party.js +73 -0
- package/dist/adapters/postgres/person.d.ts +4 -0
- package/dist/adapters/postgres/person.js +63 -0
- package/dist/assignment/assignment.d.ts +13 -0
- package/dist/assignment/assignment.js +9 -0
- package/dist/assignment/holders.d.ts +11 -0
- package/dist/assignment/holders.js +7 -0
- package/dist/contract.d.ts +9 -0
- package/dist/contract.js +9 -0
- package/dist/facade.d.ts +22 -0
- package/dist/facade.js +35 -0
- package/dist/index.d.ts +294 -0
- package/dist/index.js +20 -0
- package/dist/invitation/invitation.d.ts +36 -0
- package/dist/invitation/invitation.js +17 -0
- package/dist/invitation/invitations.d.ts +34 -0
- package/dist/invitation/invitations.js +70 -0
- package/dist/organisation/organisation.d.ts +59 -0
- package/dist/organisation/organisation.js +46 -0
- package/dist/person/bind-party.d.ts +7 -0
- package/dist/person/bind-party.js +42 -0
- package/dist/person/operations.d.ts +306 -0
- package/dist/person/operations.js +92 -0
- package/dist/person/party.d.ts +36 -0
- package/dist/person/party.js +55 -0
- package/dist/person/permissions.d.ts +6 -0
- package/dist/person/permissions.js +5 -0
- package/dist/person/person-store.d.ts +21 -0
- package/dist/person/person-store.js +3 -0
- package/dist/person/person.d.ts +43 -0
- package/dist/person/person.js +25 -0
- package/dist/person/policy.d.ts +1 -0
- package/dist/person/policy.js +6 -0
- package/dist/person/principal-operations.d.ts +110 -0
- package/dist/person/principal-operations.js +77 -0
- package/dist/person/principal.d.ts +126 -0
- package/dist/person/principal.js +49 -0
- package/dist/person/principals.d.ts +102 -0
- package/dist/person/principals.js +73 -0
- package/dist/relationship/relationship.d.ts +30 -0
- package/dist/relationship/relationship.js +12 -0
- package/dist/relationship/removal.d.ts +101 -0
- package/dist/relationship/removal.js +22 -0
- package/dist/role/access.d.ts +171 -0
- package/dist/role/access.js +252 -0
- package/dist/role/operations.d.ts +145 -0
- package/dist/role/operations.js +215 -0
- package/dist/role/people.d.ts +45 -0
- package/dist/role/people.js +57 -0
- package/dist/role/permission-catalogue.d.ts +2 -0
- package/dist/role/permission-catalogue.js +1 -0
- package/dist/role/role.d.ts +31 -0
- package/dist/role/role.js +12 -0
- package/dist/storage.d.ts +38 -0
- package/dist/storage.js +4 -0
- package/migrations/0001_party_person.sql +5 -0
- package/migrations/0002_party_model.sql +27 -0
- package/migrations/0003_party_invitation.sql +11 -0
- package/migrations/0004_publish_staff.sql +4 -0
- package/migrations/0005_principals.sql +18 -0
- package/package.json +57 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @house-rules/party
|
|
2
|
+
|
|
3
|
+
## What it does
|
|
4
|
+
|
|
5
|
+
Party owns person and agent principals, organisations, relationships, roles and assignments. It owns their tables and migrations. Identity owns login bindings. Party stores no login credentials.
|
|
6
|
+
|
|
7
|
+
## Mount and bind
|
|
8
|
+
|
|
9
|
+
Call `Party.layerMemory({ builtInRoles })` or `Party.layerPostgres({ builtInRoles })`. Each role entry is `{ name, permissions }`, keyed by role key. Raw `Party.layer(options)` needs `PersonStore` and `PartyStore`. Share the app's unit adapter. Postgres also needs the shared SQL client.
|
|
10
|
+
|
|
11
|
+
Register this package in `migrations.json`. Never edit applied migrations. `0001` owns persons; `0002` adds organisations, relationships, custom roles and assignments; `0003` adds hashed one-time invitations; append-only `0004` publishes party_staff_read (person_id, organisation_id), containing current person-to-organisation staff relationships only. Read models may select this view for SQL privacy filters, never the relationship table. No cross-module foreign keys.
|
|
12
|
+
|
|
13
|
+
Import PermissionCatalogue from application, not Party. Composition supplies its `permissions: () => Effect<readonly Permission[]>` before building modules. It and Runtime.permissions expose the same mount-derived array. Custom-role writes fail closed without the port. May filters permissions when the port is present. Exported PartyMount contains Party, all PartyOperations and bindParty; add the chosen layer in composition/modules.ts.
|
|
14
|
+
|
|
15
|
+
Declare builtInRoles, systemPermissions and `may = Party.may()` in composition/access.ts. Pass `may` to composition in app.ts and roles to Party's mount in modules.ts. It resolves the MaySocket after Party is built. Its `permissionsOf(caller: PersonCaller)` returns an Effect of permissions with no requirements. It captures the built Party service. It does not cache assignments.
|
|
16
|
+
|
|
17
|
+
## Operations
|
|
18
|
+
|
|
19
|
+
Use `PartyOperations` from the browser-safe contract door. Bind all descriptors with `bindParty(service)`. App features call them through declared edges. Only features are exposed. All operation edges are empty.
|
|
20
|
+
|
|
21
|
+
| Operation | Permission | Flags |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| createPerson, createPrincipal | party:create | transactional |
|
|
24
|
+
| disable | party:update | transactional, destructive, needsApproval |
|
|
25
|
+
| lockActive | party:read | transactional |
|
|
26
|
+
| principalsByIds, findPrincipal, holdersOf | party:read | readOnly |
|
|
27
|
+
| showPerson, showMe | party:read | readOnly |
|
|
28
|
+
| renamePerson | party:update | transactional |
|
|
29
|
+
| createOrganisation | party:create-organisation | transactional |
|
|
30
|
+
| relate, defineRole, assignRole, createInvitation | party:admin | transactional |
|
|
31
|
+
| acceptInvitation | party:read | transactional |
|
|
32
|
+
| listInvitations, listPeople | party:admin | readOnly |
|
|
33
|
+
| myOrganisations, listMembers | party:read | readOnly |
|
|
34
|
+
| revokeInvitation | party:admin | transactional, destructive, needsApproval |
|
|
35
|
+
| unrelate, revokeRole, deleteRole | party:admin | transactional, destructive, needsApproval |
|
|
36
|
+
| showOrganisation, isRelated, canAdminOrganisation, listRoles, rolesOf, myAccess | party:read | readOnly |
|
|
37
|
+
|
|
38
|
+
## Service and fixed rules
|
|
39
|
+
|
|
40
|
+
- `0005_principals.sql` adds principal kind, unique immutable handle, createdBy and disabledAt. It backfills existing people with kind person, handle=id and null provenance/disable time. Legacy handles and `createPerson` bypass the new validation; Person and existing operations keep their old shapes. The migration publishes `party_principals_read(id, display_name, kind, handle, created_by, created_at, disabled_at)` and `party_assignments_read(person_id, role_key, organisation_id)` for app read models.
|
|
41
|
+
- `createPrincipal({ id?, displayName, kind, handle, createdBy })` is system-only. Kind is person or agent; createdBy is an ActorId or null passed as data. New handles contain 1 to 120 characters. `handles: { pattern?: RegExp, reserved?: readonly string[] }` supplies app policy. Matching is case-sensitive, with no normalization. Handle uniqueness spans both kinds and concurrent creates.
|
|
42
|
+
- `disable({ id })` is system-only, irreversible, transactional, destructive and approval-gated. It returns `{ principal, fact }`, with a `principal.disabled` fact carrying before/after or null when already disabled. Creation returns a `principal.created` fact carrying after. Composition chooses recording in the same unit. Party never emits events. There is no enable. Protecting a person owner is app policy, not a Party role rule.
|
|
43
|
+
- `standingOf(actorId)` is a facade method for Identity's caller-owned standing slot, not an operation. It answers active, disabled or missing. May also returns no permissions for disabled principals. New request admission belongs to Identity; already admitted requests without a lock may finish after disable.
|
|
44
|
+
- `lockActive({ handle, kind })` accepts person and system callers with party:read and requires a live unit. It returns a principal, or PersonNotFound for unknown handles and kind mismatches for person callers. System callers may receive PrincipalKindRefused for a kind mismatch. Disabled targets return PersonNotFound to person callers and PrincipalDisabledRefused to system callers. Kind agent refuses a person owner. Postgres holds FOR SHARE until unit end, never FOR KEY SHARE. Disable locks FOR NO KEY UPDATE before capturing its fact and waits for active locks. Locks after its commit fail. Memory relies on the shared memoryUnitOfWork's whole-unit serialization.
|
|
45
|
+
- `findPrincipal({ handle })` accepts person and system callers with party:read. It returns the full principal by exact handle, including disabledAt for disabled principals, or PersonNotFound. It is a directory read, not an active-principal check.
|
|
46
|
+
- `holdersOf({ role, scope? })` accepts person and system callers with party:read. Scope is one organisation id; omitted or null means global assignments only. It returns `{ ids, truncated }` in ascending id order, including disabled holders. Unknown roles and empty scopes return no ids. `holdersLimit` in Party options defaults to 1000 and must be a positive safe integer below Number.MAX_SAFE_INTEGER; invalid options fail acquisition with PartyInputInvalid. Adapters fetch at most limit + 1 distinct ids; Party returns at most limit and flags overflow without failing unavailable. Storage failures remain typed PartyUnavailable. This is an assignment lookup, not a permission grant.
|
|
47
|
+
- `principalsByIds({ ids })` accepts both person and system callers with party:read. It returns each matching principal once, includes disabled principals and omits missing ids. This is a directory read, not authority or credential admission.
|
|
48
|
+
|
|
49
|
+
- `myAccess` is person-only and takes no target id. It reads only the caller's assignments, effective permissions and the public app catalogue. Postgres filters assignments by that caller id in SQL; memory uses the same port. System callers are refused.
|
|
50
|
+
- Person creation is system-only at the binder. Person reads need a person caller. Rename allows only the target person. `erase(id)` is a trusted service hook, not an operation.
|
|
51
|
+
- Global admins manage all scopes. An admin whose authority comes only from organisation X may manage roles and assignments only in X. Check both old and new scope when moving a custom role. Organisation creation accepts a person with party:create-organisation at the gate and relates the creator as staff. An app feature may bootstrap a scoped admin role through an explicit system assignRole edge.
|
|
52
|
+
- A person defining or assigning a role must hold every permission it grants in the target scope. Global assignments count in any organisation; another organisation's assignments never count. This covers built-in roles and updates to already assigned custom roles. System assignment is exempt. `party:admin` alone cannot delegate unheld permissions.
|
|
53
|
+
- Role reads take a caller. Organisation-scoped callers see global built-ins and custom roles only in their own organisations. Assignment reads apply the same scope filter. Postgres filters these lists in SQL; global admins and trusted system reads see all scopes.
|
|
54
|
+
- Built-in keys are reserved. Custom permissions must have `resource:action` syntax and belong to the mount-derived catalogue. Organisation-scoped custom roles cannot be assigned outside their organisation.
|
|
55
|
+
- System callers may assign roles for first sign-in. They cannot revoke roles, define roles or bypass other management checks. Ordinary person callers cannot use the system assignment path.
|
|
56
|
+
- May unions every held role across contexts. It drops missing roles and permissions absent from the current catalogue. Revocation takes effect on the next call. MCP and HTTP get the same answer for the same Actor.
|
|
57
|
+
- Invitations take `{ organisationId, roleKey: string | null }`. Creation checks the caller administers that org and holds every attached role permission there. It returns `{ invitation, code }` once. Codes use 256 random bits, SHA-256 storage and a seven-day expiry. Lists never return codes or hashes. Acceptance atomically consumes a pending unexpired code, relates the person as staff and returns `{ personId, organisationId, roleKey }`; an app feature assigns the optional role through a system edge in the same unit. Acceptance rechecks the inviter's current delegation authority. Unknown, used, revoked and expired codes are generic not_found. Revocation needs approval.
|
|
58
|
+
- `myOrganisations` returns the caller's relationships or scoped assignments as `{ id, name, relation, roles: [{ roleKey, roleName, permissions }] }[]`. Roles are own org-scoped assignments only. Assignment-only membership has null relation; multiple relations use the first kind in lexical order.
|
|
59
|
+
- `listPeople` returns `{ id, displayName, createdAt, assignments: [{ roleKey, roleName, organisationId, organisationName }], organisations: [{ id, name, relation }] }[]`. Global admins see all people and assignments. Scoped admins see only staff in administered orgs and only assignments and relations there, never global or other-org assignments. Non-admins get forbidden. Both adapters filter facts before returning; Postgres filters people, assignments and relations in SQL.
|
|
60
|
+
- `listMembers({ organisationId })` needs `party:read` at the gate and admin authority in that org inside Party. It returns `{ id, displayName, createdAt, relation, roles: [{ roleKey, roleName }] }[]` for staff, with only org-scoped roles. Members, admins of other orgs and unknown ids get the same not_found. It needs no migration.
|
|
61
|
+
- Removing a person's staff relation revokes all assignments scoped to that org in the same unit. Global assignments stay. Removing the last scoped org admin fails with conflict, with a clear message. Postgres locks the organisation before checking, so concurrent removals cannot remove both admins.
|
|
62
|
+
- `canAdminOrganisation({ organisationId })` answers whether the current person holds party:admin globally or in that scope, using live assignments and the catalogue. It is a party:read operation, not an admin gate. It returns false for absent authority, including system callers. This lets modules own object checks through declared edges without requiring party:admin for every ordinary booking call.
|
|
63
|
+
- Relationships are directional facts. Management checks every organisation endpoint. Other modules ask `isRelated` through an operation edge; they never query Party tables.
|
|
64
|
+
- Domain checks run inside the write unit. All writes require `UnitOfWork.required`. Memory registers undo before mutation; Postgres joins the transaction. Modules never open units.
|
|
65
|
+
- Expected errors declare kinds. Unavailable errors have fixed public messages and private diagnostic causes.
|
|
66
|
+
|
|
67
|
+
The [README](README.md) lists inputs and mounting details. Shared suites test memory and real Postgres. Use `MigrationsTesting.database`, never mocked SQL. Tests use `it.effect` or `it.layer`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub Szwajka
|
|
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
CHANGED
|
@@ -1,3 +1,143 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @house-rules/party
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`PartyOperations.myAccess` reads the caller's own assignments and effective permissions plus the app's public permission catalogue. It needs `party:read`, is read-only and refuses system callers. Bind it with `bindParty` like the other operations. It accepts no target person id. Both stores filter assignments by the caller id before returning them.
|
|
4
|
+
|
|
5
|
+
Party owns person and agent principals, organisations, relationships, roles and role assignments. Identity owns login bindings. Party stores no email, issuer or login subject.
|
|
6
|
+
|
|
7
|
+
## Mount
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const options = {
|
|
11
|
+
builtInRoles: {
|
|
12
|
+
member: { name: "Member", permissions: ["party:read"] },
|
|
13
|
+
admin: { name: "Administrator", permissions: ["party:admin", "party:read"] },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const partyLayer = Party.layerPostgres(options).pipe(
|
|
17
|
+
Layer.provide(database),
|
|
18
|
+
Layer.provide(catalogueLayer),
|
|
19
|
+
);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use `Party.layerMemory(options)` for memory storage. Raw `Party.layer(options)` needs `PersonStore` and `PartyStore`. Share the app's unit adapter and SQL client. Register `@house-rules/party` in `migrations.json`. Migrations `0001` and `0002` create Party's core tables. Append-only `0003` adds invitations. `0004_publish_staff.sql` publishes `party_staff_read(person_id, organisation_id)` over current staff relationships. App read models may use it for SQL privacy filters. Modules still ask Party operations through declared edges, never read this view to make domain decisions.
|
|
23
|
+
|
|
24
|
+
`PartyOperations.canAdminOrganisation({ organisationId })` needs party:read and returns whether the current person holds party:admin globally or in that scope. It uses live assignments and the catalogue. System callers and unrelated scoped admins get false.
|
|
25
|
+
|
|
26
|
+
Import PartyMount from main and add `{ ...PartyMount, layer: chosenPartyLayer }` to the app's module plan. Composition captures Party and calls bindParty once. Pass `may: Party.may()`; composition resolves it after modules build. It captures Party and returns a MaySocket with no per-call requirements.
|
|
27
|
+
|
|
28
|
+
`PermissionCatalogue` belongs to application, not Party. Its service shape is:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
{ permissions: () => Effect<readonly Permission[]> }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Composition derives the catalogue from mount descriptors before acquiring Party. Its callback and Runtime.permissions expose the same array. Do not acquire Runtime inside Party's layer. There is no installation callback or mutable reference. Custom-role writes fail closed if the port is absent. Without a catalogue, May returns all held permissions. With it, May intersects them with the current catalogue on every call.
|
|
35
|
+
|
|
36
|
+
## Operations
|
|
37
|
+
|
|
38
|
+
Descriptors come from `@house-rules/party/contract`. Only app features are exposed. All operations have empty edges. `T` means transactional, `R` read-only, `D` destructive and `A` needs approval.
|
|
39
|
+
|
|
40
|
+
| Member | Permission | Flags |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| createPerson, createPrincipal | party:create | T |
|
|
43
|
+
| disable | party:update | T D A |
|
|
44
|
+
| lockActive | party:read | T |
|
|
45
|
+
| principalsByIds, findPrincipal, holdersOf | party:read | R |
|
|
46
|
+
| showPerson | party:read | R |
|
|
47
|
+
| showMe | party:read | R |
|
|
48
|
+
| myAccess | party:read | R |
|
|
49
|
+
| renamePerson | party:update | T |
|
|
50
|
+
| createOrganisation | party:create-organisation | T |
|
|
51
|
+
| myOrganisations | party:read | R |
|
|
52
|
+
| listPeople | party:admin | R |
|
|
53
|
+
| listMembers | party:read | R |
|
|
54
|
+
| createInvitation | party:admin | T |
|
|
55
|
+
| acceptInvitation | party:read | T |
|
|
56
|
+
| listInvitations | party:admin | R |
|
|
57
|
+
| revokeInvitation | party:admin | T D A |
|
|
58
|
+
| showOrganisation | party:read | R |
|
|
59
|
+
| canAdminOrganisation | party:read | R |
|
|
60
|
+
| relate | party:admin | T |
|
|
61
|
+
| unrelate | party:admin | T D A |
|
|
62
|
+
| isRelated | party:read | R |
|
|
63
|
+
| defineRole | party:admin | T |
|
|
64
|
+
| deleteRole | party:admin | T D A |
|
|
65
|
+
| listRoles | party:read | R |
|
|
66
|
+
| assignRole | party:admin | T |
|
|
67
|
+
| revokeRole | party:admin | T D A |
|
|
68
|
+
| rolesOf | party:read | R |
|
|
69
|
+
|
|
70
|
+
Organisation creation takes `{ id, name }`. Relationships take `{ from: { kind, id }, to: { kind, id }, kind }`; endpoint kinds are `person` or `organisation`. Roles take `{ key, name, permissions, organisationId }`. Assignments take `{ personId, roleKey, organisationId }`. Use `null` for global scope. Role reads add `builtIn`.
|
|
71
|
+
|
|
72
|
+
## Principals and standing
|
|
73
|
+
|
|
74
|
+
`createPrincipal({ id?, displayName, kind, handle, createdBy })` creates a `person` or `agent`. It returns `{ principal, fact }`; the fact is `{ kind: "principal.created", after }`. `createdBy` is an ActorId or null, passed as data by the provisioning process. Party does not infer it from a system caller. Legacy `Person`, `createPerson`, reads and rename retain their existing shapes.
|
|
75
|
+
|
|
76
|
+
Handle options are app policy:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
Party.layerPostgres({
|
|
80
|
+
builtInRoles,
|
|
81
|
+
handles: {
|
|
82
|
+
pattern: /^[a-z][a-z0-9-]{0,31}$/u,
|
|
83
|
+
reserved: ["me", "none"],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
New handles must contain 1 to 120 characters. Without options there is no additional pattern or reserved word. Pattern and reserved checks are case-sensitive; Party never trims or lowercases handles. Handles are immutable and unique across both kinds, including concurrent creates. Duplicate handles or ids fail with `PersonAlreadyExists`; invalid or reserved handles fail with `PersonInputInvalid`. Migration `0005_principals.sql` backfills existing people with kind `person`, handle equal to id, and null `createdBy`/`disabledAt`. These legacy handles, including those made by `createPerson`, bypass the new handle validation so existing ids and account setup keep working.
|
|
89
|
+
|
|
90
|
+
Caller rules at the operation boundary:
|
|
91
|
+
|
|
92
|
+
- `createPrincipal` and `disable` accept only system callers. App provisioning features use explicit `asSystem` edges with reasons. Their permissions are `party:create` and `party:update` respectively.
|
|
93
|
+
- `lockActive({ handle, kind })` accepts person and system callers with `party:read`. It needs an open unit and returns the principal. Unknown handles fail with `PersonNotFound`. A kind mismatch also returns `PersonNotFound` for person callers, so they cannot probe handles; system callers get `PrincipalKindRefused`. Disabled principals also return `PersonNotFound` to person callers; system callers get `PrincipalDisabledRefused`. Asking for kind `agent` rejects a person owner. Party does not know owner roles.
|
|
94
|
+
- `principalsByIds({ ids })` accepts person and system callers with `party:read`. It returns matching principals once each, omits missing ids, and includes disabled principals. It exposes no credentials. This supports notification paths as well as person-facing names.
|
|
95
|
+
- `findPrincipal({ handle })` accepts person and system callers with `party:read`. It matches the exact handle and returns the full principal, including id, kind, displayName, handle, createdBy, createdAt and disabledAt. Missing handles fail with `PersonNotFound`. Disabled principals are returned, not refused. Callers decide whether standing permits their action.
|
|
96
|
+
- `holdersOf({ role, scope? })` accepts person and system callers with `party:read`. The role is its key; scope is one organisation id. Omitted or null scope selects global assignments only, never assignments across all organisations. It returns `{ ids, truncated }` in ascending id order, without duplicates, including disabled holders. Unknown roles or scopes with no assignments return `{ ids: [], truncated: false }`. This lookup grants no authority.
|
|
97
|
+
- `standingOf(actorId)` is a facade method for a caller-owned Identity standing slot, not an operation. It returns `active`, `disabled` or `missing`; storage failures remain typed unavailable errors.
|
|
98
|
+
|
|
99
|
+
Both lookups are read-only and need no unit. Set `holdersLimit` in `Party.layerMemory`, `Party.layerPostgres` or raw `Party.layer` options to bound each `holdersOf` result. The default is 1000. The limit must be a positive safe integer below `Number.MAX_SAFE_INTEGER`; invalid options fail layer acquisition with `PartyInputInvalid`. Adapters fetch at most limit + 1 distinct ids, then Party returns at most limit ids. `truncated` is true only when another matching id exists. Overflow is not an unavailable error; actual storage failures remain typed unavailable errors. Consumers that require complete results must check the flag.
|
|
100
|
+
|
|
101
|
+
`disable({ id })` permanently sets `disabledAt` and returns `{ principal, fact }`. A first disable returns `{ kind: "principal.disabled", before, after }`; a repeated disable preserves the timestamp and returns `fact: null`. Unknown ids fail with `PersonNotFound`. It is transactional, destructive and needs approval. There is no enable. Party permits disabling either kind; protecting an owner is the app's provisioning policy. The composing feature or process chooses which returned facts to record in the same unit. Party never records them itself.
|
|
102
|
+
|
|
103
|
+
`Party.may()` returns no permissions for a disabled principal. Identity should also check standing before admitting a request. For writes that must not race disable, call `lockActive` in the write's unit. Postgres takes `FOR SHARE`, not `FOR KEY SHARE`, on the principal row until commit or rollback. Disable takes `FOR NO KEY UPDATE` before capturing the change, so it waits for those units; a subsequent lock sees the committed disabled state and fails. Memory's shared `memoryUnitOfWork` serializes whole units and provides the same guarantee. Requests already admitted without `lockActive` may finish after a disable.
|
|
104
|
+
|
|
105
|
+
Migration `0005` publishes `party_principals_read(id, display_name, kind, handle, created_by, created_at, disabled_at)` and `party_assignments_read(person_id, role_key, organisation_id)`. Read models may select these views for directory ordering and assignment joins. Modules still ask operations, not views, for domain decisions.
|
|
106
|
+
|
|
107
|
+
## Invitations and membership
|
|
108
|
+
|
|
109
|
+
`createOrganisation(caller, { id, name })` accepts a person and relates them as staff. The operation gate needs `party:create-organisation`. Bootstrap the creator's scoped admin assignment in the app through an explicit system `assignRole` edge, in the same feature transaction.
|
|
110
|
+
|
|
111
|
+
`createInvitation(caller, { organisationId, roleKey })` needs an administrator of that organisation. Use `null` to omit a role. The inviter must hold every attached role permission in that scope. Creation returns `{ invitation, code }`. Save the code then: it is never listed again. It has 256 random bits, a seven-day expiry and only its SHA-256 hash is stored.
|
|
112
|
+
|
|
113
|
+
`acceptInvitation(caller, code)` consumes the code with a conditional write and relates that person as staff. Its result is `{ personId, organisationId, roleKey }`. A feature calls the optional `assignRole` through a system edge. Keep both calls in one transaction, so a failed assignment restores the code and relation. Acceptance rechecks that the inviter can still delegate the current role. Unknown, expired, used and revoked codes all fail with `PartyNotFound`.
|
|
114
|
+
|
|
115
|
+
`listInvitations(caller, organisationId)` shows only an administered org's invitations, without codes or hashes. `revokeInvitation(caller, id)` checks the invitation's org and needs approval.
|
|
116
|
+
|
|
117
|
+
`myOrganisations(caller)` returns `{ id, name, relation, roles: [{ roleKey, roleName, permissions }] }[]` for the caller's relationships or scoped assignments. It includes only that person's org-scoped roles, not global roles. An assignment without a relationship has `relation: null`. If several relations exist, `relation` is the first kind in lexical order.
|
|
118
|
+
|
|
119
|
+
`listPeople(caller)` returns `{ id, displayName, createdAt, assignments, organisations }[]`. Assignments are `{ roleKey, roleName, organisationId, organisationName }`, with null organisation fields for global roles. Organisations are `{ id, name, relation }`. Global admins see everyone and all assignments and relations. Org admins see only staff of their administered orgs and only assignments and relations in those orgs. Global assignments are hidden from org admins, even for a visible person. Other callers get forbidden.
|
|
120
|
+
|
|
121
|
+
`listMembers(caller, organisationId)` returns `{ id, displayName, createdAt, relation, roles: [{ roleKey, roleName }] }[]` for that org's staff. Roles include only assignments scoped to that org. The operation gate needs `party:read`; Party checks admin authority for the requested org. Members, admins of other orgs and unknown org ids all get `PartyNotFound`.
|
|
122
|
+
|
|
123
|
+
Both adapters filter storage facts before returning them. Postgres filters people, assignments and relations in SQL; it never fetches another org's assignments for an org-admin list. These reads use the existing tables and need no new migration.
|
|
124
|
+
|
|
125
|
+
`unrelate` on person-to-organisation staff also revokes that person's assignments scoped to that org. Global roles stay. It refuses removal of the last scoped org admin with `PartyLastAdmin`, kind conflict, and locks the organisation before checking on Postgres.
|
|
126
|
+
|
|
127
|
+
## Fixed rules
|
|
128
|
+
|
|
129
|
+
Built-in roles live in options. Custom roles live in `party_role`. Custom keys cannot replace built-in keys. Permissions must be valid `resource:action` strings from the catalogue. A custom role may belong to one organisation or be global. Only global admins may edit global roles. Editing a role's scope checks both its old and new scope. Defining or assigning a role also requires every granted permission in that scope, including built-in roles and updates to assigned custom roles. Global permissions count in an organisation, never the reverse. Permissions in another organisation do not count. `party:admin` alone grants no extra delegation rights.
|
|
130
|
+
|
|
131
|
+
Assignments refer to a role key, not a role row. This lets them resolve built-in roles too. An organisation admin may manage assignments only in that organisation. Organisation-scoped custom roles cannot be assigned elsewhere. Global admins may manage every organisation. Trusted system callers may assign a default role during first sign-in. Ordinary persons cannot use that exception. System callers cannot revoke roles or manage custom roles. Their trusted assignment path is exempt from the delegation subset check.
|
|
132
|
+
|
|
133
|
+
`listRoles(caller)` returns global built-ins plus custom roles in the caller's organisations. `rolesOf(caller, personId)` filters assignments to those same scopes and hides global custom roles from scoped callers. Global admins and trusted system reads see all scopes. Both stores filter lists before returning them; Postgres does so in SQL.
|
|
134
|
+
|
|
135
|
+
May unions permissions from all assignments, across all contexts. It ignores removed roles and stale permissions. It caches nothing, so revocation affects the next call. HTTP and MCP callers with the same Actor get the same permissions. Domain operations still check management scope inside the write unit. Relationship checks are directional facts; they grant no authority on their own.
|
|
136
|
+
|
|
137
|
+
Any person may read a person's display name. Only that person may rename it. Person creation is system-only at the operation boundary. `party.erase(id)` is trusted service code for account deletion, not an exposed operation.
|
|
138
|
+
|
|
139
|
+
Every write requires an open unit. Memory registers rollback before mutation. Postgres joins the app's transaction. Party never opens a unit. Tables have no cross-module foreign keys. Errors declare kinds; unavailable messages are fixed and keep diagnostic causes private.
|
|
140
|
+
|
|
141
|
+
## Tests
|
|
142
|
+
|
|
143
|
+
Run `pnpm exec tsc --project tsconfig.json --noEmit` and `pnpm exec varlock run --path ../../ -- vitest run` from this package. Shared storage and domain suites run on memory and real Postgres through `MigrationsTesting.database`.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ActorId, UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { PartyStore } from "../../storage.js";
|
|
4
|
+
export const memoryPartyStore = () => Layer.sync(PartyStore, () => {
|
|
5
|
+
const organisations = new Map();
|
|
6
|
+
const relationships = new Map();
|
|
7
|
+
const roles = new Map();
|
|
8
|
+
const assignments = new Map();
|
|
9
|
+
const invitations = new Map();
|
|
10
|
+
const copy = (value) => structuredClone(value);
|
|
11
|
+
const key = (value) => JSON.stringify(value);
|
|
12
|
+
const change = Effect.fn("PartyStore.memory.change")(function* (run) {
|
|
13
|
+
yield* UnitOfWork.required;
|
|
14
|
+
const before = [
|
|
15
|
+
new Map(organisations),
|
|
16
|
+
new Map(relationships),
|
|
17
|
+
new Map(roles),
|
|
18
|
+
new Map(assignments),
|
|
19
|
+
new Map(invitations),
|
|
20
|
+
];
|
|
21
|
+
yield* UnitOfWork.onRollback(Effect.sync(() => {
|
|
22
|
+
organisations.clear();
|
|
23
|
+
for (const [k, v] of before[0])
|
|
24
|
+
organisations.set(k, v);
|
|
25
|
+
relationships.clear();
|
|
26
|
+
for (const [k, v] of before[1])
|
|
27
|
+
relationships.set(k, v);
|
|
28
|
+
roles.clear();
|
|
29
|
+
for (const [k, v] of before[2])
|
|
30
|
+
roles.set(k, v);
|
|
31
|
+
assignments.clear();
|
|
32
|
+
for (const [k, v] of before[3])
|
|
33
|
+
assignments.set(k, v);
|
|
34
|
+
invitations.clear();
|
|
35
|
+
for (const [k, v] of before[4])
|
|
36
|
+
invitations.set(k, v);
|
|
37
|
+
}));
|
|
38
|
+
return run();
|
|
39
|
+
});
|
|
40
|
+
const changeAssignment = Effect.fnUntraced(function* (value, assigned) {
|
|
41
|
+
yield* UnitOfWork.required;
|
|
42
|
+
let undo;
|
|
43
|
+
yield* UnitOfWork.onRollback(Effect.sync(() => undo?.()));
|
|
44
|
+
yield* Effect.sync(() => {
|
|
45
|
+
// Keep one previous entry, not a growing snapshot for every assignment in the unit.
|
|
46
|
+
const id = key(value);
|
|
47
|
+
const before = assignments.get(id);
|
|
48
|
+
undo = () => {
|
|
49
|
+
if (before === undefined)
|
|
50
|
+
assignments.delete(id);
|
|
51
|
+
else
|
|
52
|
+
assignments.set(id, before);
|
|
53
|
+
};
|
|
54
|
+
if (assigned)
|
|
55
|
+
assignments.set(id, copy(value));
|
|
56
|
+
else
|
|
57
|
+
assignments.delete(id);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
return PartyStore.of({
|
|
61
|
+
organisation: (id) => Effect.sync(() => copy(organisations.get(id))),
|
|
62
|
+
organisationsFor: (id) => Effect.sync(() => copy([...organisations.values()]
|
|
63
|
+
.filter((org) => [...relationships.values()].some((r) => r.from.kind === "person" &&
|
|
64
|
+
r.from.id === id &&
|
|
65
|
+
r.to.kind === "organisation" &&
|
|
66
|
+
r.to.id === org.id) ||
|
|
67
|
+
[...assignments.values()].some((a) => a.personId === id && a.organisationId === org.id))
|
|
68
|
+
.map((org) => ({
|
|
69
|
+
...org,
|
|
70
|
+
relation: [...relationships.values()]
|
|
71
|
+
.filter((r) => r.from.kind === "person" &&
|
|
72
|
+
r.from.id === id &&
|
|
73
|
+
r.to.kind === "organisation" &&
|
|
74
|
+
r.to.id === org.id)
|
|
75
|
+
.map((r) => r.kind)
|
|
76
|
+
.sort()[0] ?? null,
|
|
77
|
+
}))
|
|
78
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)))),
|
|
79
|
+
relationsFor: (id, scopes) => Effect.sync(() => copy([...relationships.values()]
|
|
80
|
+
.flatMap((r) => {
|
|
81
|
+
const org = organisations.get(r.to.id);
|
|
82
|
+
return r.from.kind === "person" &&
|
|
83
|
+
r.from.id === id &&
|
|
84
|
+
r.to.kind === "organisation" &&
|
|
85
|
+
org &&
|
|
86
|
+
(scopes === null || scopes.includes(org.id))
|
|
87
|
+
? [{ ...org, relation: r.kind }]
|
|
88
|
+
: [];
|
|
89
|
+
})
|
|
90
|
+
.sort((a, b) => a.name.localeCompare(b.name) ||
|
|
91
|
+
a.id.localeCompare(b.id) ||
|
|
92
|
+
a.relation.localeCompare(b.relation)))),
|
|
93
|
+
scopedAssignments: (id, scopes) => Effect.sync(() => copy([...assignments.values()]
|
|
94
|
+
.filter((a) => a.personId === id &&
|
|
95
|
+
(scopes === null ||
|
|
96
|
+
(a.organisationId !== null && scopes.includes(a.organisationId))))
|
|
97
|
+
.sort((a, b) => a.roleKey.localeCompare(b.roleKey) ||
|
|
98
|
+
(a.organisationId ?? "").localeCompare(b.organisationId ?? "")))),
|
|
99
|
+
assignmentsInOrganisation: (id) => Effect.sync(() => copy([...assignments.values()].filter((a) => a.organisationId === id))),
|
|
100
|
+
holderIds: (role, scope, take) => Effect.sync(() => [
|
|
101
|
+
...new Set([...assignments.values()]
|
|
102
|
+
.filter((a) => a.roleKey === role && a.organisationId === scope)
|
|
103
|
+
.map((a) => a.personId)),
|
|
104
|
+
]
|
|
105
|
+
.sort()
|
|
106
|
+
.slice(0, take)),
|
|
107
|
+
lockOrganisation: () => UnitOfWork.required,
|
|
108
|
+
memberIds: (ids) => Effect.sync(() => [
|
|
109
|
+
...new Set([...relationships.values()]
|
|
110
|
+
.filter((r) => r.from.kind === "person" &&
|
|
111
|
+
r.to.kind === "organisation" &&
|
|
112
|
+
ids.includes(r.to.id) &&
|
|
113
|
+
r.kind === "staff")
|
|
114
|
+
.map((r) => ActorId.make(r.from.id))),
|
|
115
|
+
]),
|
|
116
|
+
insertInvitation: (value) => change(() => {
|
|
117
|
+
invitations.set(value.id, copy(value));
|
|
118
|
+
}),
|
|
119
|
+
invitation: (id) => Effect.sync(() => copy(invitations.get(id))),
|
|
120
|
+
invitations: (id) => Effect.sync(() => copy([...invitations.values()].filter((i) => i.organisationId === id))),
|
|
121
|
+
consumeInvitation: (hash, now) => change(() => {
|
|
122
|
+
const found = [...invitations.values()].find((i) => i.codeHash === hash && i.state === "pending" && i.expiresAt > now);
|
|
123
|
+
if (!found)
|
|
124
|
+
return undefined;
|
|
125
|
+
invitations.set(found.id, { ...found, state: "used" });
|
|
126
|
+
return copy(found);
|
|
127
|
+
}),
|
|
128
|
+
revokeInvitation: (id) => change(() => {
|
|
129
|
+
const found = invitations.get(id);
|
|
130
|
+
if (found)
|
|
131
|
+
invitations.set(id, { ...found, state: "revoked" });
|
|
132
|
+
}),
|
|
133
|
+
insertOrganisation: (value) => change(() => {
|
|
134
|
+
if (organisations.has(value.id))
|
|
135
|
+
return false;
|
|
136
|
+
organisations.set(value.id, copy(value));
|
|
137
|
+
return true;
|
|
138
|
+
}),
|
|
139
|
+
relate: (value) => change(() => {
|
|
140
|
+
relationships.set(key(value), copy(value));
|
|
141
|
+
}),
|
|
142
|
+
unrelate: (value) => change(() => {
|
|
143
|
+
relationships.delete(key(value));
|
|
144
|
+
}),
|
|
145
|
+
isRelated: (value) => Effect.sync(() => relationships.has(key(value))),
|
|
146
|
+
role: (id) => Effect.sync(() => copy(roles.get(id))),
|
|
147
|
+
roles: (organisationIds) => Effect.sync(() => copy([...roles.values()]
|
|
148
|
+
.filter((r) => organisationIds == null ||
|
|
149
|
+
(r.organisationId !== null && organisationIds.includes(r.organisationId)))
|
|
150
|
+
.sort((a, b) => a.key.localeCompare(b.key)))),
|
|
151
|
+
putRole: (value) => change(() => {
|
|
152
|
+
roles.set(value.key, copy(value));
|
|
153
|
+
}),
|
|
154
|
+
deleteRole: (id) => change(() => {
|
|
155
|
+
roles.delete(id);
|
|
156
|
+
for (const [k, v] of assignments)
|
|
157
|
+
if (v.roleKey === id)
|
|
158
|
+
assignments.delete(k);
|
|
159
|
+
}),
|
|
160
|
+
assign: (value) => changeAssignment(value, true),
|
|
161
|
+
revoke: (value) => changeAssignment(value, false),
|
|
162
|
+
assignments: (id, organisationIds, builtInKeys = []) => Effect.sync(() => copy([...assignments.values()].filter((a) => a.personId === id &&
|
|
163
|
+
(organisationIds == null ||
|
|
164
|
+
(a.organisationId === null
|
|
165
|
+
? builtInKeys.includes(a.roleKey)
|
|
166
|
+
: organisationIds.includes(a.organisationId) &&
|
|
167
|
+
(builtInKeys.includes(a.roleKey) ||
|
|
168
|
+
roles.get(a.roleKey)?.organisationId === a.organisationId)))))),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { legacyPrincipal, personOf } from "../../person/principal.js";
|
|
4
|
+
import { PersonStore } from "../../storage.js";
|
|
5
|
+
export const memoryPersonStore = () => Layer.sync(PersonStore, () => {
|
|
6
|
+
const rows = new Map();
|
|
7
|
+
const handles = new Map();
|
|
8
|
+
const set = (id, principal) => {
|
|
9
|
+
const before = rows.get(id);
|
|
10
|
+
if (before)
|
|
11
|
+
handles.delete(before.handle);
|
|
12
|
+
if (principal) {
|
|
13
|
+
rows.set(id, principal);
|
|
14
|
+
handles.set(principal.handle, id);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
rows.delete(id);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const change = Effect.fnUntraced(function* (id, run) {
|
|
21
|
+
yield* UnitOfWork.required;
|
|
22
|
+
let undo;
|
|
23
|
+
yield* UnitOfWork.onRollback(Effect.sync(() => undo?.()));
|
|
24
|
+
return yield* Effect.sync(() => {
|
|
25
|
+
// Capture and mutate together, after registering undo, even with parallel writes in a unit.
|
|
26
|
+
const before = rows.get(id);
|
|
27
|
+
undo = () => set(id, before);
|
|
28
|
+
return run();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
const insertPrincipal = (principal) => change(principal.id, () => {
|
|
32
|
+
if (rows.has(principal.id) || handles.has(principal.handle))
|
|
33
|
+
return false;
|
|
34
|
+
set(principal.id, { ...principal });
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
return PersonStore.of({
|
|
38
|
+
principal: (id) => Effect.sync(() => {
|
|
39
|
+
const row = rows.get(id);
|
|
40
|
+
return row && { ...row };
|
|
41
|
+
}),
|
|
42
|
+
principalByHandle: (handle) => Effect.sync(() => {
|
|
43
|
+
const id = handles.get(handle);
|
|
44
|
+
const row = id === undefined ? undefined : rows.get(id);
|
|
45
|
+
return row && { ...row };
|
|
46
|
+
}),
|
|
47
|
+
principalsByIds: (ids) => Effect.sync(() => [...rows.values()].filter((p) => ids.includes(p.id)).map((p) => ({ ...p }))),
|
|
48
|
+
insertPrincipal,
|
|
49
|
+
// memoryUnitOfWork serializes whole units, so the lock lasts until commit or rollback.
|
|
50
|
+
lockPrincipal: (handle) => Effect.gen(function* () {
|
|
51
|
+
yield* UnitOfWork.required;
|
|
52
|
+
const id = handles.get(handle);
|
|
53
|
+
const row = id === undefined ? undefined : rows.get(id);
|
|
54
|
+
return row && { ...row };
|
|
55
|
+
}),
|
|
56
|
+
principalForUpdate: (id) => Effect.gen(function* () {
|
|
57
|
+
yield* UnitOfWork.required;
|
|
58
|
+
const row = rows.get(id);
|
|
59
|
+
return row && { ...row };
|
|
60
|
+
}),
|
|
61
|
+
disable: (id, disabledAt) => Effect.gen(function* () {
|
|
62
|
+
yield* UnitOfWork.required;
|
|
63
|
+
const row = rows.get(id);
|
|
64
|
+
if (!row)
|
|
65
|
+
return undefined;
|
|
66
|
+
const disabled = { ...row, disabledAt: row.disabledAt ?? disabledAt };
|
|
67
|
+
yield* change(id, () => set(id, disabled));
|
|
68
|
+
return { ...disabled };
|
|
69
|
+
}),
|
|
70
|
+
find: (id) => Effect.sync(() => {
|
|
71
|
+
const row = rows.get(id);
|
|
72
|
+
return row && personOf(row);
|
|
73
|
+
}),
|
|
74
|
+
list: (ids) => Effect.sync(() => [...rows.values()].filter((p) => ids === null || ids.includes(p.id)).map(personOf)),
|
|
75
|
+
insertIfFree: (person) => insertPrincipal(legacyPrincipal(person)),
|
|
76
|
+
rename: (id, displayName) => Effect.gen(function* () {
|
|
77
|
+
yield* UnitOfWork.required;
|
|
78
|
+
const person = rows.get(id);
|
|
79
|
+
if (person === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const renamed = { ...person, displayName };
|
|
82
|
+
yield* change(id, () => set(id, renamed));
|
|
83
|
+
return personOf(renamed);
|
|
84
|
+
}),
|
|
85
|
+
erase: (id) => change(id, () => set(id, undefined)),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ActorId, UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { SqlClient } from "effect/sql/SqlClient";
|
|
4
|
+
import { StoredInvitation } from "../../invitation/invitation.js";
|
|
5
|
+
import { Assignment } from "../../assignment/assignment.js";
|
|
6
|
+
import { Organisation, OrganisationRelation, OwnOrganisation, PartyUnavailable, } from "../../organisation/organisation.js";
|
|
7
|
+
import { Role } from "../../role/role.js";
|
|
8
|
+
import { PartyStore } from "../../storage.js";
|
|
9
|
+
export const postgresPartyStore = Layer.effect(PartyStore, Effect.gen(function* () {
|
|
10
|
+
const sql = yield* SqlClient;
|
|
11
|
+
const unavailable = (cause) => new PartyUnavailable({ cause });
|
|
12
|
+
const read = (schema, query) => query.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(schema))), Effect.mapError(unavailable));
|
|
13
|
+
const write = (query) => UnitOfWork.required.pipe(Effect.andThen(query.pipe(Effect.mapError(unavailable))), Effect.asVoid);
|
|
14
|
+
const roleColumns = sql.literal('key, name, permissions, organisation_id as "organisationId", false as "builtIn"');
|
|
15
|
+
const invitationColumns = sql.literal('id, organisation_id as "organisationId", role_key as "roleKey", created_by as "createdBy", code_hash as "codeHash", expires_at as "expiresAt", state');
|
|
16
|
+
const relation = (r) => sql `from_kind = ${r.from.kind} and from_id = ${r.from.id}
|
|
17
|
+
and to_kind = ${r.to.kind} and to_id = ${r.to.id} and kind = ${r.kind}`;
|
|
18
|
+
return PartyStore.of({
|
|
19
|
+
organisation: (id) => read(Organisation, sql `select id, name from party_organisation where id = ${id}`).pipe(Effect.map((rows) => rows[0])),
|
|
20
|
+
organisationsFor: (id) => read(OwnOrganisation, sql `select id, name, (select min(r.kind) from party_relationship r where r.from_kind = 'person' and r.from_id = ${id} and r.to_kind = 'organisation' and r.to_id = o.id) as relation from party_organisation o where exists (select 1 from party_relationship r where r.from_kind = 'person' and r.from_id = ${id} and r.to_kind = 'organisation' and r.to_id = o.id) or exists (select 1 from party_assignment a where a.person_id = ${id} and a.organisation_id = o.id) order by name, id`),
|
|
21
|
+
relationsFor: (id, scopes) => read(OrganisationRelation, sql `select o.id, o.name, r.kind as relation from party_relationship r join party_organisation o on o.id = r.to_id where r.from_kind = 'person' and r.from_id = ${id} and r.to_kind = 'organisation' and ${scopes === null ? sql `true` : sql.in("o.id", scopes)} order by o.name, o.id, r.kind`),
|
|
22
|
+
scopedAssignments: (id, scopes) => read(Assignment, sql `select person_id as "personId", role_key as "roleKey", organisation_id as "organisationId" from party_assignment where person_id = ${id} and ${scopes === null ? sql `true` : sql.in("organisation_id", scopes)} order by role_key, organisation_id nulls first`),
|
|
23
|
+
assignmentsInOrganisation: (id) => read(Assignment, sql `select person_id as "personId", role_key as "roleKey", organisation_id as "organisationId" from party_assignment where organisation_id = ${id}`),
|
|
24
|
+
holderIds: (role, scope, take) => read(Schema.Struct({ id: ActorId }), sql `select distinct person_id collate "C" as id from party_assignment
|
|
25
|
+
where role_key = ${role} and organisation_id is not distinct from ${scope}
|
|
26
|
+
order by id limit ${take}`).pipe(Effect.map((rows) => rows.map((r) => r.id))),
|
|
27
|
+
lockOrganisation: (id) => write(sql `select id from party_organisation where id = ${id} for update`),
|
|
28
|
+
memberIds: (ids) => read(Schema.Struct({ id: ActorId }), sql `select distinct from_id as id from party_relationship where from_kind = 'person' and to_kind = 'organisation' and kind = 'staff' and ${sql.in("to_id", ids)}`).pipe(Effect.map((rows) => rows.map((r) => r.id))),
|
|
29
|
+
insertInvitation: (i) => write(sql `insert into party_invitation (id, organisation_id, role_key, created_by, code_hash, expires_at, state) values (${i.id}, ${i.organisationId}, ${i.roleKey}, ${i.createdBy}, ${i.codeHash}, ${i.expiresAt}, ${i.state})`),
|
|
30
|
+
invitation: (id) => read(StoredInvitation, sql `select ${invitationColumns} from party_invitation where id = ${id}`).pipe(Effect.map((rows) => rows[0])),
|
|
31
|
+
invitations: (id) => read(StoredInvitation, sql `select ${invitationColumns} from party_invitation where organisation_id = ${id} order by id`),
|
|
32
|
+
consumeInvitation: Effect.fn("PartyStore.postgres.consumeInvitation")(function* (hash, now) {
|
|
33
|
+
yield* UnitOfWork.required;
|
|
34
|
+
const rows = yield* read(StoredInvitation, sql `update party_invitation set state = 'used' where code_hash = ${hash} and state = 'pending' and expires_at > ${now} returning ${invitationColumns}`);
|
|
35
|
+
return rows[0];
|
|
36
|
+
}),
|
|
37
|
+
revokeInvitation: (id) => write(sql `update party_invitation set state = 'revoked' where id = ${id}`),
|
|
38
|
+
insertOrganisation: Effect.fn("PartyStore.postgres.insertOrganisation")(function* (value) {
|
|
39
|
+
yield* UnitOfWork.required;
|
|
40
|
+
const rows = yield* sql `insert into party_organisation (id, name) values (${value.id}, ${value.name})
|
|
41
|
+
on conflict do nothing returning id`.pipe(Effect.mapError(unavailable));
|
|
42
|
+
return rows.length > 0;
|
|
43
|
+
}),
|
|
44
|
+
relate: (r) => write(sql `insert into party_relationship (from_kind, from_id, to_kind, to_id, kind)
|
|
45
|
+
values (${r.from.kind}, ${r.from.id}, ${r.to.kind}, ${r.to.id}, ${r.kind}) on conflict do nothing`),
|
|
46
|
+
unrelate: (r) => write(sql `delete from party_relationship where ${relation(r)}`),
|
|
47
|
+
isRelated: (r) => sql `select 1 from party_relationship where ${relation(r)}`.pipe(Effect.map((rows) => rows.length > 0), Effect.mapError(unavailable)),
|
|
48
|
+
role: (key) => read(Role, sql `select ${roleColumns} from party_role where key = ${key}`).pipe(Effect.map((rows) => rows[0])),
|
|
49
|
+
roles: (organisationIds) => read(Role, sql `select ${roleColumns} from party_role
|
|
50
|
+
where ${organisationIds == null ? sql `true` : sql.in("organisation_id", organisationIds)} order by key`),
|
|
51
|
+
putRole: (r) => write(sql `insert into party_role (key, name, permissions, organisation_id)
|
|
52
|
+
values (${r.key}, ${r.name}, array(select jsonb_array_elements_text(${JSON.stringify(r.permissions)}::jsonb)), ${r.organisationId})
|
|
53
|
+
on conflict (key) do update set name = excluded.name, permissions = excluded.permissions, organisation_id = excluded.organisation_id`),
|
|
54
|
+
deleteRole: Effect.fn("PartyStore.postgres.deleteRole")(function* (key) {
|
|
55
|
+
yield* write(sql `delete from party_assignment where role_key = ${key}`);
|
|
56
|
+
yield* write(sql `delete from party_role where key = ${key}`);
|
|
57
|
+
}),
|
|
58
|
+
assign: (a) => write(sql `insert into party_assignment (person_id, role_key, organisation_id)
|
|
59
|
+
values (${a.personId}, ${a.roleKey}, ${a.organisationId}) on conflict do nothing`),
|
|
60
|
+
revoke: (a) => write(sql `delete from party_assignment where person_id = ${a.personId}
|
|
61
|
+
and role_key = ${a.roleKey} and organisation_id is not distinct from ${a.organisationId}::text`),
|
|
62
|
+
assignments: (id, organisationIds, builtInKeys = []) => read(Assignment, sql `select person_id as "personId", role_key as "roleKey",
|
|
63
|
+
organisation_id as "organisationId" from party_assignment where person_id = ${id}
|
|
64
|
+
and ${organisationIds == null
|
|
65
|
+
? sql `true`
|
|
66
|
+
: sql `((${sql.in("organisation_id", organisationIds)}
|
|
67
|
+
and (${sql.in("role_key", builtInKeys)} or exists
|
|
68
|
+
(select 1 from party_role r where r.key = party_assignment.role_key
|
|
69
|
+
and r.organisation_id = party_assignment.organisation_id)))
|
|
70
|
+
or (organisation_id is null and ${sql.in("role_key", builtInKeys)}))`}
|
|
71
|
+
order by role_key, organisation_id nulls first`),
|
|
72
|
+
});
|
|
73
|
+
}));
|