@kindgi/platform 0.0.0-bootstrap.0 → 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.
Files changed (74) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +41 -1
  3. package/dist/in-memory/index.d.ts +9 -0
  4. package/dist/in-memory/index.d.ts.map +1 -0
  5. package/dist/in-memory/index.js +11 -0
  6. package/dist/in-memory/index.js.map +1 -0
  7. package/dist/in-memory/org-binding.d.ts +6 -0
  8. package/dist/in-memory/org-binding.d.ts.map +1 -0
  9. package/dist/in-memory/org-binding.js +81 -0
  10. package/dist/in-memory/org-binding.js.map +1 -0
  11. package/dist/in-memory/project-binding.d.ts +13 -0
  12. package/dist/in-memory/project-binding.d.ts.map +1 -0
  13. package/dist/in-memory/project-binding.js +286 -0
  14. package/dist/in-memory/project-binding.js.map +1 -0
  15. package/dist/in-memory/team-binding.d.ts +12 -0
  16. package/dist/in-memory/team-binding.d.ts.map +1 -0
  17. package/dist/in-memory/team-binding.js +170 -0
  18. package/dist/in-memory/team-binding.js.map +1 -0
  19. package/dist/in-memory/util.d.ts +27 -0
  20. package/dist/in-memory/util.d.ts.map +1 -0
  21. package/dist/in-memory/util.js +49 -0
  22. package/dist/in-memory/util.js.map +1 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/org-binding.d.ts +70 -0
  28. package/dist/org-binding.d.ts.map +1 -0
  29. package/dist/org-binding.js +4 -0
  30. package/dist/org-binding.js.map +1 -0
  31. package/dist/project-binding.d.ts +115 -0
  32. package/dist/project-binding.d.ts.map +1 -0
  33. package/dist/project-binding.js +4 -0
  34. package/dist/project-binding.js.map +1 -0
  35. package/dist/scope.d.ts +45 -0
  36. package/dist/scope.d.ts.map +1 -0
  37. package/dist/scope.js +13 -0
  38. package/dist/scope.js.map +1 -0
  39. package/dist/team-binding.d.ts +108 -0
  40. package/dist/team-binding.d.ts.map +1 -0
  41. package/dist/team-binding.js +4 -0
  42. package/dist/team-binding.js.map +1 -0
  43. package/dist/team-project-grant-binding.d.ts +49 -0
  44. package/dist/team-project-grant-binding.d.ts.map +1 -0
  45. package/dist/team-project-grant-binding.js +4 -0
  46. package/dist/team-project-grant-binding.js.map +1 -0
  47. package/dist/tenant-hierarchy-binding.d.ts +104 -0
  48. package/dist/tenant-hierarchy-binding.d.ts.map +1 -0
  49. package/dist/tenant-hierarchy-binding.js +4 -0
  50. package/dist/tenant-hierarchy-binding.js.map +1 -0
  51. package/dist/types.d.ts +129 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +6 -0
  54. package/dist/types.js.map +1 -0
  55. package/package.json +49 -4
  56. package/src/in-memory/index.ts +12 -0
  57. package/src/in-memory/org-binding.ts +101 -0
  58. package/src/in-memory/project-binding.ts +327 -0
  59. package/src/in-memory/team-binding.ts +205 -0
  60. package/src/in-memory/util.ts +72 -0
  61. package/src/index.ts +56 -0
  62. package/src/org-binding.ts +76 -0
  63. package/src/project-binding.ts +134 -0
  64. package/src/scope.ts +51 -0
  65. package/src/team-binding.ts +116 -0
  66. package/src/team-project-grant-binding.ts +65 -0
  67. package/src/tenant-hierarchy-binding.ts +107 -0
  68. package/src/types.ts +177 -0
  69. package/tests/in-memory-org-binding.test.ts +12 -0
  70. package/tests/in-memory-project-binding.test.ts +12 -0
  71. package/tests/in-memory-team-binding.test.ts +12 -0
  72. package/tests/org-binding.conformance.ts +165 -0
  73. package/tests/project-binding.conformance.ts +231 -0
  74. package/tests/team-binding.conformance.ts +175 -0
