@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,116 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `TeamBinding` + `TeamMembershipBinding` — the team CRUD surface + the
6
+ * user↔team membership surface within a tenant.
7
+ *
8
+ * Team memberships are **framework-owned**: there is no external
9
+ * directory sync. `TeamMembershipBinding` is the sole source of
10
+ * truth.
11
+ *
12
+ * Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
13
+ * return, `Filter`-extending filter shapes.
14
+ */
15
+
16
+ import type { Filter, OrgId, Page, TeamId, TenantId, UserId } from '@kindgi/types';
17
+
18
+ import type { Team, TeamMembership, TeamPatch, TeamRole, TeamSpec } from './types.js';
19
+
20
+ /**
21
+ * `list` filter for teams. `orgId` narrows to teams belonging to a
22
+ * specific org (the `Team.orgId?` nullable FK); absent =
23
+ * no org filter, including cross-org teams. `nameContains` mirrors
24
+ * the org shape.
25
+ */
26
+ export interface TeamListFilter extends Filter {
27
+ readonly orgId?: OrgId;
28
+ readonly nameContains?: string;
29
+ }
30
+
31
+ /**
32
+ * Input for `TeamMembershipBinding.add`. Distinct interface (not
33
+ * positional args) so downstream additions — e.g., an `addedBy?:
34
+ * UserId` audit field added later — extend without breaking
35
+ * every call site.
36
+ */
37
+ export interface TeamMembershipAddInput {
38
+ readonly teamId: TeamId;
39
+ readonly userId: UserId;
40
+ readonly role: TeamRole;
41
+ }
42
+
43
+ /**
44
+ * The team CRUD binding.
45
+ */
46
+ export interface TeamBinding {
47
+ /**
48
+ * Create a `Team` within the tenant. `spec.orgId` optional (a team
49
+ * may be cross-org — nullable FK). Returns the assigned
50
+ * `TeamId`.
51
+ */
52
+ create(tenantId: TenantId, spec: TeamSpec): Promise<TeamId>;
53
+ /**
54
+ * Look up a `Team` by id. `undefined` when unknown or in a different
55
+ * tenant.
56
+ */
57
+ get(tenantId: TenantId, teamId: TeamId): Promise<Team | undefined>;
58
+ /**
59
+ * Cursor-paginated list of teams. Filter by `orgId` (narrow to one
60
+ * org's teams) or `nameContains` (substring match on `Team.name`).
61
+ */
62
+ list(tenantId: TenantId, filter: TeamListFilter): Promise<Page<Team>>;
63
+ /**
64
+ * Partially update a `Team`. `TeamPatch.orgId` supports `null` to
65
+ * explicitly re-assign the team out of any org (cross-org), and
66
+ * `undefined` to leave the FK untouched.
67
+ */
68
+ update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<void>;
69
+ /**
70
+ * Delete a `Team`. Its membership rows are removed with it (the
71
+ * in-memory adapter deletes them from its map).
72
+ */
73
+ delete(tenantId: TenantId, teamId: TeamId): Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * User↔team membership CRUD. Typical consumers: identity views that
78
+ * show a user's teams, and authorization, where the `team:T#member`
79
+ * userset (see `@kindgi/authz`) gives members access to projects
80
+ * through a team-project grant.
81
+ */
82
+ export interface TeamMembershipBinding {
83
+ /**
84
+ * Add a user to a team with a given role. Idempotent on the
85
+ * `(teamId, userId)` primary key — re-adding the same user does not
86
+ * duplicate the row. To change an existing member's role use
87
+ * `updateRole` (semantics are explicit; add-with-role does not
88
+ * silently mutate a differing role).
89
+ *
90
+ * The conformance suite pins this: adding an existing member whose
91
+ * stored role differs is a no-op (not a role overwrite).
92
+ */
93
+ add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<void>;
94
+ /**
95
+ * Remove a user from a team. Missing membership is a no-op (not an
96
+ * error) — matches every peer platform's membership-remove shape.
97
+ */
98
+ remove(tenantId: TenantId, teamId: TeamId, userId: UserId): Promise<void>;
99
+ /**
100
+ * Cursor-paginated memberships of a team. Ordered by `joinedAt`
101
+ * ascending; the conformance suite pins this ordering.
102
+ */
103
+ list(tenantId: TenantId, teamId: TeamId, filter: Filter): Promise<Page<TeamMembership>>;
104
+ /**
105
+ * Cursor-paginated memberships of a user (across every team the
106
+ * user belongs to within the tenant). Used to resolve a user's team
107
+ * set (identity views, authorization inheritance).
108
+ */
109
+ listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<TeamMembership>>;
110
+ /**
111
+ * Update an existing membership's role. Errors when the membership
112
+ * doesn't exist — role mutation of a non-member is a caller mistake,
113
+ * not a silent add. Conformance suite pins the not-found behaviour.
114
+ */
115
+ updateRole(tenantId: TenantId, teamId: TeamId, userId: UserId, role: TeamRole): Promise<void>;
116
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `TeamProjectGrant` — the team↔project join surface. One row per
6
+ * `(teamId, projectId)` primary key; the `role` names the project-
7
+ * scope grant every team member inherits via the FGA `team:T#member`
8
+ * userset (see `@kindgi/authz`).
9
+ *
10
+ * Same tenant-first convention as every other binding in
11
+ * `@kindgi/platform`. The in-memory reference implementation is part
12
+ * of `makeInMemoryProjectBinding` (its `grants` binding).
13
+ */
14
+
15
+ import type { Filter, Page, ProjectId, TeamId, TenantId } from '@kindgi/types';
16
+
17
+ import type { ProjectRole } from './types.js';
18
+
19
+ /**
20
+ * A team-mediated grant on a project. The `role` uses the same
21
+ * `ProjectRole` union as direct `ProjectMembership.role` — semantics
22
+ * compose (the authorization model resolves the union / inheritance).
23
+ */
24
+ export interface TeamProjectGrant {
25
+ readonly teamId: TeamId;
26
+ readonly projectId: ProjectId;
27
+ readonly role: ProjectRole;
28
+ }
29
+
30
+ export interface TeamProjectGrantAddInput {
31
+ readonly teamId: TeamId;
32
+ readonly projectId: ProjectId;
33
+ readonly role: ProjectRole;
34
+ }
35
+
36
+ /**
37
+ * CRUD for team↔project grants.
38
+ */
39
+ export interface TeamProjectGrantBinding {
40
+ /**
41
+ * Add a team-project grant. Idempotent on `(teamId, projectId)` —
42
+ * re-adding the same team on the same project does not mutate a
43
+ * stored differing role. Use `updateRole` to change roles.
44
+ */
45
+ add(tenantId: TenantId, input: TeamProjectGrantAddInput): Promise<void>;
46
+ /** Remove a team-project grant. No-op when absent. */
47
+ remove(tenantId: TenantId, teamId: TeamId, projectId: ProjectId): Promise<void>;
48
+ /** Cursor-paginated grants filtered by tenant. */
49
+ list(tenantId: TenantId, filter: Filter): Promise<Page<TeamProjectGrant>>;
50
+ /** Cursor-paginated grants for a specific team. */
51
+ listForTeam(tenantId: TenantId, teamId: TeamId, filter: Filter): Promise<Page<TeamProjectGrant>>;
52
+ /** Cursor-paginated grants for a specific project. */
53
+ listForProject(
54
+ tenantId: TenantId,
55
+ projectId: ProjectId,
56
+ filter: Filter,
57
+ ): Promise<Page<TeamProjectGrant>>;
58
+ /** Update a grant's role. Errors when the grant doesn't exist. */
59
+ updateRole(
60
+ tenantId: TenantId,
61
+ teamId: TeamId,
62
+ projectId: ProjectId,
63
+ role: ProjectRole,
64
+ ): Promise<void>;
65
+ }
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // TenantHierarchyBinding — the public shape for Org/Team/Project CRUD
6
+ // operations that write authz tuples in the same transaction as the
7
+ // entity insert. Implementations are supplied by the deployment and
8
+ // plugged in via `CreateAppInput.tenantHierarchyBinding` in
9
+ // `@kindgi/api`.
10
+ //
11
+ // The `Params` shapes are self-contained — reference only public
12
+ // types from @kindgi/{platform,types}. Error shapes are structural
13
+ // so callers don't need to import an Error type from any
14
+ // implementation.
15
+ //
16
+
17
+ import type { OrgId, ProjectId, Result, TeamId, TenantId, UserId } from '@kindgi/types';
18
+ import type { OrgSpec, ProjectRole, ProjectSpec, TeamRole, TeamSpec } from './types.js';
19
+
20
+ export interface CreateOrgParams {
21
+ readonly tenantId: TenantId;
22
+ readonly creatorUserId: UserId;
23
+ readonly spec: OrgSpec;
24
+ }
25
+
26
+ export type CreateOrgError =
27
+ | { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
28
+ | { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
29
+
30
+ export interface CreateTeamParams {
31
+ readonly tenantId: TenantId;
32
+ readonly creatorUserId: UserId;
33
+ readonly spec: TeamSpec;
34
+ }
35
+
36
+ export type CreateTeamError =
37
+ | { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
38
+ | { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
39
+
40
+ export interface AddTeamMemberParams {
41
+ readonly tenantId: TenantId;
42
+ readonly teamId: TeamId;
43
+ readonly userId: UserId;
44
+ readonly role: TeamRole;
45
+ }
46
+
47
+ export interface CreateProjectParams {
48
+ readonly tenantId: TenantId;
49
+ readonly creatorUserId: UserId;
50
+ readonly spec: ProjectSpec;
51
+ }
52
+
53
+ export type CreateProjectError =
54
+ | { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
55
+ | { readonly code: 'default-conflict'; readonly message: string; readonly cause?: unknown }
56
+ | { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
57
+
58
+ export interface AddProjectMemberParams {
59
+ readonly tenantId: TenantId;
60
+ readonly projectId: ProjectId;
61
+ readonly userId: UserId;
62
+ readonly role: ProjectRole;
63
+ }
64
+
65
+ export interface MembershipMutationError {
66
+ readonly code: string;
67
+ readonly message: string;
68
+ }
69
+
70
+ /**
71
+ * Tenant-summary read shape returned by `getTenant`. Only the fields
72
+ * the `/v1/tenant` GET route surfaces — id / name / slug / createdAt /
73
+ * updatedAt. Timestamps are ISO 8601 strings; impls that hold Date
74
+ * objects convert at the boundary.
75
+ */
76
+ export interface TenantSummary {
77
+ readonly id: TenantId;
78
+ readonly name: string;
79
+ readonly slug: string;
80
+ readonly createdAt: string;
81
+ readonly updatedAt: string;
82
+ }
83
+
84
+ /**
85
+ * Tenant-hierarchy CRUD binding. Every route that creates orgs /
86
+ * teams / projects or adds members writes atomically: entity insert
87
+ * + FGA parent/owner tuples in the same transaction.
88
+ *
89
+ * Deployments supply the implementation; tests can substitute a mock.
90
+ */
91
+ export interface TenantHierarchyBinding {
92
+ createOrg(params: CreateOrgParams): Promise<Result<{ readonly orgId: OrgId }, CreateOrgError>>;
93
+ createTeam(
94
+ params: CreateTeamParams,
95
+ ): Promise<Result<{ readonly teamId: TeamId }, CreateTeamError>>;
96
+ createProject(
97
+ params: CreateProjectParams,
98
+ ): Promise<Result<{ readonly projectId: ProjectId }, CreateProjectError>>;
99
+ addTeamMember(params: AddTeamMemberParams): Promise<Result<void, MembershipMutationError>>;
100
+ addProjectMember(params: AddProjectMemberParams): Promise<Result<void, MembershipMutationError>>;
101
+ /**
102
+ * Look up a tenant by id. Returns `null` when no tenant exists.
103
+ * Not itself tenant-scoped: it is how a caller finds out which
104
+ * tenant it is operating as.
105
+ */
106
+ getTenant(tenantId: TenantId): Promise<TenantSummary | null>;
107
+ }
package/src/types.ts ADDED
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Multi-tenant hierarchy primitives — the type surface for the
6
+ * `Tenant → Org → Team → Project + User` flow.
7
+ *
8
+ * **Pure type shapes.** No runtime code, no validation, no defaults.
9
+ * Binding interfaces live in the sibling `*-binding.ts` files;
10
+ * reference in-memory adapters live under `in-memory/`.
11
+ *
12
+ * Branded IDs are imported from `@kindgi/types` — this package
13
+ * adds no new IDs. `Tenant` itself has no full shape here (only the
14
+ * read-only `TenantSummary` in `tenant-hierarchy-binding.ts`); this
15
+ * package models the hierarchy that sits *under* the tenant.
16
+ */
17
+
18
+ import type { OrgId, ProjectId, TeamId, TenantId, Timestamp, UserId } from '@kindgi/types';
19
+
20
+ // ============================================================
21
+ // Org — optional structural subdivision within a tenant.
22
+ // A firm's litigation department; a hospital's cardiology; a bank's
23
+ // commercial-lending LOB. Small tenants ignore Org entirely (no rows,
24
+ // no reference on child entities). Large tenants use Org to enforce
25
+ // structural boundaries.
26
+ // ============================================================
27
+
28
+ export interface Org {
29
+ readonly id: OrgId;
30
+ readonly tenantId: TenantId;
31
+ readonly name: string;
32
+ readonly slug: string;
33
+ readonly createdAt: Timestamp;
34
+ readonly updatedAt: Timestamp;
35
+ }
36
+
37
+ export interface OrgSpec {
38
+ readonly name: string;
39
+ readonly slug: string;
40
+ }
41
+
42
+ export interface OrgPatch {
43
+ readonly name?: string;
44
+ readonly slug?: string;
45
+ }
46
+
47
+ // ============================================================
48
+ // Team — people-group. A named collection of users who work together.
49
+ // A matter team; a care team; a deal team. Users belong to many teams.
50
+ // Same team works on many projects; same project can have many teams
51
+ // collaborating. Distinct from Org (structural) — Team is about
52
+ // *working groups*, which cross Org boundaries. Team↔Project is N..N
53
+ // via `TeamProjectGrant`.
54
+ // ============================================================
55
+
56
+ export interface Team {
57
+ readonly id: TeamId;
58
+ readonly tenantId: TenantId;
59
+ /**
60
+ * Optional — a team may belong to an org, or be cross-org (no
61
+ * `orgId`).
62
+ */
63
+ readonly orgId?: OrgId;
64
+ readonly name: string;
65
+ readonly slug: string;
66
+ readonly description?: string;
67
+ readonly createdAt: Timestamp;
68
+ readonly updatedAt: Timestamp;
69
+ }
70
+
71
+ export interface TeamSpec {
72
+ readonly name: string;
73
+ readonly slug: string;
74
+ readonly description?: string;
75
+ readonly orgId?: OrgId;
76
+ }
77
+
78
+ export interface TeamPatch {
79
+ readonly name?: string;
80
+ readonly slug?: string;
81
+ readonly description?: string;
82
+ /**
83
+ * `null` explicitly re-assigns the team out of any org (cross-org).
84
+ * `undefined` leaves the FK untouched.
85
+ */
86
+ readonly orgId?: OrgId | null;
87
+ }
88
+
89
+ /**
90
+ * Roles on a `TeamMembership`. Aligned with org/project vocabulary:
91
+ * `admin` + `member`. Additive with project-level grants: a user in
92
+ * three teams has the union of the three teams' project grants.
93
+ * Framework-owned — no external directory sync.
94
+ */
95
+ export type TeamRole = 'member' | 'admin';
96
+
97
+ export interface TeamMembership {
98
+ readonly teamId: TeamId;
99
+ readonly userId: UserId;
100
+ readonly role: TeamRole;
101
+ readonly joinedAt: Timestamp;
102
+ }
103
+
104
+ // ============================================================
105
+ // Project — the primary content-scope. Facts, artifacts, runs, blobs,
106
+ // evidence, approvals belong to a Project. Vertical-agnostic on the
107
+ // wire; verticalized in packs (`Matter` in legal, `Case` in HR,
108
+ // `Engagement` in accounting, `Episode` in healthcare, `Deal` in
109
+ // finance).
110
+ //
111
+ // `isDefault` marks the auto-created "Default" project per tenant.
112
+ // Implementations keep exactly one per tenant (the in-memory adapter
113
+ // rejects a second Default on `create`).
114
+ // ============================================================
115
+
116
+ export interface Project {
117
+ readonly id: ProjectId;
118
+ readonly tenantId: TenantId;
119
+ /**
120
+ * Optional — a project may belong to an org, or be cross-org (no
121
+ * `orgId`).
122
+ */
123
+ readonly orgId?: OrgId;
124
+ readonly name: string;
125
+ readonly slug: string;
126
+ /**
127
+ * `true` for the tenant's auto-created "Default" project. Exactly
128
+ * one row per tenant carries `isDefault = true`.
129
+ * `false` for every user-created project.
130
+ */
131
+ readonly isDefault: boolean;
132
+ readonly description?: string;
133
+ readonly createdAt: Timestamp;
134
+ readonly updatedAt: Timestamp;
135
+ }
136
+
137
+ export interface ProjectSpec {
138
+ readonly name: string;
139
+ readonly slug: string;
140
+ readonly description?: string;
141
+ readonly orgId?: OrgId;
142
+ /**
143
+ * Optional at spec time — the tenant's Default project is created
144
+ * by the framework at tenant-inception, not by a caller. User
145
+ * projects created via `POST /v1/projects` default to `false`.
146
+ */
147
+ readonly isDefault?: boolean;
148
+ }
149
+
150
+ export interface ProjectPatch {
151
+ readonly name?: string;
152
+ readonly slug?: string;
153
+ readonly description?: string;
154
+ /**
155
+ * `null` explicitly re-assigns the project out of any org.
156
+ * `undefined` leaves the FK untouched.
157
+ */
158
+ readonly orgId?: OrgId | null;
159
+ }
160
+
161
+ /**
162
+ * Roles on a `ProjectMembership` — the direct `User → Project` grant
163
+ * shape. Additive with team-grants. Mirrors the OpenFGA project-type
164
+ * relations (owner / editor / viewer) plus the
165
+ * lightweight `member` and administrative `admin` shorthands.
166
+ */
167
+ export type ProjectRole = 'viewer' | 'editor' | 'owner' | 'admin' | 'member';
168
+
169
+ export interface ProjectMembership {
170
+ readonly projectId: ProjectId;
171
+ readonly userId: UserId;
172
+ readonly role: ProjectRole;
173
+ readonly joinedAt: Timestamp;
174
+ }
175
+
176
+ // Authorization types (object types, actions, tuples, checks) live in
177
+ // `@kindgi/authz`.
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Wire the shared `runOrgBindingConformance` suite against the
6
+ * in-memory reference adapter.
7
+ */
8
+
9
+ import { makeInMemoryOrgBinding } from '../src/in-memory/org-binding.js';
10
+ import { runOrgBindingConformance } from './org-binding.conformance.js';
11
+
12
+ runOrgBindingConformance(makeInMemoryOrgBinding, 'InMemoryOrgBinding');
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Wire the shared `runProjectBindingConformance` suite against the
6
+ * in-memory reference adapter.
7
+ */
8
+
9
+ import { makeInMemoryProjectBinding } from '../src/in-memory/project-binding.js';
10
+ import { runProjectBindingConformance } from './project-binding.conformance.js';
11
+
12
+ runProjectBindingConformance(makeInMemoryProjectBinding, 'InMemoryProjectBinding');
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Wire the shared `runTeamBindingConformance` suite against the
6
+ * in-memory reference adapter.
7
+ */
8
+
9
+ import { makeInMemoryTeamBinding } from '../src/in-memory/team-binding.js';
10
+ import { runTeamBindingConformance } from './team-binding.conformance.js';
11
+
12
+ runTeamBindingConformance(makeInMemoryTeamBinding, 'InMemoryTeamBinding');
@@ -0,0 +1,165 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Conformance suite for `OrgBinding`. Any adapter (the in-memory
6
+ * reference impl; a Postgres impl) invokes
7
+ * `runOrgBindingConformance(makeBinding)` and inherits the full
8
+ * behavioural contract.
9
+ *
10
+ * Cases covered:
11
+ * - CRUD happy paths (create → get → list → update → delete).
12
+ * - Cross-tenant isolation: an Org in tenant A never surfaces in
13
+ * tenant B's list/get. This is the guardrail, not a feature —
14
+ * asserted at every reachable read op.
15
+ * - Pagination via `Filter.cursor`.
16
+ * - `nameContains` substring filter.
17
+ * - `get`/`update`/`delete` behaviour on unknown orgIds.
18
+ */
19
+
20
+ import { describe, expect, it } from 'vitest';
21
+
22
+ import type { OrgId, TenantId } from '@kindgi/types';
23
+
24
+ import type { OrgBinding } from '../src/org-binding.js';
25
+
26
+ const T1 = 'tenant-1' as TenantId;
27
+ const T2 = 'tenant-2' as TenantId;
28
+
29
+ export function runOrgBindingConformance(
30
+ makeBinding: () => OrgBinding,
31
+ label = 'OrgBinding',
32
+ ): void {
33
+ describe(`${label} — CRUD happy paths`, () => {
34
+ it('creates + gets an Org', async () => {
35
+ const b = makeBinding();
36
+ const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
37
+ const got = await b.get(T1, id);
38
+ expect(got).toBeDefined();
39
+ expect(got?.id).toBe(id);
40
+ expect(got?.tenantId).toBe(T1);
41
+ expect(got?.name).toBe('Acme');
42
+ expect(got?.slug).toBe('acme');
43
+ });
44
+
45
+ it('updates an Org partially', async () => {
46
+ const b = makeBinding();
47
+ const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
48
+ await b.update(T1, id, { name: 'Acme Corp' });
49
+ const got = await b.get(T1, id);
50
+ expect(got?.name).toBe('Acme Corp');
51
+ expect(got?.slug).toBe('acme');
52
+ });
53
+
54
+ it('deletes an Org', async () => {
55
+ const b = makeBinding();
56
+ const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
57
+ await b.delete(T1, id);
58
+ const got = await b.get(T1, id);
59
+ expect(got).toBeUndefined();
60
+ });
61
+
62
+ it('lists orgs (empty tenant)', async () => {
63
+ const b = makeBinding();
64
+ const page = await b.list(T1, {});
65
+ expect(page.items).toEqual([]);
66
+ expect(page.nextCursor).toBeUndefined();
67
+ });
68
+ });
69
+
70
+ describe(`${label} — cross-tenant isolation`, () => {
71
+ it('get on tenant B for tenant-A orgId returns undefined', async () => {
72
+ const b = makeBinding();
73
+ const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
74
+ expect(await b.get(T2, id)).toBeUndefined();
75
+ });
76
+
77
+ it('list on tenant B does not surface tenant-A orgs', async () => {
78
+ const b = makeBinding();
79
+ await b.create(T1, { name: 'Acme', slug: 'acme' });
80
+ await b.create(T1, { name: 'Globex', slug: 'globex' });
81
+ const t2Page = await b.list(T2, {});
82
+ expect(t2Page.items).toEqual([]);
83
+ });
84
+
85
+ it('update on tenant B for tenant-A orgId errors', async () => {
86
+ const b = makeBinding();
87
+ const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
88
+ await expect(b.update(T2, id, { name: 'X' })).rejects.toThrow();
89
+ });
90
+
91
+ it('delete on tenant B for tenant-A orgId is a no-op (does not mutate A)', async () => {
92
+ const b = makeBinding();
93
+ const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
94
+ await b.delete(T2, id); // no-op
95
+ const got = await b.get(T1, id);
96
+ expect(got).toBeDefined();
97
+ });
98
+ });
99
+
100
+ describe(`${label} — pagination`, () => {
101
+ it('paginates via Filter.cursor', async () => {
102
+ const b = makeBinding();
103
+ const created: OrgId[] = [];
104
+ for (let i = 0; i < 5; i += 1) {
105
+ created.push(await b.create(T1, { name: `Org${i}`, slug: `org-${i}` }));
106
+ }
107
+ const page1 = await b.list(T1, { limit: 2 });
108
+ expect(page1.items).toHaveLength(2);
109
+ expect(page1.nextCursor).toBeDefined();
110
+
111
+ const page2 = await b.list(T1, {
112
+ limit: 2,
113
+ ...(page1.nextCursor !== undefined ? { cursor: page1.nextCursor } : {}),
114
+ });
115
+ expect(page2.items).toHaveLength(2);
116
+ expect(page2.nextCursor).toBeDefined();
117
+
118
+ const page3 = await b.list(T1, {
119
+ limit: 2,
120
+ ...(page2.nextCursor !== undefined ? { cursor: page2.nextCursor } : {}),
121
+ });
122
+ expect(page3.items).toHaveLength(1);
123
+ expect(page3.nextCursor).toBeUndefined();
124
+
125
+ const seen = new Set<string>();
126
+ for (const p of [page1, page2, page3]) {
127
+ for (const item of p.items) seen.add(item.id);
128
+ }
129
+ expect(seen.size).toBe(5);
130
+ });
131
+ });
132
+
133
+ describe(`${label} — nameContains filter`, () => {
134
+ it('narrows to orgs whose name contains the substring', async () => {
135
+ const b = makeBinding();
136
+ await b.create(T1, { name: 'Litigation', slug: 'lit' });
137
+ await b.create(T1, { name: 'Corporate', slug: 'corp' });
138
+ await b.create(T1, { name: 'Litigation-Sub', slug: 'lit-sub' });
139
+
140
+ const page = await b.list(T1, { nameContains: 'Litigation' });
141
+ expect(page.items).toHaveLength(2);
142
+ const names = page.items.map((o) => o.name).sort();
143
+ expect(names).toEqual(['Litigation', 'Litigation-Sub']);
144
+ });
145
+ });
146
+
147
+ describe(`${label} — not-found behaviour`, () => {
148
+ const unknown = 'org-unknown' as OrgId;
149
+
150
+ it('get on unknown orgId returns undefined', async () => {
151
+ const b = makeBinding();
152
+ expect(await b.get(T1, unknown)).toBeUndefined();
153
+ });
154
+
155
+ it('update on unknown orgId errors', async () => {
156
+ const b = makeBinding();
157
+ await expect(b.update(T1, unknown, { name: 'X' })).rejects.toThrow();
158
+ });
159
+
160
+ it('delete on unknown orgId is a no-op', async () => {
161
+ const b = makeBinding();
162
+ await expect(b.delete(T1, unknown)).resolves.toBeUndefined();
163
+ });
164
+ });
165
+ }