@@ -936,6 +936,231 @@ export class HttpDispatcher {
936936 return { handled : false } ;
937937 }
938938
939+ /**
940+ * Cloud / Environment Control-Plane routes.
941+ *
942+ * - GET /cloud/environments → list
943+ * - POST /cloud/environments → provision
944+ * - GET /cloud/environments/:id → detail (+ db, credential, membership)
945+ * - PATCH /cloud/environments/:id → update displayName / plan / status / isDefault / metadata
946+ * - POST /cloud/environments/:id/activate → mark as active for session (stub)
947+ * - POST /cloud/environments/:id/credentials/rotate → rotate credential
948+ * - GET /cloud/environments/:id/members → list members
949+ *
950+ * Backed by ObjectQL sys__environment / sys__environment_database /
951+ * sys__database_credential / sys__environment_member tables (registered
952+ * by `@objectstack/service-tenant`'s `createTenantPlugin`).
953+ */
954+ async handleCloud ( path : string , method : string , body : any , query : any , _context : HttpProtocolContext ) : Promise < HttpDispatcherResult > {
955+ const m = method . toUpperCase ( ) ;
956+ const parts = path . replace ( / ^ \/ + / , '' ) . split ( '/' ) . filter ( Boolean ) ;
957+
958+ const qlService = await this . getObjectQLService ( ) ;
959+ const ql = qlService ?? await this . resolveService ( 'objectql' ) ;
960+ if ( ! ql ) {
961+ return { handled : true , response : this . error ( 'Environment service not available (ObjectQL missing)' , 503 ) } ;
962+ }
963+
964+ const ENV = 'sys__environment' ;
965+ const DB = 'sys__environment_database' ;
966+ const CRED = 'sys__database_credential' ;
967+ const MEM = 'sys__environment_member' ;
968+
969+ const findOne = async ( obj : string , where : Record < string , unknown > ) : Promise < any | undefined > => {
970+ let rows = await ql . find ( obj , { where } as any ) ;
971+ if ( rows && ( rows as any ) . value ) rows = ( rows as any ) . value ;
972+ if ( ! Array . isArray ( rows ) ) return undefined ;
973+ return rows [ 0 ] ;
974+ } ;
975+
976+ try {
977+ // ----- /cloud/environments collection routes -----
978+ if ( parts . length === 1 && parts [ 0 ] === 'environments' && m === 'GET' ) {
979+ const where : Record < string , unknown > = { } ;
980+ if ( query ?. organizationId ) where . organization_id = query . organizationId ;
981+ if ( query ?. envType ) where . env_type = query . envType ;
982+ if ( query ?. status ) where . status = query . status ;
983+ let rows = await ql . find ( ENV , Object . keys ( where ) . length ? ( { where } as any ) : undefined ) ;
984+ if ( rows && ( rows as any ) . value ) rows = ( rows as any ) . value ;
985+ const environments = Array . isArray ( rows ) ? rows : [ ] ;
986+ return { handled : true , response : this . success ( { environments, total : environments . length } ) } ;
987+ }
988+
989+ if ( parts . length === 1 && parts [ 0 ] === 'environments' && m === 'POST' ) {
990+ const req = body || { } ;
991+ if ( ! req . organizationId || ! req . slug || ! req . displayName || ! req . envType ) {
992+ return { handled : true , response : this . error ( 'organizationId, slug, displayName, envType are required' , 400 ) } ;
993+ }
994+ const environmentId = randomUUID ( ) ;
995+ const environmentDatabaseId = randomUUID ( ) ;
996+ const credentialId = randomUUID ( ) ;
997+ const nowIso = new Date ( ) . toISOString ( ) ;
998+ const driver = req . driver ?? 'turso' ;
999+ const region = req . region ?? 'us-east-1' ;
1000+ const databaseName = `env-${ environmentId } ` ;
1001+ const databaseUrl = `libsql://${ databaseName } .mock-${ driver } .local` ;
1002+ const plaintextSecret = `mock-token-${ environmentId } ` ;
1003+
1004+ await ql . insert ( ENV , {
1005+ id : environmentId ,
1006+ organization_id : req . organizationId ,
1007+ slug : req . slug ,
1008+ display_name : req . displayName ,
1009+ env_type : req . envType ,
1010+ is_default : req . isDefault ?? false ,
1011+ region,
1012+ plan : req . plan ?? 'free' ,
1013+ status : 'active' ,
1014+ created_by : req . createdBy ?? 'system' ,
1015+ metadata : req . metadata ? JSON . stringify ( req . metadata ) : null ,
1016+ created_at : nowIso ,
1017+ updated_at : nowIso ,
1018+ } ) ;
1019+
1020+ await ql . insert ( DB , {
1021+ id : environmentDatabaseId ,
1022+ environment_id : environmentId ,
1023+ database_name : databaseName ,
1024+ database_url : databaseUrl ,
1025+ driver,
1026+ region,
1027+ storage_limit_mb : req . storageLimitMb ?? 1024 ,
1028+ provisioned_at : nowIso ,
1029+ created_at : nowIso ,
1030+ updated_at : nowIso ,
1031+ } ) ;
1032+
1033+ await ql . insert ( CRED , {
1034+ id : credentialId ,
1035+ environment_database_id : environmentDatabaseId ,
1036+ secret_ciphertext : plaintextSecret ,
1037+ encryption_key_id : 'noop' ,
1038+ authorization : 'full_access' ,
1039+ status : 'active' ,
1040+ created_at : nowIso ,
1041+ updated_at : nowIso ,
1042+ } ) ;
1043+
1044+ const environment = await findOne ( ENV , { id : environmentId } ) ;
1045+ const database = await findOne ( DB , { id : environmentDatabaseId } ) ;
1046+ const res = this . success ( { environment, database } ) ;
1047+ res . status = 201 ;
1048+ return { handled : true , response : res } ;
1049+ }
1050+
1051+ // ----- /cloud/environments/:id -----
1052+ if ( parts . length === 2 && parts [ 0 ] === 'environments' ) {
1053+ const id = decodeURIComponent ( parts [ 1 ] ) ;
1054+
1055+ if ( m === 'GET' ) {
1056+ const environment = await findOne ( ENV , { id } ) ;
1057+ if ( ! environment ) return { handled : true , response : this . error ( `Environment '${ id } ' not found` , 404 ) } ;
1058+ const database = await findOne ( DB , { environment_id : id } ) ;
1059+ const credential = database
1060+ ? await findOne ( CRED , { environment_database_id : database . id , status : 'active' } )
1061+ : undefined ;
1062+ const membership = await findOne ( MEM , { environment_id : id } ) ;
1063+ // Omit the ciphertext from responses — metadata only.
1064+ const credMeta = credential
1065+ ? {
1066+ id : credential . id ,
1067+ status : credential . status ,
1068+ authorization : credential . authorization ,
1069+ activatedAt : credential . created_at ,
1070+ expiresAt : credential . expires_at ,
1071+ }
1072+ : undefined ;
1073+ return {
1074+ handled : true ,
1075+ response : this . success ( { environment, database, credential : credMeta , membership } ) ,
1076+ } ;
1077+ }
1078+
1079+ if ( m === 'PATCH' ) {
1080+ const patch : Record < string , unknown > = { } ;
1081+ if ( body ?. displayName !== undefined ) patch . display_name = body . displayName ;
1082+ if ( body ?. plan !== undefined ) patch . plan = body . plan ;
1083+ if ( body ?. status !== undefined ) patch . status = body . status ;
1084+ if ( body ?. isDefault !== undefined ) patch . is_default = body . isDefault ;
1085+ if ( body ?. metadata !== undefined ) patch . metadata = JSON . stringify ( body . metadata ) ;
1086+ patch . updated_at = new Date ( ) . toISOString ( ) ;
1087+ await ql . update ( ENV , patch , { where : { id } } as any ) ;
1088+ const environment = await findOne ( ENV , { id } ) ;
1089+ if ( ! environment ) return { handled : true , response : this . error ( `Environment '${ id } ' not found` , 404 ) } ;
1090+ return { handled : true , response : this . success ( { environment } ) } ;
1091+ }
1092+ }
1093+
1094+ // ----- /cloud/environments/:id/activate -----
1095+ if ( parts . length === 3 && parts [ 0 ] === 'environments' && parts [ 2 ] === 'activate' && m === 'POST' ) {
1096+ const id = decodeURIComponent ( parts [ 1 ] ) ;
1097+ const environment = await findOne ( ENV , { id } ) ;
1098+ if ( ! environment ) return { handled : true , response : this . error ( `Environment '${ id } ' not found` , 404 ) } ;
1099+ // TODO: persist active_environment_id on the session once session service is wired.
1100+ return { handled : true , response : this . success ( { environment, sessionUpdated : false } ) } ;
1101+ }
1102+
1103+ // ----- /cloud/environments/:id/credentials/rotate -----
1104+ if ( parts . length === 4 && parts [ 0 ] === 'environments' && parts [ 2 ] === 'credentials' && parts [ 3 ] === 'rotate' && m === 'POST' ) {
1105+ const id = decodeURIComponent ( parts [ 1 ] ) ;
1106+ const plaintext = body ?. plaintext ;
1107+ if ( ! plaintext || typeof plaintext !== 'string' ) {
1108+ return { handled : true , response : this . error ( 'plaintext is required' , 400 ) } ;
1109+ }
1110+ const database = await findOne ( DB , { environment_id : id } ) ;
1111+ if ( ! database ) return { handled : true , response : this . error ( `No database for environment '${ id } '` , 404 ) } ;
1112+
1113+ const nowIso = new Date ( ) . toISOString ( ) ;
1114+ // Revoke existing active credentials
1115+ let existing = await ql . find ( CRED , { where : { environment_database_id : database . id , status : 'active' } } as any ) ;
1116+ if ( existing && ( existing as any ) . value ) existing = ( existing as any ) . value ;
1117+ for ( const row of ( Array . isArray ( existing ) ? existing : [ ] ) ) {
1118+ await ql . update ( CRED , {
1119+ status : 'revoked' ,
1120+ revoked_at : nowIso ,
1121+ updated_at : nowIso ,
1122+ } , { where : { id : row . id } } as any ) ;
1123+ }
1124+
1125+ const credentialId = randomUUID ( ) ;
1126+ await ql . insert ( CRED , {
1127+ id : credentialId ,
1128+ environment_database_id : database . id ,
1129+ secret_ciphertext : plaintext ,
1130+ encryption_key_id : 'noop' ,
1131+ authorization : 'full_access' ,
1132+ status : 'active' ,
1133+ created_at : nowIso ,
1134+ updated_at : nowIso ,
1135+ } ) ;
1136+
1137+ const credential = await findOne ( CRED , { id : credentialId } ) ;
1138+ const credMeta = credential
1139+ ? {
1140+ id : credential . id ,
1141+ status : credential . status ,
1142+ authorization : credential . authorization ,
1143+ activatedAt : credential . created_at ,
1144+ }
1145+ : undefined ;
1146+ return { handled : true , response : this . success ( { credential : credMeta } ) } ;
1147+ }
1148+
1149+ // ----- /cloud/environments/:id/members -----
1150+ if ( parts . length === 3 && parts [ 0 ] === 'environments' && parts [ 2 ] === 'members' && m === 'GET' ) {
1151+ const id = decodeURIComponent ( parts [ 1 ] ) ;
1152+ let rows = await ql . find ( MEM , { where : { environment_id : id } } as any ) ;
1153+ if ( rows && ( rows as any ) . value ) rows = ( rows as any ) . value ;
1154+ const members = Array . isArray ( rows ) ? rows : [ ] ;
1155+ return { handled : true , response : this . success ( { members } ) } ;
1156+ }
1157+ } catch ( e : any ) {
1158+ return { handled : true , response : this . error ( e . message , e . statusCode || 500 ) } ;
1159+ }
1160+
1161+ return { handled : false } ;
1162+ }
1163+
9391164
9401165
9411166 /**
@@ -1370,6 +1595,10 @@ export class HttpDispatcher {
13701595 return this . handlePackages ( cleanPath . substring ( 9 ) , method , body , query , context ) ;
13711596 }
13721597
1598+ if ( cleanPath . startsWith ( '/cloud' ) ) {
1599+ return this . handleCloud ( cleanPath . substring ( 6 ) , method , body , query , context ) ;
1600+ }
1601+
13731602 if ( cleanPath . startsWith ( '/i18n' ) ) {
13741603 return this . handleI18n ( cleanPath . substring ( 5 ) , method , query , context ) ;
13751604 }
0 commit comments