@@ -0,0 +1,231 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Conformance suite for `ProjectBinding` + `ProjectMembershipBinding`
6
+ * + `TeamProjectGrantBinding` (the three project-related bindings the
7
+ * in-memory reference adapter co-locates in one factory).
8
+ *
9
+ * Cases covered:
10
+ * - CRUD happy paths.
11
+ * - `getDefault` returns the tenant's Default row when `isDefault=true`.
12
+ * - `isDefault` uniqueness guardrail — creating a second Default in
13
+ * the same tenant rejects (in-memory adapter enforces at write
14
+ * time; Postgres impl enforces via partial-unique-index).
15
+ * - `listForUser` aggregates memberships across projects.
16
+ * - Cross-tenant isolation on every reachable read.
17
+ */
18
+
19
+ import { describe, expect, it } from 'vitest';
20
+
21
+ import type { OrgId, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
22
+
23
+ import type { ProjectBinding, ProjectMembershipBinding } from '../src/project-binding.js';
24
+ import type { TeamProjectGrantBinding } from '../src/team-project-grant-binding.js';
25
+
26
+ const T1 = 'tenant-1' as TenantId;
27
+ const T2 = 'tenant-2' as TenantId;
28
+ const U1 = 'user-1' as UserId;
29
+ const U2 = 'user-2' as UserId;
30
+ const TEAM_A = 'team-A' as TeamId;
31
+ const TEAM_B = 'team-B' as TeamId;
32
+ const ORG_A = 'org-A' as OrgId;
33
+
34
+ export function runProjectBindingConformance(
35
+ makeBinding: () => {
36
+ readonly projects: ProjectBinding;
37
+ readonly memberships: ProjectMembershipBinding;
38
+ readonly grants: TeamProjectGrantBinding;
39
+ },
40
+ label = 'ProjectBinding',
41
+ ): void {
42
+ describe(`${label} — project CRUD`, () => {
43
+ it('creates + gets a project', async () => {
44
+ const { projects } = makeBinding();
45
+ const id = await projects.create(T1, { name: 'Matter #1', slug: 'm1' });
46
+ const got = await projects.get(T1, id);
47
+ expect(got?.name).toBe('Matter #1');
48
+ expect(got?.isDefault).toBe(false);
49
+ });
50
+
51
+ it('lists projects filtered by orgId', async () => {
52
+ const { projects } = makeBinding();
53
+ await projects.create(T1, { name: 'A', slug: 'a', orgId: ORG_A });
54
+ await projects.create(T1, { name: 'B', slug: 'b' });
55
+ const inOrg = await projects.list(T1, { orgId: ORG_A });
56
+ expect(inOrg.items).toHaveLength(1);
57
+ expect(inOrg.items[0]?.name).toBe('A');
58
+ });
59
+
60
+ it('updates a project', async () => {
61
+ const { projects } = makeBinding();
62
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
63
+ await projects.update(T1, id, { name: 'A-updated' });
64
+ const got = await projects.get(T1, id);
65
+ expect(got?.name).toBe('A-updated');
66
+ });
67
+
68
+ it('deletes a project + cascades memberships + grants', async () => {
69
+ const { projects, memberships, grants } = makeBinding();
70
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
71
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
72
+ await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
73
+ await projects.delete(T1, id);
74
+ const mPage = await memberships.listForUser(T1, U1, {});
75
+ expect(mPage.items).toHaveLength(0);
76
+ const gPage = await grants.listForTeam(T1, TEAM_A, {});
77
+ expect(gPage.items).toHaveLength(0);
78
+ });
79
+ });
80
+
81
+ describe(`${label} — Default project`, () => {
82
+ it('getDefault returns undefined before any Default is created', async () => {
83
+ const { projects } = makeBinding();
84
+ expect(await projects.getDefault(T1)).toBeUndefined();
85
+ });
86
+
87
+ it('getDefault returns the isDefault=true row', async () => {
88
+ const { projects } = makeBinding();
89
+ const id = await projects.create(T1, {
90
+ name: 'Default',
91
+ slug: 'default',
92
+ isDefault: true,
93
+ });
94
+ const got = await projects.getDefault(T1);
95
+ expect(got).toBeDefined();
96
+ expect(got?.id).toBe(id);
97
+ expect(got?.isDefault).toBe(true);
98
+ });
99
+
100
+ it('isDefault uniqueness — a second Default in the same tenant errors', async () => {
101
+ const { projects } = makeBinding();
102
+ await projects.create(T1, { name: 'D1', slug: 'd1', isDefault: true });
103
+ await expect(
104
+ projects.create(T1, { name: 'D2', slug: 'd2', isDefault: true }),
105
+ ).rejects.toThrow();
106
+ });
107
+
108
+ it('a Default in tenant A does NOT block a Default in tenant B', async () => {
109
+ const { projects } = makeBinding();
110
+ await projects.create(T1, { name: 'D1', slug: 'd1', isDefault: true });
111
+ const d2 = await projects.create(T2, {
112
+ name: 'D2',
113
+ slug: 'd2',
114
+ isDefault: true,
115
+ });
116
+ const got = await projects.getDefault(T2);
117
+ expect(got?.id).toBe(d2);
118
+ });
119
+ });
120
+
121
+ describe(`${label} — cross-tenant isolation`, () => {
122
+ it('project get across tenants returns undefined', async () => {
123
+ const { projects } = makeBinding();
124
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
125
+ expect(await projects.get(T2, id)).toBeUndefined();
126
+ });
127
+
128
+ it('project list across tenants does not leak', async () => {
129
+ const { projects } = makeBinding();
130
+ await projects.create(T1, { name: 'A', slug: 'a' });
131
+ const page = await projects.list(T2, {});
132
+ expect(page.items).toEqual([]);
133
+ });
134
+
135
+ it('project-membership add under wrong tenant errors', async () => {
136
+ const { projects, memberships } = makeBinding();
137
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
138
+ await expect(
139
+ memberships.add(T2, { projectId: id, userId: U1, role: 'editor' }),
140
+ ).rejects.toThrow();
141
+ });
142
+
143
+ it('team-project grant add under wrong tenant errors', async () => {
144
+ const { projects, grants } = makeBinding();
145
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
146
+ await expect(
147
+ grants.add(T2, { teamId: TEAM_A, projectId: id, role: 'editor' }),
148
+ ).rejects.toThrow();
149
+ });
150
+
151
+ it('listForUser across tenants only returns tenant-scoped rows', async () => {
152
+ const { projects, memberships } = makeBinding();
153
+ const p1 = await projects.create(T1, { name: 'A', slug: 'a' });
154
+ const p2 = await projects.create(T2, { name: 'B', slug: 'b' });
155
+ await memberships.add(T1, { projectId: p1, userId: U1, role: 'editor' });
156
+ await memberships.add(T2, { projectId: p2, userId: U1, role: 'editor' });
157
+ const t1Page = await memberships.listForUser(T1, U1, {});
158
+ expect(t1Page.items).toHaveLength(1);
159
+ expect(t1Page.items[0]?.projectId).toBe(p1);
160
+ });
161
+ });
162
+
163
+ describe(`${label} — membership + grant idempotency`, () => {
164
+ it('project-membership add is idempotent (does not overwrite role)', async () => {
165
+ const { projects, memberships } = makeBinding();
166
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
167
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' });
168
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'viewer' });
169
+ const page = await memberships.list(T1, id, {});
170
+ expect(page.items).toHaveLength(1);
171
+ expect(page.items[0]?.role).toBe('owner');
172
+ });
173
+
174
+ it('grant add is idempotent', async () => {
175
+ const { projects, grants } = makeBinding();
176
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
177
+ await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'owner' });
178
+ await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'viewer' });
179
+ const page = await grants.listForProject(T1, id, {});
180
+ expect(page.items).toHaveLength(1);
181
+ expect(page.items[0]?.role).toBe('owner');
182
+ });
183
+
184
+ it('grant updateRole mutates the role', async () => {
185
+ const { projects, grants } = makeBinding();
186
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
187
+ await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
188
+ await grants.updateRole(T1, TEAM_A, id, 'owner');
189
+ const page = await grants.listForTeam(T1, TEAM_A, {});
190
+ expect(page.items[0]?.role).toBe('owner');
191
+ });
192
+ });
193
+
194
+ describe(`${label} — listForUser aggregates across projects`, () => {
195
+ it('returns every direct-grant project the user has', async () => {
196
+ const { projects, memberships } = makeBinding();
197
+ const a = await projects.create(T1, { name: 'A', slug: 'a' });
198
+ const b = await projects.create(T1, { name: 'B', slug: 'b' });
199
+ const c = await projects.create(T1, { name: 'C', slug: 'c' });
200
+ await memberships.add(T1, { projectId: a, userId: U1, role: 'editor' });
201
+ await memberships.add(T1, { projectId: b, userId: U1, role: 'viewer' });
202
+ await memberships.add(T1, { projectId: c, userId: U2, role: 'editor' });
203
+ const page = await memberships.listForUser(T1, U1, {});
204
+ expect(page.items).toHaveLength(2);
205
+ const projIds = new Set<ProjectId>(page.items.map((m) => m.projectId));
206
+ expect(projIds.has(a)).toBe(true);
207
+ expect(projIds.has(b)).toBe(true);
208
+ });
209
+ });
210
+
211
+ describe(`${label} — grant listForProject + listForTeam`, () => {
212
+ it('listForProject returns every team-grant on the project', async () => {
213
+ const { projects, grants } = makeBinding();
214
+ const id = await projects.create(T1, { name: 'A', slug: 'a' });
215
+ await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
216
+ await grants.add(T1, { teamId: TEAM_B, projectId: id, role: 'viewer' });
217
+ const page = await grants.listForProject(T1, id, {});
218
+ expect(page.items).toHaveLength(2);
219
+ });
220
+
221
+ it('listForTeam returns every project the team has a grant on', async () => {
222
+ const { projects, grants } = makeBinding();
223
+ const a = await projects.create(T1, { name: 'A', slug: 'a' });
224
+ const b = await projects.create(T1, { name: 'B', slug: 'b' });
225
+ await grants.add(T1, { teamId: TEAM_A, projectId: a, role: 'editor' });
226
+ await grants.add(T1, { teamId: TEAM_A, projectId: b, role: 'viewer' });
227
+ const page = await grants.listForTeam(T1, TEAM_A, {});
228
+ expect(page.items).toHaveLength(2);
229
+ });
230
+ });
231
+ }
@@ -0,0 +1,175 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Conformance suite for `TeamBinding` + `TeamMembershipBinding`.
6
+ *
7
+ * Cases covered:
8
+ * - CRUD happy paths for both team + membership shapes.
9
+ * - Team creation with + without `orgId` (nullable FK).
10
+ * - Membership `add` idempotency — adding an existing member does not
11
+ * silently mutate a stored differing role (mutation goes through
12
+ * `updateRole`).
13
+ * - `listForUser` returns memberships across teams within a tenant.
14
+ * - Cross-tenant isolation on every reachable read.
15
+ */
16
+
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import type { OrgId, TeamId, TenantId, UserId } from '@kindgi/types';
20
+
21
+ import type { TeamBinding, TeamMembershipBinding } from '../src/team-binding.js';
22
+
23
+ const T1 = 'tenant-1' as TenantId;
24
+ const T2 = 'tenant-2' as TenantId;
25
+ const U1 = 'user-1' as UserId;
26
+ const U2 = 'user-2' as UserId;
27
+ const ORG_A = 'org-a' as OrgId;
28
+
29
+ export function runTeamBindingConformance(
30
+ makeBinding: () => {
31
+ readonly teams: TeamBinding;
32
+ readonly memberships: TeamMembershipBinding;
33
+ },
34
+ label = 'TeamBinding',
35
+ ): void {
36
+ describe(`${label} — team CRUD`, () => {
37
+ it('creates + gets a team with orgId', async () => {
38
+ const { teams } = makeBinding();
39
+ const id = await teams.create(T1, {
40
+ name: 'Litigation',
41
+ slug: 'lit',
42
+ orgId: ORG_A,
43
+ });
44
+ const got = await teams.get(T1, id);
45
+ expect(got).toBeDefined();
46
+ expect(got?.name).toBe('Litigation');
47
+ expect(got?.orgId).toBe(ORG_A);
48
+ });
49
+
50
+ it('creates a cross-org team (no orgId)', async () => {
51
+ const { teams } = makeBinding();
52
+ const id = await teams.create(T1, { name: 'Cross', slug: 'cross' });
53
+ const got = await teams.get(T1, id);
54
+ expect(got?.orgId).toBeUndefined();
55
+ });
56
+
57
+ it('updates a team; orgId=null clears the FK', async () => {
58
+ const { teams } = makeBinding();
59
+ const id = await teams.create(T1, {
60
+ name: 'Litigation',
61
+ slug: 'lit',
62
+ orgId: ORG_A,
63
+ });
64
+ await teams.update(T1, id, { orgId: null });
65
+ const got = await teams.get(T1, id);
66
+ expect(got?.orgId).toBeUndefined();
67
+ });
68
+
69
+ it('lists teams filtered by orgId', async () => {
70
+ const { teams } = makeBinding();
71
+ await teams.create(T1, { name: 'A', slug: 'a', orgId: ORG_A });
72
+ await teams.create(T1, { name: 'B', slug: 'b' });
73
+ const inOrg = await teams.list(T1, { orgId: ORG_A });
74
+ expect(inOrg.items).toHaveLength(1);
75
+ expect(inOrg.items[0]?.name).toBe('A');
76
+ });
77
+
78
+ it('deletes a team + cascades memberships', async () => {
79
+ const { teams, memberships } = makeBinding();
80
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
81
+ await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
82
+ await teams.delete(T1, id);
83
+ const page = await memberships.listForUser(T1, U1, {});
84
+ expect(page.items).toHaveLength(0);
85
+ });
86
+ });
87
+
88
+ describe(`${label} — cross-tenant isolation`, () => {
89
+ it('team get across tenants returns undefined', async () => {
90
+ const { teams } = makeBinding();
91
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
92
+ expect(await teams.get(T2, id)).toBeUndefined();
93
+ });
94
+
95
+ it('team list across tenants does not leak', async () => {
96
+ const { teams } = makeBinding();
97
+ await teams.create(T1, { name: 'X', slug: 'x' });
98
+ const page = await teams.list(T2, {});
99
+ expect(page.items).toEqual([]);
100
+ });
101
+
102
+ it('adding a membership under wrong tenant errors', async () => {
103
+ const { teams, memberships } = makeBinding();
104
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
105
+ await expect(
106
+ memberships.add(T2, { teamId: id, userId: U1, role: 'member' }),
107
+ ).rejects.toThrow();
108
+ });
109
+
110
+ it('listForUser across tenants only returns tenant-scoped memberships', async () => {
111
+ const { teams, memberships } = makeBinding();
112
+ const t1Team = await teams.create(T1, { name: 'A', slug: 'a' });
113
+ const t2Team = await teams.create(T2, { name: 'B', slug: 'b' });
114
+ await memberships.add(T1, { teamId: t1Team, userId: U1, role: 'member' });
115
+ await memberships.add(T2, { teamId: t2Team, userId: U1, role: 'member' });
116
+ const t1Page = await memberships.listForUser(T1, U1, {});
117
+ expect(t1Page.items).toHaveLength(1);
118
+ expect(t1Page.items[0]?.teamId).toBe(t1Team);
119
+ const t2Page = await memberships.listForUser(T2, U1, {});
120
+ expect(t2Page.items).toHaveLength(1);
121
+ expect(t2Page.items[0]?.teamId).toBe(t2Team);
122
+ });
123
+ });
124
+
125
+ describe(`${label} — membership idempotency`, () => {
126
+ it('add on existing membership is a no-op (does NOT overwrite role)', async () => {
127
+ const { teams, memberships } = makeBinding();
128
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
129
+ await memberships.add(T1, { teamId: id, userId: U1, role: 'admin' });
130
+ // Re-add with a different role — should be a no-op, not a mutation.
131
+ await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
132
+ const page = await memberships.list(T1, id, {});
133
+ expect(page.items).toHaveLength(1);
134
+ expect(page.items[0]?.role).toBe('admin');
135
+ });
136
+
137
+ it('remove on missing membership is a no-op', async () => {
138
+ const { teams, memberships } = makeBinding();
139
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
140
+ await expect(memberships.remove(T1, id, U1)).resolves.toBeUndefined();
141
+ });
142
+
143
+ it('updateRole on missing membership errors', async () => {
144
+ const { teams, memberships } = makeBinding();
145
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
146
+ await expect(memberships.updateRole(T1, id, U1, 'admin')).rejects.toThrow();
147
+ });
148
+
149
+ it('updateRole on existing membership mutates the role', async () => {
150
+ const { teams, memberships } = makeBinding();
151
+ const id = await teams.create(T1, { name: 'X', slug: 'x' });
152
+ await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
153
+ await memberships.updateRole(T1, id, U1, 'admin');
154
+ const page = await memberships.list(T1, id, {});
155
+ expect(page.items[0]?.role).toBe('admin');
156
+ });
157
+ });
158
+
159
+ describe(`${label} — listForUser across teams`, () => {
160
+ it('returns every team the user belongs to in the tenant', async () => {
161
+ const { teams, memberships } = makeBinding();
162
+ const a = await teams.create(T1, { name: 'A', slug: 'a' });
163
+ const b = await teams.create(T1, { name: 'B', slug: 'b' });
164
+ const c = await teams.create(T1, { name: 'C', slug: 'c' });
165
+ await memberships.add(T1, { teamId: a, userId: U1, role: 'member' });
166
+ await memberships.add(T1, { teamId: b, userId: U1, role: 'admin' });
167
+ await memberships.add(T1, { teamId: c, userId: U2, role: 'member' });
168
+ const page = await memberships.listForUser(T1, U1, {});
169
+ expect(page.items).toHaveLength(2);
170
+ const teamIds = new Set<TeamId>(page.items.map((m) => m.teamId));
171
+ expect(teamIds.has(a)).toBe(true);
172
+ expect(teamIds.has(b)).toBe(true);
173
+ });
174
+ });
175
+ }